From 8f49f2a0214de38c8f7714e315e3a01d23c26271 Mon Sep 17 00:00:00 2001 From: devaskim Date: Tue, 1 Nov 2022 16:14:39 +0500 Subject: [PATCH 01/84] Introduce NOT option for relation query. --- .../data/query/RelationsQueryFilter.java | 1 + .../relation/RelationEntityTypeFilter.java | 15 ++++++- .../query/DefaultEntityQueryRepository.java | 21 +++++++--- .../entity/entity-filter.component.html | 3 ++ .../entity/entity-filter.component.ts | 3 +- .../relation/relation-filters.component.html | 41 +++++++++++-------- .../relation/relation-filters.component.ts | 6 ++- ui-ngx/src/app/shared/models/alias.models.ts | 1 + .../src/app/shared/models/relation.models.ts | 1 + .../assets/locale/locale.constant-en_US.json | 4 +- 10 files changed, 66 insertions(+), 30 deletions(-) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/RelationsQueryFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/RelationsQueryFilter.java index 2bed46a63b..ea9f031de5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/RelationsQueryFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/RelationsQueryFilter.java @@ -40,5 +40,6 @@ public class RelationsQueryFilter implements EntityFilter { private List filters; private int maxLevel; private boolean fetchLastLevelOnly; + private boolean negate; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/relation/RelationEntityTypeFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/relation/RelationEntityTypeFilter.java index 6bac4c9a0f..18786250a5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/relation/RelationEntityTypeFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/relation/RelationEntityTypeFilter.java @@ -17,7 +17,6 @@ package org.thingsboard.server.common.data.relation; import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; -import lombok.AllArgsConstructor; import lombok.Data; import org.thingsboard.server.common.data.EntityType; @@ -27,13 +26,25 @@ import java.util.List; * Created by ashvayka on 02.05.17. */ @Data -@AllArgsConstructor @ApiModel public class RelationEntityTypeFilter { + public RelationEntityTypeFilter(String relationType, List entityTypes) { + this(relationType, entityTypes, false); + } + + public RelationEntityTypeFilter(String relationType, List entityTypes, boolean negate) { + this.relationType = relationType; + this.entityTypes = entityTypes; + this.negate = negate; + } + @ApiModelProperty(position = 1, value = "Type of the relation between root entity and other entity (e.g. 'Contains' or 'Manages').", example = "Contains") private String relationType; @ApiModelProperty(position = 2, value = "Array of entity types to filter the related entities (e.g. 'DEVICE', 'ASSET').") private List entityTypes; + + @ApiModelProperty(position = 3, value = "Negate relation type between root entity and other entity.") + private boolean negate; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java index 1963190aa5..be91d78704 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java @@ -704,10 +704,16 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { .append(entityFilter.getDirection().equals(EntitySearchDirection.FROM) ? "to" : "from") .append("_type in (:where_entity_types").append(")"); ctx.addStringListParameter("where_entity_types", Arrays.stream(RELATION_QUERY_ENTITY_TYPES).map(EntityType::name).collect(Collectors.toList())); - } - - if (!noConditions && !single) { - whereFilter = new StringBuilder().append("(").append(whereFilter).append(")"); + } else { + if (!single) { + whereFilter = new StringBuilder() + .append(entityFilter.isNegate() ? " NOT (" : "(") + .append(whereFilter).append(")"); + } else if (entityFilter.isNegate()) { + whereFilter = new StringBuilder() + .append(" NOT ") + .append(whereFilter).append(")"); + } } if (entityFilter.isFetchLastLevelOnly()) { @@ -743,8 +749,11 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { boolean hasRelationType = !StringUtils.isEmpty(relationType); if (hasRelationType) { ctx.addStringParameter("where_relation_type" + entityTypeFilterIdx, relationType); - whereFilter - .append("re.relation_type = :where_relation_type").append(entityTypeFilterIdx); + if (etf.isNegate()) { + whereFilter.append("re.relation_type != :where_relation_type").append(entityTypeFilterIdx); + } else { + whereFilter.append("re.relation_type = :where_relation_type").append(entityTypeFilterIdx); + } } if (!whereEntityTypes.isEmpty()) { if (hasRelationType) { diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.html b/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.html index 6f588f34e5..85ac9febf5 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.html +++ b/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.html @@ -185,6 +185,9 @@ formControlName="maxLevel"> + + {{ 'relation.negate-all-relations' | translate }} +
relation.relation-filters
{ this.filterFormGroup.get('rootEntity').setValidators(rootStateEntity ? [] : [Validators.required]); diff --git a/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.html b/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.html index 1f5d6273ec..095cfe6eba 100644 --- a/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.html +++ b/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.html @@ -28,24 +28,29 @@
-
- - - - - +
+
+ + + + + +
+ + {{ 'relation.negate-relation' | translate }} +
diff --git a/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.ts b/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.ts index 911aa4fe30..71d4350b4f 100644 --- a/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.ts +++ b/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.ts @@ -104,7 +104,8 @@ export class RelationFiltersComponent extends PageComponent implements ControlVa const relationFiltersFormArray = this.relationFiltersFormGroup.get('relationFilters') as FormArray; const filter: RelationEntityTypeFilter = { relationType: null, - entityTypes: [] + entityTypes: [], + negate: false }; relationFiltersFormArray.push(this.createRelationFilterFormGroup(filter)); } @@ -112,7 +113,8 @@ export class RelationFiltersComponent extends PageComponent implements ControlVa private createRelationFilterFormGroup(filter: RelationEntityTypeFilter): AbstractControl { return this.fb.group({ relationType: [filter ? filter.relationType : null], - entityTypes: [filter ? filter.entityTypes : []] + entityTypes: [filter ? filter.entityTypes : []], + negate: [filter ? filter.negate : false], }); } diff --git a/ui-ngx/src/app/shared/models/alias.models.ts b/ui-ngx/src/app/shared/models/alias.models.ts index f67c0d50a7..c3322cda16 100644 --- a/ui-ngx/src/app/shared/models/alias.models.ts +++ b/ui-ngx/src/app/shared/models/alias.models.ts @@ -114,6 +114,7 @@ export interface RelationsQueryFilter { filters?: Array; maxLevel?: number; fetchLastLevelOnly?: boolean; + negate?: boolean; } export interface EntitySearchQueryFilter { diff --git a/ui-ngx/src/app/shared/models/relation.models.ts b/ui-ngx/src/app/shared/models/relation.models.ts index e42b915e9e..27920f275a 100644 --- a/ui-ngx/src/app/shared/models/relation.models.ts +++ b/ui-ngx/src/app/shared/models/relation.models.ts @@ -55,6 +55,7 @@ export const directionTypeTranslations = new Map( export interface RelationEntityTypeFilter { relationType: string; entityTypes: Array; + negate?: boolean; } export interface RelationsSearchParameters { diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index e8c322c650..b053cd83a6 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -2840,7 +2840,9 @@ "relation-filters": "Relation filters", "additional-info": "Additional info (JSON)", "invalid-additional-info": "Unable to parse additional info json.", - "no-relations-text": "No relations found" + "no-relations-text": "No relations found", + "negate-all-relations": "Negate all relations (NOT condition)", + "negate-relation": "Negate this relation (NOT condition)" }, "resource": { "add": "Add Resource", From 11eca7684aa82227ecb655495b6d372d4b725522 Mon Sep 17 00:00:00 2001 From: devaskim Date: Tue, 1 Nov 2022 20:49:49 +0500 Subject: [PATCH 02/84] Add default constructor to fix deserialization issue. --- .../server/common/data/relation/RelationEntityTypeFilter.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/relation/RelationEntityTypeFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/relation/RelationEntityTypeFilter.java index 18786250a5..e8922c4c94 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/relation/RelationEntityTypeFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/relation/RelationEntityTypeFilter.java @@ -29,6 +29,8 @@ import java.util.List; @ApiModel public class RelationEntityTypeFilter { + public RelationEntityTypeFilter() {} + public RelationEntityTypeFilter(String relationType, List entityTypes) { this(relationType, entityTypes, false); } From e817542db74fe832e6b0c479a17bf3302d4bf979 Mon Sep 17 00:00:00 2001 From: YevhenBondarenko Date: Tue, 12 Dec 2023 17:37:54 +0100 Subject: [PATCH 03/84] added tests and fix --- .../BaseEntityQueryControllerTest.java | 63 +++++++++++++++++++ .../query/DefaultEntityQueryRepository.java | 2 +- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseEntityQueryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseEntityQueryControllerTest.java index 8c692ef8a2..f1a342b767 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseEntityQueryControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseEntityQueryControllerTest.java @@ -46,7 +46,11 @@ 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.RelationsQueryFilter; import org.thingsboard.server.common.data.query.TsValue; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter; import org.thingsboard.server.common.data.security.Authority; import java.util.ArrayList; @@ -224,6 +228,65 @@ public abstract class BaseEntityQueryControllerTest extends AbstractControllerTe } + @Test + public void testFindEntityDataByQueryWithNegateParam() 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); + } + + Device mainDevice = new Device(); + mainDevice.setName("Main device"); + mainDevice = doPost("/api/device", mainDevice, Device.class); + + for (int i = 0; i < 10; i++) { + EntityRelation relation = createFromRelation(mainDevice, devices.get(i), "CONTAINS"); + doPost("/api/relation", relation).andExpect(status().isOk()); + } + + for (int i = 10; i < 97; i++) { + EntityRelation relation = createFromRelation(mainDevice, devices.get(i), "NOT_CONTAINS"); + doPost("/api/relation", relation).andExpect(status().isOk()); + } + + RelationsQueryFilter filter = new RelationsQueryFilter(); + filter.setRootEntity(mainDevice.getId()); + filter.setDirection(EntitySearchDirection.FROM); + filter.setNegate(true); + filter.setFilters(List.of(new RelationEntityTypeFilter("CONTAINS", List.of(EntityType.DEVICE), false))); + + 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(87, data.getTotalElements()); + + filter.setFilters(List.of(new RelationEntityTypeFilter("NOT_CONTAINS", List.of(EntityType.DEVICE), false))); + query = new EntityDataQuery(filter, pageLink, entityFields, null, null); + data = doPostWithTypedResponse("/api/entitiesQuery/find", query, new TypeReference<>() {}); + Assert.assertEquals(10, data.getTotalElements()); + + filter.setFilters(List.of(new RelationEntityTypeFilter("NOT_CONTAINS", List.of(EntityType.DEVICE), true))); + query = new EntityDataQuery(filter, pageLink, entityFields, null, null); + data = doPostWithTypedResponse("/api/entitiesQuery/find", query, new TypeReference<>() {}); + Assert.assertEquals(87, data.getTotalElements()); + } + + private EntityRelation createFromRelation(Device mainDevice, Device device, String relationType) { + return new EntityRelation(mainDevice.getId(), device.getId(), relationType); + } + @Test public void testFindEntityDataByQueryWithAttributes() throws Exception { List devices = new ArrayList<>(); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java index be91d78704..cdae7eff0f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java @@ -711,7 +711,7 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { .append(whereFilter).append(")"); } else if (entityFilter.isNegate()) { whereFilter = new StringBuilder() - .append(" NOT ") + .append(" NOT (") .append(whereFilter).append(")"); } } From 0e7437ff0fbf8419cafb9252d62e35323ae33de5 Mon Sep 17 00:00:00 2001 From: rusikv Date: Wed, 6 Mar 2024 13:17:27 +0200 Subject: [PATCH 04/84] UI: add 'not' option to relation queries, redesign of relation queries --- .../alias/entity-alias-dialog.component.html | 49 +++++----- .../alias/entity-alias-dialog.component.scss | 15 ++- .../entity/entity-filter.component.html | 98 ++++++++----------- .../entity/entity-filter.component.ts | 3 +- .../relation/relation-filters.component.html | 6 +- .../relation/relation-filters.component.scss | 4 + .../entity/entity-select.component.html | 4 +- .../entity/entity-type-select.component.html | 3 +- ui-ngx/src/app/shared/models/alias.models.ts | 1 - .../assets/locale/locale.constant-en_US.json | 4 +- ui-ngx/src/form.scss | 3 + 11 files changed, 88 insertions(+), 102 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.html b/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.html index e13a0c9fa6..d6edb9b4cd 100644 --- a/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.html @@ -28,34 +28,31 @@
-
-
-
- - alias.name - - - {{ 'alias.name-required' | translate }} - - - {{ 'alias.duplicate-alias' | translate }} - - -
- - - -
+
+
+ + alias.name + + + {{ 'alias.name-required' | translate }} + + + {{ 'alias.duplicate-alias' | translate }} + + +
+ + {{ 'alias.resolve-multiple' | translate }} +
- - -
+ + +
diff --git a/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.scss b/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.scss index 51a25742b7..969729c373 100644 --- a/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.scss @@ -13,16 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +@import '../../../../../scss/constants'; + :host { .tb-resolve-multiple-switch { - padding-left: 10px; - - .resolve-multiple-switch { - margin: 0; - } - - .resolve-multiple-label { - margin: 5px 0; + padding: 18px 0 0 18px; + @media #{$mat-xs} { + padding: 0 0 18px 0; } } -} \ No newline at end of file +} diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.html b/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.html index fc05985697..75bdcb43c8 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.html +++ b/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.html @@ -130,70 +130,56 @@ -
- -
- +
+
+
alias.root-entity
+ + {{ 'alias.root-state-entity' | translate }} - -
-
- + + alias.state-entity-parameter-name + + + + +
+ -
-
- - alias.state-entity-parameter-name - - -
- - - +
+ + relation.direction + + + {{ directionTypeTranslations.get(directionTypeEnum[type]) | translate }} + + + + + alias.max-relation-level + +
+ + {{ 'alias.last-level-relation' | translate }} +
-
-
- - - -
-
-
- - relation.direction - - - {{ directionTypeTranslations.get(directionTypeEnum[type]) | translate }} - - - - - alias.max-relation-level - - +
+
relation.relation-filters
+ +
- - {{ 'relation.negate-all-relations' | translate }} - -
relation.relation-filters
- -
+
{{ 'relation.relation-type' | translate }}
{{ 'entity.entity-types' | translate }}
-
+ + {{ 'relation.not' | translate}} + -
+
{{ 'entity.type' | translate }} - + {{ displayEntityTypeFn(type) }} diff --git a/ui-ngx/src/app/shared/models/alias.models.ts b/ui-ngx/src/app/shared/models/alias.models.ts index 9fa0d8a244..084646d290 100644 --- a/ui-ngx/src/app/shared/models/alias.models.ts +++ b/ui-ngx/src/app/shared/models/alias.models.ts @@ -130,7 +130,6 @@ export interface RelationsQueryFilter { filters?: Array; maxLevel?: number; fetchLastLevelOnly?: boolean; - negate?: boolean; } export interface EntitySearchQueryFilter { diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 83795384d5..46a4303626 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -637,6 +637,7 @@ "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", + "resolve-multiple-hint": "Enable to render data from all filtered entities simultaneously. \nIf disabled, the widget shows data from the selected entity only.", "filter-type": "Filter type", "filter-type-required": "Filter type is required.", "entity-filter-no-entity-matched": "No entities matching specified filter were found.", @@ -3708,8 +3709,7 @@ "additional-info": "Additional info (JSON)", "invalid-additional-info": "Unable to parse additional info json.", "no-relations-text": "No relations found", - "negate-all-relations": "Negate all relations (NOT condition)", - "negate-relation": "Negate this relation (NOT condition)" + "not": "Not" }, "resource": { "add": "Add resource", diff --git a/ui-ngx/src/form.scss b/ui-ngx/src/form.scss index 524a4e86ac..86f986dd7a 100644 --- a/ui-ngx/src/form.scss +++ b/ui-ngx/src/form.scss @@ -499,6 +499,9 @@ &.no-padding-right { padding-right: 0; } + &.align-start { + align-items: start; + } @media #{$mat-gt-md} { gap: 12px; padding-left: 12px; From e32634f5017f51cc5cd68c14818f8b60b1790b32 Mon Sep 17 00:00:00 2001 From: rusikv Date: Wed, 6 Mar 2024 14:17:33 +0200 Subject: [PATCH 05/84] UI: make 'Not' option in relation queries optional --- .../home/components/entity/entity-filter.component.html | 1 + .../home/components/relation/relation-filters.component.html | 4 ++-- .../home/components/relation/relation-filters.component.ts | 5 +++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.html b/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.html index 75bdcb43c8..61d22dcff4 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.html +++ b/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.html @@ -176,6 +176,7 @@
relation.relation-filters
diff --git a/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.html b/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.html index 9df199be1e..055b5df22e 100644 --- a/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.html +++ b/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.html @@ -18,7 +18,7 @@
-
+
{{ 'relation.relation-type' | translate }}
{{ 'entity.entity-types' | translate }}
@@ -26,7 +26,7 @@
- + {{ 'relation.not' | translate}} ; + @Input() + @coerceBoolean() + enableNotOption = false; + relationFiltersFormGroup: UntypedFormGroup; private destroy$ = new Subject(); From bf6c67bc6a4a3a80a45c7aeca870a34b01dac2fa Mon Sep 17 00:00:00 2001 From: rusikv Date: Wed, 6 Mar 2024 15:44:09 +0200 Subject: [PATCH 06/84] UI: fixed unused form control in relation filters --- .../relation/relation-filters.component.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.ts b/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.ts index 12d2fa8306..3d438f30ad 100644 --- a/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.ts +++ b/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.ts @@ -117,18 +117,20 @@ export class RelationFiltersComponent extends PageComponent implements ControlVa public addFilter() { const filter: RelationEntityTypeFilter = { relationType: null, - entityTypes: [], - negate: false + entityTypes: [] }; this.relationFiltersFormArray.push(this.createRelationFilterFormGroup(filter)); } private createRelationFilterFormGroup(filter: RelationEntityTypeFilter): AbstractControl { - return this.fb.group({ + const formGroup = this.fb.group({ relationType: [filter ? filter.relationType : null], - entityTypes: [filter ? filter.entityTypes : []], - negate: [filter ? filter.negate : false], + entityTypes: [filter ? filter.entityTypes : []] }); + if (this.enableNotOption) { + formGroup.addControl('negate', this.fb.control(filter ? filter.negate : false)); + } + return formGroup; } private updateModel() { From bb027cb98733e1d93e7a739e7ea8f7fb7e419142 Mon Sep 17 00:00:00 2001 From: rusikv Date: Thu, 4 Apr 2024 12:31:48 +0300 Subject: [PATCH 07/84] UI: added pattern support for widget title on export --- .../app/modules/home/models/dashboard-component.models.ts | 5 +++-- ui-ngx/src/app/shared/import-export/import-export.service.ts | 2 +- ui-ngx/src/app/shared/models/widget.models.ts | 1 + 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts b/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts index b7ccbd76bf..81a5a7c834 100644 --- a/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts @@ -26,7 +26,7 @@ import { import { WidgetLayout, WidgetLayouts } from '@app/shared/models/dashboard.models'; import { IDashboardWidget, WidgetAction, WidgetContext, WidgetHeaderAction } from './widget-component.models'; import { Timewindow } from '@shared/models/time/time.models'; -import { Observable, of, Subject } from 'rxjs'; +import { Observable, of, Subject, tap } from 'rxjs'; import { convertKeysToCamelCase, formattedDataFormDatasourceData, @@ -440,7 +440,8 @@ export class DashboardWidget implements GridsterItem, IDashboardWidget { const title = isDefined(this.widgetContext.widgetTitle) && this.widgetContext.widgetTitle.length ? this.widgetContext.widgetTitle : this.widget.config.title; - this.title$ = this.widgetContext.registerLabelPattern(title, this.title$); + this.title$ = this.widgetContext.registerLabelPattern(title, this.title$).pipe( + tap(value => this.widget.config.titleByPattern = value)); this.titleTooltip = isDefined(this.widgetContext.widgetTitleTooltip) && this.widgetContext.widgetTitleTooltip.length ? this.widgetContext.widgetTitleTooltip : this.widget.config.titleTooltip; this.titleTooltip = this.dashboard.utils.customTranslation(this.titleTooltip, this.titleTooltip); diff --git a/ui-ngx/src/app/shared/import-export/import-export.service.ts b/ui-ngx/src/app/shared/import-export/import-export.service.ts index b7d0dd6a25..ea4fd0892c 100644 --- a/ui-ngx/src/app/shared/import-export/import-export.service.ts +++ b/ui-ngx/src/app/shared/import-export/import-export.service.ts @@ -200,7 +200,7 @@ export class ImportExportService { public exportWidget(dashboard: Dashboard, sourceState: string, sourceLayout: DashboardLayoutId, widget: Widget) { const widgetItem = this.itembuffer.prepareWidgetItem(dashboard, sourceState, sourceLayout, widget); - let name = widgetItem.widget.config.title; + let name = widgetItem.widget.config.titleByPattern; name = name.toLowerCase().replace(/\W/g, '_'); this.exportToPc(this.prepareExport(widgetItem), name); } diff --git a/ui-ngx/src/app/shared/models/widget.models.ts b/ui-ngx/src/app/shared/models/widget.models.ts index c736ed7404..b76c2322e3 100644 --- a/ui-ngx/src/app/shared/models/widget.models.ts +++ b/ui-ngx/src/app/shared/models/widget.models.ts @@ -726,6 +726,7 @@ export enum WidgetConfigMode { export interface WidgetConfig { configMode?: WidgetConfigMode; title?: string; + titleByPattern?: string; titleFont?: Font; titleColor?: string; titleIcon?: string; From d0ff4a2d869d2cb91015ce91e1e7b16ad4252c50 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Thu, 18 Apr 2024 11:25:05 +0300 Subject: [PATCH 08/84] mobile app QR code initial implementation --- .../main/data/upgrade/3.6.4/schema_update.sql | 12 +++ .../server/controller/AdminController.java | 31 ++++++ .../controller/MobileAppLinksController.java | 86 +++++++++++++++++ .../server/controller/QRCodeController.java | 94 +++++++++++++++++++ .../service/qr/QRSecretCaffeineCache.java | 33 +++++++ .../server/service/qr/QRSecretEvictEvent.java | 25 +++++ .../server/service/qr/QRSecretRedisCache.java | 36 +++++++ .../server/service/qr/QRService.java | 28 ++++++ .../server/service/qr/QRServiceImpl.java | 66 +++++++++++++ .../service/security/permission/Resource.java | 4 +- .../permission/SysAdminPermissions.java | 1 + .../permission/TenantAdminPermissions.java | 1 + .../system/DefaultSystemSecurityService.java | 4 +- .../src/main/resources/thingsboard.yml | 6 ++ .../controller/AdminControllerTest.java | 24 +++++ .../resources/application-test.properties | 1 + .../server/common/data/CacheConstants.java | 2 + .../common/data/mobile/MobileAppSettings.java | 63 +++++++++++++ .../common/data/security/model/JwtPair.java | 4 +- .../data/security/model/SecuritySettings.java | 2 + .../mobile/BaseMobileAppSettingsService.java | 92 ++++++++++++++++++ .../MobileAppSettingsCaffeineCache.java | 34 +++++++ .../dao/mobile/MobileAppSettingsDao.java | 29 ++++++ .../mobile/MobileAppSettingsEvictEvent.java | 24 +++++ .../mobile/MobileAppSettingsRedisCache.java | 36 +++++++ .../dao/mobile/MobileAppSettingsService.java | 27 ++++++ .../server/dao/model/ModelConstants.java | 9 ++ .../model/sql/MobileAppSettingsEntity.java | 92 ++++++++++++++++++ .../dao/sql/mobile/JpaMobileAppDao.java | 53 +++++++++++ .../mobile/MobileAppSettingsRepository.java | 34 +++++++ .../main/resources/sql/schema-entities.sql | 8 ++ 31 files changed, 957 insertions(+), 4 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/controller/MobileAppLinksController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/QRCodeController.java create mode 100644 application/src/main/java/org/thingsboard/server/service/qr/QRSecretCaffeineCache.java create mode 100644 application/src/main/java/org/thingsboard/server/service/qr/QRSecretEvictEvent.java create mode 100644 application/src/main/java/org/thingsboard/server/service/qr/QRSecretRedisCache.java create mode 100644 application/src/main/java/org/thingsboard/server/service/qr/QRService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/qr/QRServiceImpl.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/mobile/MobileAppSettings.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/mobile/BaseMobileAppSettingsService.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsCaffeineCache.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsEvictEvent.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsRedisCache.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsService.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/MobileAppSettingsEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/mobile/JpaMobileAppDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/mobile/MobileAppSettingsRepository.java diff --git a/application/src/main/data/upgrade/3.6.4/schema_update.sql b/application/src/main/data/upgrade/3.6.4/schema_update.sql index d7d8887d13..99c503d8f9 100644 --- a/application/src/main/data/upgrade/3.6.4/schema_update.sql +++ b/application/src/main/data/upgrade/3.6.4/schema_update.sql @@ -134,3 +134,15 @@ DELETE FROM asset WHERE type='TbServiceQueue'; DELETE FROM asset_profile WHERE name ='TbServiceQueue'; -- QUEUE STATS UPDATE END + +-- MOBILE APPS TABLE CREATE START + +CREATE TABLE IF NOT EXISTS mobile_app_settings ( + tenant_id UUID NOT NULL, + app_package VARCHAR(100) UNIQUE, + sha256_cert_fingerprints VARCHAR(10000), + app_id VARCHAR(100) UNIQUE, + settings VARCHAR(100000) +); + +-- MOBILE APPS TABLE CREATE END \ No newline at end of file diff --git a/application/src/main/java/org/thingsboard/server/controller/AdminController.java b/application/src/main/java/org/thingsboard/server/controller/AdminController.java index 818a2a15a6..2a6593f452 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AdminController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AdminController.java @@ -65,6 +65,7 @@ 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.mobile.MobileAppSettings; import org.thingsboard.server.common.data.security.model.JwtPair; import org.thingsboard.server.common.data.security.model.JwtSettings; import org.thingsboard.server.common.data.security.model.SecuritySettings; @@ -75,6 +76,7 @@ import org.thingsboard.server.common.data.sync.vc.RepositorySettingsInfo; import org.thingsboard.server.common.data.sync.vc.VcUtils; import org.thingsboard.server.config.annotations.ApiOperation; import org.thingsboard.server.dao.audit.AuditLogService; +import org.thingsboard.server.dao.mobile.MobileAppSettingsService; import org.thingsboard.server.dao.settings.AdminSettingsService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.auth.jwt.settings.JwtSettingsService; @@ -95,6 +97,7 @@ import java.util.Optional; 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_OR_CUSTOMER_AUTHORITY_PARAGRAPH; @RestController @TbCoreComponent @@ -119,6 +122,7 @@ public class AdminController extends BaseController { private final UpdateService updateService; private final SystemInfoService systemInfoService; private final AuditLogService auditLogService; + private final MobileAppSettingsService mobileAppSettingsService; @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) @@ -482,4 +486,31 @@ public class AdminController extends BaseController { adminSettingsService.saveAdminSettings(TenantId.SYS_TENANT_ID, adminSettings); response.sendRedirect(prevUri); } + + @ApiOperation(value = "Create Or Update the Mobile application settings (saveMobileAppSettings)", + notes = "The payload contains platform qr code widget settings and associated android and iOS applications." + SYSTEM_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") + @RequestMapping(value = "/mobileAppSettings", method = RequestMethod.POST) + @ResponseStatus(value = HttpStatus.OK) + public MobileAppSettings saveMobileAppSettings(@Parameter(description = "A JSON value representing the mobile apps configuration") + @RequestBody MobileAppSettings mobileAppSettings) throws ThingsboardException { + SecurityUser currentUser = getCurrentUser(); + accessControlService.checkPermission(currentUser, Resource.MOBILE_APP_SETTINGS, Operation.WRITE); + mobileAppSettings.setTenantId(getTenantId()); + + return mobileAppSettingsService.saveMobileAppSettings(currentUser.getTenantId(), mobileAppSettings); + } + + @ApiOperation(value = "Get Mobile application settings (getMobileAppSettings)", + notes = "The payload contains platform qr code widget settings and creds for associated android and iOS applications." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/mobileAppSettings", method = RequestMethod.GET) + @ResponseBody + public MobileAppSettings getMobileAppSettings() throws ThingsboardException { + SecurityUser currentUser = getCurrentUser(); + accessControlService.checkPermission(currentUser, Resource.MOBILE_APP_SETTINGS, Operation.READ); + + return mobileAppSettingsService.getMobileAppSettings(currentUser.getTenantId()); + } + } diff --git a/application/src/main/java/org/thingsboard/server/controller/MobileAppLinksController.java b/application/src/main/java/org/thingsboard/server/controller/MobileAppLinksController.java new file mode 100644 index 0000000000..1265715126 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/MobileAppLinksController.java @@ -0,0 +1,86 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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 lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +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.common.util.JacksonUtil; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.mobile.MobileAppSettings; +import org.thingsboard.server.config.annotations.ApiOperation; +import org.thingsboard.server.dao.mobile.MobileAppSettingsService; +import org.thingsboard.server.queue.util.TbCoreComponent; + +@RequiredArgsConstructor +@RestController +@TbCoreComponent +public class MobileAppLinksController extends BaseController { + + public static final String ASSET_LINKS_PATTERN = "[{\n" + + " \"relation\": [\"delegate_permission/common.handle_all_urls\"],\n" + + " \"target\": {\n" + + " \"namespace\": \"android_app\",\n" + + " \"package_name\": \"%s\",\n" + + " \"sha256_cert_fingerprints\":\n" + + " [\"%s\"]\n" + + " }\n" + + "}]"; + + public static final String APPLE_APP_SITE_ASSOCIATION_PATTERN = "{\n" + + " \"applinks\": {\n" + + " \"apps\": [],\n" + + " \"details\": [\n" + + " {\n" + + " \"appID\": \"%s\",\n" + + " \"paths\": [ \"*\" ]\n" + + " }\n" + + " ]\n" + + " }\n" + + "}"; + + + private final MobileAppSettingsService mobileAppSettingsService; + + + @ApiOperation(value = "Get associated android applications (getAssetLinks)") + @RequestMapping(value = "/.well-known/assetlinks.json", method = RequestMethod.GET) + @ResponseBody + public ResponseEntity getAssetLinks() { + MobileAppSettings mobileAppSettings = mobileAppSettingsService.getMobileAppSettings(TenantId.SYS_TENANT_ID); + if (mobileAppSettings != null && mobileAppSettings.getAppPackage() != null && mobileAppSettings.getSha256CertFingerprints() != null) { + return ResponseEntity.ok(JacksonUtil.toJsonNode(String.format(ASSET_LINKS_PATTERN, mobileAppSettings.getAppPackage(), mobileAppSettings.getSha256CertFingerprints()))); + } else { + return ResponseEntity.notFound().build(); + } + } + + @ApiOperation(value = "Get associated ios applications (getAppleAppSiteAssociation)") + @RequestMapping(value = "/.well-known/apple-app-site-association", method = RequestMethod.GET) + @ResponseBody + public ResponseEntity getAppleAppSiteAssociation() { + MobileAppSettings mobileAppSettings = mobileAppSettingsService.getMobileAppSettings(TenantId.SYS_TENANT_ID); + if (mobileAppSettings != null && mobileAppSettings.getAppId() != null) { + return ResponseEntity.ok(JacksonUtil.toJsonNode(String.format(APPLE_APP_SITE_ASSOCIATION_PATTERN, mobileAppSettings.getAppId()))); + } else { + return ResponseEntity.notFound().build(); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/controller/QRCodeController.java b/application/src/main/java/org/thingsboard/server/controller/QRCodeController.java new file mode 100644 index 0000000000..aed35cb153 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/QRCodeController.java @@ -0,0 +1,94 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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 io.swagger.v3.oas.annotations.Parameter; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +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.ResponseBody; +import org.springframework.web.bind.annotation.RestController; +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.security.model.JwtPair; +import org.thingsboard.server.config.annotations.ApiOperation; +import org.thingsboard.server.dao.mobile.MobileAppSettingsService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.qr.QRService; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.system.SystemSecurityService; + +import java.net.URI; +import java.net.URISyntaxException; + +@RequiredArgsConstructor +@RestController +@TbCoreComponent +@RequestMapping("/api") +public class QRCodeController extends BaseController { + + public static final String SECRET = "secret"; + public static final String SECRET_PARAM_DESCRIPTION = "A string value representing short-live secret key"; + public static final String DEFAULT_APP_DOMAIN = "demo.thingsboard.io"; + public static final String DEEP_LINK_PATTERN = "https://%s/api/noauth/qr?secret=%s"; + private final QRService qrService; + private final SystemSecurityService systemSecurityService; + + private final MobileAppSettingsService mobileAppSettingsService; + + @ApiOperation(value = "Get the deep link to the associated mobile application (getQRCodeDeepLink)", + notes = "Fetch the url that takes user to associated mobile application ") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/qr/deepLink", method = RequestMethod.GET, produces = "text/plain") + @ResponseBody + public String getQRCodeDeepLink(HttpServletRequest request) throws ThingsboardException, URISyntaxException { + SecurityUser currentUser = getCurrentUser(); + String secret = qrService.generateSecret(currentUser); + + String baseUrl = systemSecurityService.getBaseUrl(TenantId.SYS_TENANT_ID, new CustomerId(EntityId.NULL_UUID), request); + String platformDomain = new URI(baseUrl).getHost(); + JsonNode mobileAppSettings = mobileAppSettingsService.getMobileAppSettings(TenantId.SYS_TENANT_ID).getSettings(); + String appDomain; + if (mobileAppSettings != null && mobileAppSettings.get("useDefault") != null + && !mobileAppSettings.get("useDefault").asBoolean()) { + appDomain = platformDomain; + } else { + appDomain = DEFAULT_APP_DOMAIN; + } + String deepLink = String.format(DEEP_LINK_PATTERN, appDomain, secret); + if (!appDomain.equals(platformDomain)) { + deepLink = deepLink + "&host=" + baseUrl; + } + return deepLink; + } + + @ApiOperation(value = "Get User Token (getUserToken)", + notes = "Returns the token of the User based on the provided secret key.") + @RequestMapping(value = "/noauth/qr/{secret}", method = RequestMethod.GET) + @ResponseBody + public JwtPair getUserToken(@Parameter(description = SECRET_PARAM_DESCRIPTION) + @PathVariable(SECRET) String secret) throws ThingsboardException { + checkParameter(SECRET, secret); + return qrService.getJwtPair(secret); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/qr/QRSecretCaffeineCache.java b/application/src/main/java/org/thingsboard/server/service/qr/QRSecretCaffeineCache.java new file mode 100644 index 0000000000..d74307926c --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/qr/QRSecretCaffeineCache.java @@ -0,0 +1,33 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.qr; + +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.security.model.JwtPair; + +@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "caffeine", matchIfMissing = true) +@Service("QRSecretCache") +public class QRSecretCaffeineCache extends CaffeineTbTransactionalCache { + + public QRSecretCaffeineCache(CacheManager cacheManager) { + super(cacheManager, CacheConstants.QR_SECRET_KEY_CACHE); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/qr/QRSecretEvictEvent.java b/application/src/main/java/org/thingsboard/server/service/qr/QRSecretEvictEvent.java new file mode 100644 index 0000000000..bf0c7b67fe --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/qr/QRSecretEvictEvent.java @@ -0,0 +1,25 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.qr; + +import lombok.Data; + +@Data +public class QRSecretEvictEvent { + + private final String secret; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/qr/QRSecretRedisCache.java b/application/src/main/java/org/thingsboard/server/service/qr/QRSecretRedisCache.java new file mode 100644 index 0000000000..8efe664187 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/qr/QRSecretRedisCache.java @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.qr; + +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.TbJsonRedisSerializer; +import org.thingsboard.server.common.data.CacheConstants; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.security.model.JwtPair; + +@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "redis") +@Service("QRSecretCache") +public class QRSecretRedisCache extends RedisTbTransactionalCache { + + public QRSecretRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) { + super(CacheConstants.QR_SECRET_KEY_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbJsonRedisSerializer<>(JwtPair.class)); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/qr/QRService.java b/application/src/main/java/org/thingsboard/server/service/qr/QRService.java new file mode 100644 index 0000000000..9502d3440b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/qr/QRService.java @@ -0,0 +1,28 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.qr; + +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.security.model.JwtPair; +import org.thingsboard.server.service.security.model.SecurityUser; + +public interface QRService { + + String generateSecret(SecurityUser securityUser); + + JwtPair getJwtPair(String secret) throws ThingsboardException; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/qr/QRServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/qr/QRServiceImpl.java new file mode 100644 index 0000000000..36bf72c74e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/qr/QRServiceImpl.java @@ -0,0 +1,66 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.qr; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionalEventListener; +import org.thingsboard.server.cache.TbCacheValueWrapper; +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.security.model.JwtPair; +import org.thingsboard.server.dao.entity.AbstractCachedService; +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 static org.thingsboard.server.service.security.system.DefaultSystemSecurityService.DEFAULT_QR_SECRET_KEY_LENGTH; + +@Service +@Slf4j +@RequiredArgsConstructor +public class QRServiceImpl extends AbstractCachedService implements QRService { + + private final JwtTokenFactory tokenFactory; + private final SystemSecurityService systemSecurityService; + + @Override + public String generateSecret(SecurityUser securityUser) { + log.trace("Executing generateSecret for user [{}]", securityUser.getId()); + Integer qrSecretKeyLength = systemSecurityService.getSecuritySettings().getQrSecretKeyLength(); + String secret = StringUtils.generateSafeToken(qrSecretKeyLength == null ? DEFAULT_QR_SECRET_KEY_LENGTH : qrSecretKeyLength); + cache.put(secret, tokenFactory.createTokenPair(securityUser)); + return secret; + } + + @Override + public JwtPair getJwtPair(String secret) throws ThingsboardException { + TbCacheValueWrapper jwtPair = cache.get(secret); + if (jwtPair != null) { + return jwtPair.get(); + } else { + throw new ThingsboardException("Jwt token not found or expired!", ThingsboardErrorCode.JWT_TOKEN_EXPIRED); + } + } + + @TransactionalEventListener(classes = QRSecretEvictEvent.class) + @Override + public void handleEvictEvent(QRSecretEvictEvent event) { + cache.evict(event.getSecret()); + } +} 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 index 5aa869f2a8..d43663fb97 100644 --- 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 @@ -46,8 +46,8 @@ public enum Resource { QUEUE(EntityType.QUEUE), VERSION_CONTROL, NOTIFICATION(EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_TEMPLATE, - EntityType.NOTIFICATION_REQUEST, EntityType.NOTIFICATION_RULE); - + EntityType.NOTIFICATION_REQUEST, EntityType.NOTIFICATION_RULE), + MOBILE_APP_SETTINGS; private final Set entityTypes; Resource() { 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 index 67cc105c91..d906938e47 100644 --- 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 @@ -41,6 +41,7 @@ public class SysAdminPermissions extends AbstractPermissions { put(Resource.TB_RESOURCE, systemEntityPermissionChecker); put(Resource.QUEUE, systemEntityPermissionChecker); put(Resource.NOTIFICATION, systemEntityPermissionChecker); + put(Resource.MOBILE_APP_SETTINGS, PermissionChecker.allowAllPermissionChecker); } private static final PermissionChecker systemEntityPermissionChecker = new PermissionChecker() { 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 index bfe0af966f..f4117e1a36 100644 --- 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 @@ -50,6 +50,7 @@ public class TenantAdminPermissions extends AbstractPermissions { put(Resource.QUEUE, queuePermissionChecker); put(Resource.VERSION_CONTROL, PermissionChecker.allowAllPermissionChecker); put(Resource.NOTIFICATION, tenantEntityPermissionChecker); + put(Resource.MOBILE_APP_SETTINGS, PermissionChecker.allowAllPermissionChecker); } public static final PermissionChecker tenantEntityPermissionChecker = new PermissionChecker() { 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 index d3f945cda1..a804565f59 100644 --- 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 @@ -57,7 +57,6 @@ import org.thingsboard.server.dao.user.UserService; import org.thingsboard.server.dao.user.UserServiceImpl; import org.thingsboard.server.service.security.auth.rest.RestAuthenticationDetails; import org.thingsboard.server.service.security.exception.UserPasswordExpiredException; -import org.thingsboard.server.service.security.exception.UserPasswordNotValidException; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.utils.MiscUtils; import ua_parser.Client; @@ -75,6 +74,8 @@ import static org.thingsboard.server.common.data.CacheConstants.SECURITY_SETTING @Slf4j public class DefaultSystemSecurityService implements SystemSecurityService { + public static final int DEFAULT_QR_SECRET_KEY_LENGTH = 64; + @Autowired private AdminSettingsService adminSettingsService; @@ -109,6 +110,7 @@ public class DefaultSystemSecurityService implements SystemSecurityService { securitySettings.setPasswordPolicy(new UserPasswordPolicy()); securitySettings.getPasswordPolicy().setMinimumLength(6); securitySettings.getPasswordPolicy().setMaximumLength(72); + securitySettings.setQrSecretKeyLength(DEFAULT_QR_SECRET_KEY_LENGTH); } return securitySettings; } diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 33146b1b82..2a0c8693ad 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -579,6 +579,12 @@ cache: alarmTypes: timeToLiveInMinutes: "${CACHE_SPECS_ALARM_TYPES_TTL:60}" # Alarm types cache TTL maxSize: "${CACHE_SPECS_ALARM_TYPES_MAX_SIZE:10000}" # 0 means the cache is disabled + mobileAppSettings: + timeToLiveInMinutes: "${CACHE_SPECS_MOBILE_APP_SETTINGS_TTL:1440}" # Mobile application cache TTL + maxSize: "${CACHE_SPECS_MOBILE_APP_SETTINGS_MAX_SIZE:10000}" # 0 means the cache is disabled + qrSecretKey: + timeToLiveInMinutes: "${CACHE_QR_SECRET_KEY_TTL:2}" # QR secret key cache TTL + maxSize: "${CACHE_QR_SECRET_KEY_MAX_SIZE:10000}" # 0 means the cache is disabled # Deliberately placed outside the 'specs' group above notificationRules: diff --git a/application/src/test/java/org/thingsboard/server/controller/AdminControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AdminControllerTest.java index 430ff2eb09..2d1ae9fdbb 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AdminControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AdminControllerTest.java @@ -23,6 +23,7 @@ import org.junit.Test; import org.mockito.Mockito; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.AdminSettings; +import org.thingsboard.server.common.data.mobile.MobileAppSettings; import org.thingsboard.server.common.data.security.model.JwtSettings; import org.thingsboard.server.dao.service.DaoSqlTest; @@ -181,4 +182,27 @@ public class AdminControllerTest extends AbstractControllerTest { resetJwtSettingsToDefault(); } + @Test + public void testSaveMobileAppSettings() throws Exception { + loginSysAdmin(); + MobileAppSettings mobileAppSettings = doGet("/api/admin/mobileAppSettings", MobileAppSettings.class); + assertThat(mobileAppSettings.getSettings().get("qrSecretKeyRefreshRateInMin").asInt()).isEqualTo(1); + + JsonNode jsonValue = mobileAppSettings.getSettings(); + ((ObjectNode) jsonValue).put("useDefault", false); + + doPost("/api/admin/mobileAppSettings", mobileAppSettings) + .andExpect(status().isOk()); + + doGet("/api/admin/mobileAppSettings") + .andExpect(status().isOk()) + .andExpect(content().contentType(contentType)) + .andExpect(jsonPath("$.settings.useDefault", is(false))); + + // check refresh rate should be less than secret ttl (5 min) + ((ObjectNode) jsonValue).put("qrSecretKeyRefreshRateInMin", 6); + doPost("/api/admin/mobileAppSettings", mobileAppSettings) + .andExpect(status().isBadRequest()); + } + } diff --git a/application/src/test/resources/application-test.properties b/application/src/test/resources/application-test.properties index 92959e4552..2e5944e0dc 100644 --- a/application/src/test/resources/application-test.properties +++ b/application/src/test/resources/application-test.properties @@ -8,6 +8,7 @@ transport.lwm2m.bootstrap.security.credentials.keystore.store_file=lwm2m/credent transport.lwm2m.security.trust-credentials.enabled=true transport.lwm2m.security.trust-credentials.type=KEYSTORE transport.lwm2m.security.trust-credentials.keystore.store_file=lwm2m/credentials/lwm2mtruststorechain.jks +cache.specs.qrSecretKey.timeToLiveInMinutes=5 # Edge disabled to speed up the context init. Will be enabled by @TestPropertySource in respective tests edges.enabled=false diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java index 9fda1c6bb5..8eb8cd8678 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java @@ -46,4 +46,6 @@ public class CacheConstants { public static final String ENTITY_COUNT_CACHE = "entityCount"; public static final String RESOURCE_INFO_CACHE = "resourceInfo"; public static final String ALARM_TYPES_CACHE = "alarmTypes"; + public static final String MOBILE_APP_SETTINGS_CACHE = "mobileAppSettings"; + public static final String QR_SECRET_KEY_CACHE = "qrSecretKey"; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/MobileAppSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/MobileAppSettings.java new file mode 100644 index 0000000000..039143f4a9 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/MobileAppSettings.java @@ -0,0 +1,63 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.mobile; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Data; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.validation.Length; +import org.thingsboard.server.common.data.validation.NoXss; + +import java.io.Serializable; + +import static org.thingsboard.server.common.data.BaseDataWithAdditionalInfo.getJson; +import static org.thingsboard.server.common.data.BaseDataWithAdditionalInfo.setJson; + +@Data +public class MobileAppSettings implements Serializable { + + private static final long serialVersionUID = 2628323657987010348L; + + private TenantId tenantId; + + @NoXss + @Length(fieldName = "appPackage") + private String appPackage; + + @NoXss + @Length(fieldName = "sha256CertFingerprints") + private String sha256CertFingerprints; + + @NoXss + @Length(fieldName = "appId") + private String appId; + + @NoXss + @Length(fieldName = "settings", max = 10000000) + private transient JsonNode settings; + + @JsonIgnore + private byte[] settingsBytes; + + public JsonNode getSettings() { + return getJson(() -> settings, () -> settingsBytes); + } + + public void setSettings(JsonNode settings) { + setJson(settings, json -> this.settings = json, bytes -> this.settingsBytes = bytes); + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/JwtPair.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/JwtPair.java index 459aa7b26f..ae484136ce 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/JwtPair.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/JwtPair.java @@ -20,10 +20,12 @@ import lombok.Data; import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.security.Authority; +import java.io.Serializable; + @Schema(description = "JWT Pair") @Data @NoArgsConstructor -public class JwtPair { +public class JwtPair implements Serializable { @Schema(description = "The JWT Access Token. Used to perform API calls.", example = "AAB254FF67D..") private String token; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/SecuritySettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/SecuritySettings.java index 07262c7eb4..b6ee1a17a5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/SecuritySettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/SecuritySettings.java @@ -32,4 +32,6 @@ public class SecuritySettings implements Serializable { private Integer maxFailedLoginAttempts; @Schema(description = "Email to use for notifications about locked users." ) private String userLockoutNotificationEmail; + @Schema(description = "QR secret key length" ) + private Integer qrSecretKeyLength; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/mobile/BaseMobileAppSettingsService.java b/dao/src/main/java/org/thingsboard/server/dao/mobile/BaseMobileAppSettingsService.java new file mode 100644 index 0000000000..fa035cc32d --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/mobile/BaseMobileAppSettingsService.java @@ -0,0 +1,92 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.mobile; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionalEventListener; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.mobile.MobileAppSettings; +import org.thingsboard.server.dao.entity.AbstractCachedService; +import org.thingsboard.server.dao.exception.DataValidationException; + +@Service +@Slf4j +@RequiredArgsConstructor +public class BaseMobileAppSettingsService extends AbstractCachedService implements MobileAppSettingsService { + + @Value("${cache.specs.qrSecretKey.timeToLiveInMinutes:2}") + private int secretKeyTtl; + + private static final int MIN_TIME_TO_DOWNLOAD_MOBILE_APP_IN_MIN = 1; + private final MobileAppSettingsDao mobileAppSettingsDao; + + @Override + public MobileAppSettings saveMobileAppSettings(TenantId tenantId, MobileAppSettings settings) { + if (settings.getSettings() != null && settings.getSettings().get("qrSecretKeyRefreshRateInMin") != null + && (settings.getSettings().get("qrSecretKeyRefreshRateInMin").asInt() + MIN_TIME_TO_DOWNLOAD_MOBILE_APP_IN_MIN > secretKeyTtl)) { + throw new DataValidationException("Refresh rate should be less than server secret key ttl"); + } + MobileAppSettings mobileAppSettings = mobileAppSettingsDao.save(tenantId, settings); + publishEvictEvent(new MobileAppSettingsEvictEvent(tenantId)); + return mobileAppSettings; + } + + @Override + public MobileAppSettings getMobileAppSettings(TenantId tenantId) { + log.trace("Executing getMobileAppSettings for tenant [{}] ", tenantId); + MobileAppSettings mobileAppSettings = cache.getAndPutInTransaction(tenantId, + () -> mobileAppSettingsDao.findByTenantId(tenantId), false); + return constructMobileAppSettings(mobileAppSettings); + } + + @TransactionalEventListener(classes = MobileAppSettingsEvictEvent.class) + @Override + public void handleEvictEvent(MobileAppSettingsEvictEvent event) { + cache.evict(event.getTenantId()); + } + + private MobileAppSettings constructMobileAppSettings(MobileAppSettings mobileAppSettings) { + if (mobileAppSettings == null) { + mobileAppSettings = new MobileAppSettings(); + + ObjectNode settings = JacksonUtil.newObjectNode(); + settings.put("useDefault", true); + settings.put("showOnHomePage", true); + settings.put("qrLabel", "Scan to connect or download mobile app"); + settings.put("qrSecretKeyRefreshRateInMin", 1); + + ObjectNode androidSettings = JacksonUtil.newObjectNode(); + androidSettings.put("badge", "original"); + androidSettings.put("badgePosition", "Right"); + settings.set("android", androidSettings); + + ObjectNode iOSSettings = JacksonUtil.newObjectNode(); + iOSSettings.put("badge", "original"); + iOSSettings.put("badgePosition", "Right"); + settings.set("iOS", iOSSettings); + + mobileAppSettings.setSettings(settings); + } + return mobileAppSettings; + } + + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsCaffeineCache.java b/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsCaffeineCache.java new file mode 100644 index 0000000000..9e15ae8572 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsCaffeineCache.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.mobile; + +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.mobile.MobileAppSettings; + +@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "caffeine", matchIfMissing = true) +@Service("MobileAppCache") +public class MobileAppSettingsCaffeineCache extends CaffeineTbTransactionalCache { + + public MobileAppSettingsCaffeineCache(CacheManager cacheManager) { + super(cacheManager, CacheConstants.MOBILE_APP_SETTINGS_CACHE); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsDao.java b/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsDao.java new file mode 100644 index 0000000000..ea8b4c3b3c --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsDao.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.mobile; + +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.mobile.MobileAppSettings; + + +public interface MobileAppSettingsDao { + + MobileAppSettings save(TenantId tenantId, MobileAppSettings whiteLabeling); + + MobileAppSettings findByTenantId(TenantId tenantId); + + void removeByTenantId(TenantId tenantId); +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsEvictEvent.java b/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsEvictEvent.java new file mode 100644 index 0000000000..459e61ef6d --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsEvictEvent.java @@ -0,0 +1,24 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.mobile; + +import lombok.Data; +import org.thingsboard.server.common.data.id.TenantId; + +@Data +public class MobileAppSettingsEvictEvent { + private final TenantId tenantId; +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsRedisCache.java b/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsRedisCache.java new file mode 100644 index 0000000000..9dd149abcb --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsRedisCache.java @@ -0,0 +1,36 @@ + /** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.mobile; + +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.TbJsonRedisSerializer; +import org.thingsboard.server.common.data.CacheConstants; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.mobile.MobileAppSettings; + +@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "redis") +@Service("MobileAppCache") +public class MobileAppSettingsRedisCache extends RedisTbTransactionalCache { + + public MobileAppSettingsRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) { + super(CacheConstants.MOBILE_APP_SETTINGS_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbJsonRedisSerializer<>(MobileAppSettings.class)); + } +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsService.java b/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsService.java new file mode 100644 index 0000000000..b170e4ab91 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsService.java @@ -0,0 +1,27 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.mobile; + +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.mobile.MobileAppSettings; + +public interface MobileAppSettingsService { + + MobileAppSettings saveMobileAppSettings(TenantId tenantId, MobileAppSettings settings); + + MobileAppSettings getMobileAppSettings(TenantId tenantId); + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index ae15c2d029..f7f061e37a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -659,6 +659,15 @@ public class ModelConstants { public static final String NOTIFICATION_TEMPLATE_NOTIFICATION_TYPE_PROPERTY = "notification_type"; public static final String NOTIFICATION_TEMPLATE_CONFIGURATION_PROPERTY = "configuration"; + /** + * Mobile application settings constants. + */ + public static final String MOBILE_APP_SETTINGS_TABLE_NAME = "mobile_app_settings"; + public static final String MOBILE_APP_SETTINGS_APP_PACKAGE_PROPERTY = "app_package"; + public static final String MOBILE_APP_SETTINGS_SHA256_CERT_FINGERPRINTS_PROPERTY = "sha256_cert_fingerprints"; + public static final String MOBILE_APP_APP_ID_PROPERTY = "app_id"; + public static final String MOBILE_APP_SETTINGS_PROPERTY = "settings"; + protected static final String[] NONE_AGGREGATION_COLUMNS = new String[]{LONG_VALUE_COLUMN, DOUBLE_VALUE_COLUMN, BOOLEAN_VALUE_COLUMN, STRING_VALUE_COLUMN, JSON_VALUE_COLUMN, KEY_COLUMN, TS_COLUMN}; protected static final String[] COUNT_AGGREGATION_COLUMNS = new String[]{count(LONG_VALUE_COLUMN), count(DOUBLE_VALUE_COLUMN), count(BOOLEAN_VALUE_COLUMN), count(STRING_VALUE_COLUMN), count(JSON_VALUE_COLUMN), max(TS_COLUMN)}; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/MobileAppSettingsEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/MobileAppSettingsEntity.java new file mode 100644 index 0000000000..faceffdca8 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/MobileAppSettingsEntity.java @@ -0,0 +1,92 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.model.sql; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.mobile.MobileAppSettings; +import org.thingsboard.server.dao.model.ModelConstants; +import org.thingsboard.server.dao.model.ToData; +import org.thingsboard.server.dao.util.mapping.JsonConverter; + +import java.io.Serializable; +import java.util.UUID; + +@Data +@NoArgsConstructor +@Entity +@Table(name = ModelConstants.MOBILE_APP_SETTINGS_TABLE_NAME) +public class MobileAppSettingsEntity implements ToData, Serializable { + + @Id + @Column(name = ModelConstants.TENANT_ID_COLUMN, columnDefinition = "uuid") + protected UUID tenantId; + + @Column(name = ModelConstants.MOBILE_APP_SETTINGS_APP_PACKAGE_PROPERTY) + private String appPackage; + + @Column(name = ModelConstants.MOBILE_APP_SETTINGS_SHA256_CERT_FINGERPRINTS_PROPERTY) + private String sha256CertFingerprints; + + @Column(name = ModelConstants.MOBILE_APP_APP_ID_PROPERTY) + private String appId; + + @Convert(converter = JsonConverter.class) + @Column(name = ModelConstants.MOBILE_APP_SETTINGS_PROPERTY) + private JsonNode settings; + + public MobileAppSettingsEntity(MobileAppSettings mobileAppSettings) { + this.tenantId = mobileAppSettings.getTenantId().getId(); + if (mobileAppSettings.getAppPackage() != null) { + this.appPackage = mobileAppSettings.getAppPackage(); + } + if (mobileAppSettings.getSha256CertFingerprints() != null) { + this.sha256CertFingerprints = mobileAppSettings.getSha256CertFingerprints(); + } + if (mobileAppSettings.getAppId() != null) { + this.appId = mobileAppSettings.getAppId(); + } + if (mobileAppSettings.getSettings() != null) { + this.settings = mobileAppSettings.getSettings(); + } + } + + @Override + public MobileAppSettings toData() { + MobileAppSettings mobileAppSettings = new MobileAppSettings(); + mobileAppSettings.setTenantId(TenantId.fromUUID(tenantId)); + if (appPackage != null) { + mobileAppSettings.setAppPackage(appPackage); + } + if (sha256CertFingerprints != null) { + mobileAppSettings.setSha256CertFingerprints(sha256CertFingerprints); + } + if (appId != null) { + mobileAppSettings.setAppId(appId); + } + if (settings != null) { + mobileAppSettings.setSettings(settings); + } + return mobileAppSettings; + } +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/mobile/JpaMobileAppDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/mobile/JpaMobileAppDao.java new file mode 100644 index 0000000000..f445cb0829 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/mobile/JpaMobileAppDao.java @@ -0,0 +1,53 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.mobile; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.mobile.MobileAppSettings; +import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.mobile.MobileAppSettingsDao; +import org.thingsboard.server.dao.model.sql.MobileAppSettingsEntity; +import org.thingsboard.server.dao.sql.JpaAbstractDaoListeningExecutorService; +import org.thingsboard.server.dao.util.SqlDao; + + +@Component +@Slf4j +@SqlDao +public class JpaMobileAppDao extends JpaAbstractDaoListeningExecutorService implements MobileAppSettingsDao { + + @Autowired + private MobileAppSettingsRepository mobileAppSettingsRepository; + + @Override + public MobileAppSettings save(TenantId tenantId, MobileAppSettings whiteLabeling) { + return DaoUtil.getData(mobileAppSettingsRepository.save(new MobileAppSettingsEntity(whiteLabeling))); + } + + @Override + public MobileAppSettings findByTenantId(TenantId tenantId) { + return DaoUtil.getData(mobileAppSettingsRepository.findById(tenantId.getId())); + } + + @Override + public void removeByTenantId(TenantId tenantId) { + mobileAppSettingsRepository.deleteByTenantId(tenantId.getId()); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/mobile/MobileAppSettingsRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/mobile/MobileAppSettingsRepository.java new file mode 100644 index 0000000000..a2339e1354 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/mobile/MobileAppSettingsRepository.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.mobile; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; +import org.thingsboard.server.dao.model.sql.MobileAppSettingsEntity; + +import java.util.UUID; + + +public interface MobileAppSettingsRepository extends JpaRepository { + + @Transactional + @Modifying + @Query("DELETE FROM MobileAppSettingsEntity r WHERE r.tenantId = :tenantId") + void deleteByTenantId(@Param("tenantId") UUID tenantId); +} diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index 1199f45181..cc6b950be9 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -893,4 +893,12 @@ CREATE TABLE IF NOT EXISTS queue_stats ( queue_name varchar(255) NOT NULL, service_id varchar(255) NOT NULL, CONSTRAINT queue_stats_name_unq_key UNIQUE (tenant_id, queue_name, service_id) +); + +CREATE TABLE IF NOT EXISTS mobile_app_settings ( + tenant_id UUID NOT NULL, + app_package VARCHAR(100) UNIQUE, + sha256_cert_fingerprints VARCHAR(10000), + app_id VARCHAR(100) UNIQUE, + settings VARCHAR(100000) ); \ No newline at end of file From 67ba6441952a7a25eb4299725da718d38404b7fe Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Wed, 24 Apr 2024 19:02:14 +0300 Subject: [PATCH 09/84] moved all apis to one controller --- .../main/data/upgrade/3.6.4/schema_update.sql | 8 +- .../server/controller/AdminController.java | 31 ---- .../controller/MobileAppLinksController.java | 86 --------- .../MobileApplicationController.java | 173 ++++++++++++++++++ .../server/controller/QRCodeController.java | 94 ---------- ...rvice.java => MobileAppSecretService.java} | 4 +- ...l.java => MobileAppSecretServiceImpl.java} | 14 +- ...he.java => MobileSecretCaffeineCache.java} | 6 +- ...Event.java => MobileSecretEvictEvent.java} | 2 +- ...Cache.java => MobileSecretRedisCache.java} | 6 +- .../system/DefaultSystemSecurityService.java | 4 +- .../src/main/resources/thingsboard.yml | 6 +- .../controller/AdminControllerTest.java | 24 --- .../MobileApplicationControllerTest.java | 132 +++++++++++++ .../resources/application-test.properties | 1 - .../server/common/data/CacheConstants.java | 2 +- .../common/data/mobile/AndroidConfig.java | 38 ++++ .../common/data/mobile/BadgePosition.java | 23 +++ .../server/common/data/mobile/BadgeStyle.java | 24 +++ .../server/common/data/mobile/IosConfig.java | 36 ++++ .../common/data/mobile/MobileAppSettings.java | 41 +---- .../common/data/mobile/QRCodeConfig.java | 38 ++++ .../data/security/model/SecuritySettings.java | 4 +- .../mobile/BaseMobileAppSettingsService.java | 43 ++--- .../dao/mobile/MobileAppSettingsDao.java | 2 +- .../server/dao/model/ModelConstants.java | 8 +- .../model/sql/MobileAppSettingsEntity.java | 54 +++--- .../dao/sql/mobile/JpaMobileAppDao.java | 4 +- .../main/resources/sql/schema-entities.sql | 8 +- 29 files changed, 552 insertions(+), 364 deletions(-) delete mode 100644 application/src/main/java/org/thingsboard/server/controller/MobileAppLinksController.java create mode 100644 application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java delete mode 100644 application/src/main/java/org/thingsboard/server/controller/QRCodeController.java rename application/src/main/java/org/thingsboard/server/service/qr/{QRService.java => MobileAppSecretService.java} (89%) rename application/src/main/java/org/thingsboard/server/service/qr/{QRServiceImpl.java => MobileAppSecretServiceImpl.java} (77%) rename application/src/main/java/org/thingsboard/server/service/qr/{QRSecretCaffeineCache.java => MobileSecretCaffeineCache.java} (83%) rename application/src/main/java/org/thingsboard/server/service/qr/{QRSecretEvictEvent.java => MobileSecretEvictEvent.java} (95%) rename application/src/main/java/org/thingsboard/server/service/qr/{QRSecretRedisCache.java => MobileSecretRedisCache.java} (78%) create mode 100644 application/src/test/java/org/thingsboard/server/controller/MobileApplicationControllerTest.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/mobile/AndroidConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/mobile/BadgePosition.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/mobile/BadgeStyle.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/mobile/IosConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/mobile/QRCodeConfig.java diff --git a/application/src/main/data/upgrade/3.6.4/schema_update.sql b/application/src/main/data/upgrade/3.6.4/schema_update.sql index 99c503d8f9..b7bbdc761f 100644 --- a/application/src/main/data/upgrade/3.6.4/schema_update.sql +++ b/application/src/main/data/upgrade/3.6.4/schema_update.sql @@ -139,10 +139,10 @@ DELETE FROM asset_profile WHERE name ='TbServiceQueue'; CREATE TABLE IF NOT EXISTS mobile_app_settings ( tenant_id UUID NOT NULL, - app_package VARCHAR(100) UNIQUE, - sha256_cert_fingerprints VARCHAR(10000), - app_id VARCHAR(100) UNIQUE, - settings VARCHAR(100000) + use_default boolean, + android_config VARCHAR(1000), + ios_config VARCHAR(1000), + qr_code_config VARCHAR(100000) ); -- MOBILE APPS TABLE CREATE END \ No newline at end of file diff --git a/application/src/main/java/org/thingsboard/server/controller/AdminController.java b/application/src/main/java/org/thingsboard/server/controller/AdminController.java index 2a6593f452..818a2a15a6 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AdminController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AdminController.java @@ -65,7 +65,6 @@ 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.mobile.MobileAppSettings; import org.thingsboard.server.common.data.security.model.JwtPair; import org.thingsboard.server.common.data.security.model.JwtSettings; import org.thingsboard.server.common.data.security.model.SecuritySettings; @@ -76,7 +75,6 @@ import org.thingsboard.server.common.data.sync.vc.RepositorySettingsInfo; import org.thingsboard.server.common.data.sync.vc.VcUtils; import org.thingsboard.server.config.annotations.ApiOperation; import org.thingsboard.server.dao.audit.AuditLogService; -import org.thingsboard.server.dao.mobile.MobileAppSettingsService; import org.thingsboard.server.dao.settings.AdminSettingsService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.auth.jwt.settings.JwtSettingsService; @@ -97,7 +95,6 @@ import java.util.Optional; 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_OR_CUSTOMER_AUTHORITY_PARAGRAPH; @RestController @TbCoreComponent @@ -122,7 +119,6 @@ public class AdminController extends BaseController { private final UpdateService updateService; private final SystemInfoService systemInfoService; private final AuditLogService auditLogService; - private final MobileAppSettingsService mobileAppSettingsService; @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) @@ -486,31 +482,4 @@ public class AdminController extends BaseController { adminSettingsService.saveAdminSettings(TenantId.SYS_TENANT_ID, adminSettings); response.sendRedirect(prevUri); } - - @ApiOperation(value = "Create Or Update the Mobile application settings (saveMobileAppSettings)", - notes = "The payload contains platform qr code widget settings and associated android and iOS applications." + SYSTEM_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") - @RequestMapping(value = "/mobileAppSettings", method = RequestMethod.POST) - @ResponseStatus(value = HttpStatus.OK) - public MobileAppSettings saveMobileAppSettings(@Parameter(description = "A JSON value representing the mobile apps configuration") - @RequestBody MobileAppSettings mobileAppSettings) throws ThingsboardException { - SecurityUser currentUser = getCurrentUser(); - accessControlService.checkPermission(currentUser, Resource.MOBILE_APP_SETTINGS, Operation.WRITE); - mobileAppSettings.setTenantId(getTenantId()); - - return mobileAppSettingsService.saveMobileAppSettings(currentUser.getTenantId(), mobileAppSettings); - } - - @ApiOperation(value = "Get Mobile application settings (getMobileAppSettings)", - notes = "The payload contains platform qr code widget settings and creds for associated android and iOS applications." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/mobileAppSettings", method = RequestMethod.GET) - @ResponseBody - public MobileAppSettings getMobileAppSettings() throws ThingsboardException { - SecurityUser currentUser = getCurrentUser(); - accessControlService.checkPermission(currentUser, Resource.MOBILE_APP_SETTINGS, Operation.READ); - - return mobileAppSettingsService.getMobileAppSettings(currentUser.getTenantId()); - } - } diff --git a/application/src/main/java/org/thingsboard/server/controller/MobileAppLinksController.java b/application/src/main/java/org/thingsboard/server/controller/MobileAppLinksController.java deleted file mode 100644 index 1265715126..0000000000 --- a/application/src/main/java/org/thingsboard/server/controller/MobileAppLinksController.java +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Copyright © 2016-2024 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF 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 lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -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.common.util.JacksonUtil; -import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.mobile.MobileAppSettings; -import org.thingsboard.server.config.annotations.ApiOperation; -import org.thingsboard.server.dao.mobile.MobileAppSettingsService; -import org.thingsboard.server.queue.util.TbCoreComponent; - -@RequiredArgsConstructor -@RestController -@TbCoreComponent -public class MobileAppLinksController extends BaseController { - - public static final String ASSET_LINKS_PATTERN = "[{\n" + - " \"relation\": [\"delegate_permission/common.handle_all_urls\"],\n" + - " \"target\": {\n" + - " \"namespace\": \"android_app\",\n" + - " \"package_name\": \"%s\",\n" + - " \"sha256_cert_fingerprints\":\n" + - " [\"%s\"]\n" + - " }\n" + - "}]"; - - public static final String APPLE_APP_SITE_ASSOCIATION_PATTERN = "{\n" + - " \"applinks\": {\n" + - " \"apps\": [],\n" + - " \"details\": [\n" + - " {\n" + - " \"appID\": \"%s\",\n" + - " \"paths\": [ \"*\" ]\n" + - " }\n" + - " ]\n" + - " }\n" + - "}"; - - - private final MobileAppSettingsService mobileAppSettingsService; - - - @ApiOperation(value = "Get associated android applications (getAssetLinks)") - @RequestMapping(value = "/.well-known/assetlinks.json", method = RequestMethod.GET) - @ResponseBody - public ResponseEntity getAssetLinks() { - MobileAppSettings mobileAppSettings = mobileAppSettingsService.getMobileAppSettings(TenantId.SYS_TENANT_ID); - if (mobileAppSettings != null && mobileAppSettings.getAppPackage() != null && mobileAppSettings.getSha256CertFingerprints() != null) { - return ResponseEntity.ok(JacksonUtil.toJsonNode(String.format(ASSET_LINKS_PATTERN, mobileAppSettings.getAppPackage(), mobileAppSettings.getSha256CertFingerprints()))); - } else { - return ResponseEntity.notFound().build(); - } - } - - @ApiOperation(value = "Get associated ios applications (getAppleAppSiteAssociation)") - @RequestMapping(value = "/.well-known/apple-app-site-association", method = RequestMethod.GET) - @ResponseBody - public ResponseEntity getAppleAppSiteAssociation() { - MobileAppSettings mobileAppSettings = mobileAppSettingsService.getMobileAppSettings(TenantId.SYS_TENANT_ID); - if (mobileAppSettings != null && mobileAppSettings.getAppId() != null) { - return ResponseEntity.ok(JacksonUtil.toJsonNode(String.format(APPLE_APP_SITE_ASSOCIATION_PATTERN, mobileAppSettings.getAppId()))); - } else { - return ResponseEntity.notFound().build(); - } - } -} diff --git a/application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java b/application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java new file mode 100644 index 0000000000..a65d8506ff --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java @@ -0,0 +1,173 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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 io.swagger.v3.oas.annotations.Parameter; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +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.RestController; +import org.thingsboard.common.util.JacksonUtil; +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.mobile.AndroidConfig; +import org.thingsboard.server.common.data.mobile.IosConfig; +import org.thingsboard.server.common.data.mobile.MobileAppSettings; +import org.thingsboard.server.common.data.security.model.JwtPair; +import org.thingsboard.server.config.annotations.ApiOperation; +import org.thingsboard.server.dao.mobile.MobileAppSettingsService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.qr.MobileAppSecretService; +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.security.system.SystemSecurityService; + +import java.net.URI; +import java.net.URISyntaxException; + +import static org.thingsboard.server.controller.ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER; +import static org.thingsboard.server.controller.ControllerConstants.SYSTEM_AUTHORITY_PARAGRAPH; + +@RequiredArgsConstructor +@RestController +@TbCoreComponent +public class MobileApplicationController extends BaseController { + + @Value("${cache.specs.mobileSecretKey.timeToLiveInMinutes:2}") + private int mobileSecretKeyTtl; + + public static final String ASSET_LINKS_PATTERN = "[{\n" + + " \"relation\": [\"delegate_permission/common.handle_all_urls\"],\n" + + " \"target\": {\n" + + " \"namespace\": \"android_app\",\n" + + " \"package_name\": \"%s\",\n" + + " \"sha256_cert_fingerprints\":\n" + + " [\"%s\"]\n" + + " }\n" + + "}]"; + + public static final String APPLE_APP_SITE_ASSOCIATION_PATTERN = "{\n" + + " \"applinks\": {\n" + + " \"apps\": [],\n" + + " \"details\": [\n" + + " {\n" + + " \"appID\": \"%s\",\n" + + " \"paths\": [ \"/api/noauth/qr\" ]\n" + + " }\n" + + " ]\n" + + " }\n" + + "}"; + + public static final String SECRET = "secret"; + public static final String SECRET_PARAM_DESCRIPTION = "A string value representing short-live secret key"; + public static final String DEFAULT_APP_DOMAIN = "demo.thingsboard.io"; + public static final String DEEP_LINK_PATTERN = "https://%s/api/noauth/qr?secret=%s&ttl=%s"; + private final SystemSecurityService systemSecurityService; + private final MobileAppSecretService mobileAppSecretService; + private final MobileAppSettingsService mobileAppSettingsService; + + @ApiOperation(value = "Get associated android applications (getAssetLinks)") + @GetMapping(value = "/.well-known/assetlinks.json") + public ResponseEntity getAssetLinks() { + MobileAppSettings mobileAppSettings = mobileAppSettingsService.getMobileAppSettings(TenantId.SYS_TENANT_ID); + AndroidConfig androidConfig = mobileAppSettings.getAndroidConfig(); + if (androidConfig != null && androidConfig.isEnabled() && !androidConfig.getAppPackage().isBlank() && !androidConfig.getSha256CertFingerprints().isBlank()) { + return ResponseEntity.ok(JacksonUtil.toJsonNode(String.format(ASSET_LINKS_PATTERN, androidConfig.getAppPackage(), androidConfig.getSha256CertFingerprints()))); + } else { + return ResponseEntity.notFound().build(); + } + } + + @ApiOperation(value = "Get associated ios applications (getAppleAppSiteAssociation)") + @GetMapping(value = "/.well-known/apple-app-site-association") + public ResponseEntity getAppleAppSiteAssociation() { + MobileAppSettings mobileAppSettings = mobileAppSettingsService.getMobileAppSettings(TenantId.SYS_TENANT_ID); + IosConfig iosConfig = mobileAppSettings.getIosConfig(); + + if (iosConfig != null && iosConfig.isEnabled() && !iosConfig.getAppId().isBlank()) { + return ResponseEntity.ok(JacksonUtil.toJsonNode(String.format(APPLE_APP_SITE_ASSOCIATION_PATTERN, iosConfig.getAppId()))); + } else { + return ResponseEntity.notFound().build(); + } + } + + @ApiOperation(value = "Create Or Update the Mobile application settings (saveMobileAppSettings)", + notes = "The payload contains associated android and iOS applications and platform qr code widget settings." + SYSTEM_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") + @PostMapping(value = "/api/mobile/app/settings") + public MobileAppSettings saveMobileAppSettings(@Parameter(description = "A JSON value representing the mobile apps configuration") + @RequestBody MobileAppSettings mobileAppSettings) throws ThingsboardException { + SecurityUser currentUser = getCurrentUser(); + accessControlService.checkPermission(currentUser, Resource.MOBILE_APP_SETTINGS, Operation.WRITE); + mobileAppSettings.setTenantId(getTenantId()); + + return mobileAppSettingsService.saveMobileAppSettings(currentUser.getTenantId(), mobileAppSettings); + } + + @ApiOperation(value = "Get Mobile application settings (getMobileAppSettings)", + notes = "The payload contains associated android and iOS applications and platform qr code widget settings." + AVAILABLE_FOR_ANY_AUTHORIZED_USER) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @GetMapping(value = "/api/mobile/app/settings") + public MobileAppSettings getMobileAppSettings() throws ThingsboardException { + SecurityUser currentUser = getCurrentUser(); + accessControlService.checkPermission(currentUser, Resource.MOBILE_APP_SETTINGS, Operation.READ); + + return mobileAppSettingsService.getMobileAppSettings(currentUser.getTenantId()); + } + + @ApiOperation(value = "Get the deep link to the associated mobile application (getMobileAppDeepLink)", + notes = "Fetch the url that takes user to associated mobile application " + AVAILABLE_FOR_ANY_AUTHORIZED_USER) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @GetMapping(value = "/api/mobile/deepLink", produces = "text/plain") + public String getMobileAppDeepLink(HttpServletRequest request) throws ThingsboardException, URISyntaxException { + SecurityUser currentUser = getCurrentUser(); + String secret = mobileAppSecretService.generateMobileAppSecret(currentUser); + + String baseUrl = systemSecurityService.getBaseUrl(TenantId.SYS_TENANT_ID, new CustomerId(EntityId.NULL_UUID), request); + String platformDomain = new URI(baseUrl).getHost(); + MobileAppSettings mobileAppSettings = mobileAppSettingsService.getMobileAppSettings(TenantId.SYS_TENANT_ID); + String appDomain; + if (!mobileAppSettings.isUseDefault()) { + appDomain = platformDomain; + } else { + appDomain = DEFAULT_APP_DOMAIN; + } + String deepLink = String.format(DEEP_LINK_PATTERN, appDomain, secret, mobileSecretKeyTtl); + if (!appDomain.equals(platformDomain)) { + deepLink = deepLink + "&host=" + baseUrl; + } + return deepLink; + } + + @ApiOperation(value = "Get User Token (getUserTokenByMobileSecret)", + notes = "Returns the token of the User based on the provided secret key.") + @GetMapping(value = "/api/noauth/qr/{secret}") + public JwtPair getUserTokenByMobileSecret(@Parameter(description = SECRET_PARAM_DESCRIPTION) + @PathVariable(SECRET) String secret) throws ThingsboardException { + checkParameter(SECRET, secret); + return mobileAppSecretService.getJwtPair(secret); + } +} diff --git a/application/src/main/java/org/thingsboard/server/controller/QRCodeController.java b/application/src/main/java/org/thingsboard/server/controller/QRCodeController.java deleted file mode 100644 index aed35cb153..0000000000 --- a/application/src/main/java/org/thingsboard/server/controller/QRCodeController.java +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Copyright © 2016-2024 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF 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 io.swagger.v3.oas.annotations.Parameter; -import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; -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.ResponseBody; -import org.springframework.web.bind.annotation.RestController; -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.security.model.JwtPair; -import org.thingsboard.server.config.annotations.ApiOperation; -import org.thingsboard.server.dao.mobile.MobileAppSettingsService; -import org.thingsboard.server.queue.util.TbCoreComponent; -import org.thingsboard.server.service.qr.QRService; -import org.thingsboard.server.service.security.model.SecurityUser; -import org.thingsboard.server.service.security.system.SystemSecurityService; - -import java.net.URI; -import java.net.URISyntaxException; - -@RequiredArgsConstructor -@RestController -@TbCoreComponent -@RequestMapping("/api") -public class QRCodeController extends BaseController { - - public static final String SECRET = "secret"; - public static final String SECRET_PARAM_DESCRIPTION = "A string value representing short-live secret key"; - public static final String DEFAULT_APP_DOMAIN = "demo.thingsboard.io"; - public static final String DEEP_LINK_PATTERN = "https://%s/api/noauth/qr?secret=%s"; - private final QRService qrService; - private final SystemSecurityService systemSecurityService; - - private final MobileAppSettingsService mobileAppSettingsService; - - @ApiOperation(value = "Get the deep link to the associated mobile application (getQRCodeDeepLink)", - notes = "Fetch the url that takes user to associated mobile application ") - @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/qr/deepLink", method = RequestMethod.GET, produces = "text/plain") - @ResponseBody - public String getQRCodeDeepLink(HttpServletRequest request) throws ThingsboardException, URISyntaxException { - SecurityUser currentUser = getCurrentUser(); - String secret = qrService.generateSecret(currentUser); - - String baseUrl = systemSecurityService.getBaseUrl(TenantId.SYS_TENANT_ID, new CustomerId(EntityId.NULL_UUID), request); - String platformDomain = new URI(baseUrl).getHost(); - JsonNode mobileAppSettings = mobileAppSettingsService.getMobileAppSettings(TenantId.SYS_TENANT_ID).getSettings(); - String appDomain; - if (mobileAppSettings != null && mobileAppSettings.get("useDefault") != null - && !mobileAppSettings.get("useDefault").asBoolean()) { - appDomain = platformDomain; - } else { - appDomain = DEFAULT_APP_DOMAIN; - } - String deepLink = String.format(DEEP_LINK_PATTERN, appDomain, secret); - if (!appDomain.equals(platformDomain)) { - deepLink = deepLink + "&host=" + baseUrl; - } - return deepLink; - } - - @ApiOperation(value = "Get User Token (getUserToken)", - notes = "Returns the token of the User based on the provided secret key.") - @RequestMapping(value = "/noauth/qr/{secret}", method = RequestMethod.GET) - @ResponseBody - public JwtPair getUserToken(@Parameter(description = SECRET_PARAM_DESCRIPTION) - @PathVariable(SECRET) String secret) throws ThingsboardException { - checkParameter(SECRET, secret); - return qrService.getJwtPair(secret); - } - -} diff --git a/application/src/main/java/org/thingsboard/server/service/qr/QRService.java b/application/src/main/java/org/thingsboard/server/service/qr/MobileAppSecretService.java similarity index 89% rename from application/src/main/java/org/thingsboard/server/service/qr/QRService.java rename to application/src/main/java/org/thingsboard/server/service/qr/MobileAppSecretService.java index 9502d3440b..943fd45c54 100644 --- a/application/src/main/java/org/thingsboard/server/service/qr/QRService.java +++ b/application/src/main/java/org/thingsboard/server/service/qr/MobileAppSecretService.java @@ -19,9 +19,9 @@ import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.security.model.JwtPair; import org.thingsboard.server.service.security.model.SecurityUser; -public interface QRService { +public interface MobileAppSecretService { - String generateSecret(SecurityUser securityUser); + String generateMobileAppSecret(SecurityUser securityUser); JwtPair getJwtPair(String secret) throws ThingsboardException; diff --git a/application/src/main/java/org/thingsboard/server/service/qr/QRServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/qr/MobileAppSecretServiceImpl.java similarity index 77% rename from application/src/main/java/org/thingsboard/server/service/qr/QRServiceImpl.java rename to application/src/main/java/org/thingsboard/server/service/qr/MobileAppSecretServiceImpl.java index 36bf72c74e..f8d8629d10 100644 --- a/application/src/main/java/org/thingsboard/server/service/qr/QRServiceImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/qr/MobileAppSecretServiceImpl.java @@ -29,21 +29,21 @@ 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 static org.thingsboard.server.service.security.system.DefaultSystemSecurityService.DEFAULT_QR_SECRET_KEY_LENGTH; +import static org.thingsboard.server.service.security.system.DefaultSystemSecurityService.DEFAULT_MOBILE_SECRET_KEY_LENGTH; @Service @Slf4j @RequiredArgsConstructor -public class QRServiceImpl extends AbstractCachedService implements QRService { +public class MobileAppSecretServiceImpl extends AbstractCachedService implements MobileAppSecretService { private final JwtTokenFactory tokenFactory; private final SystemSecurityService systemSecurityService; @Override - public String generateSecret(SecurityUser securityUser) { + public String generateMobileAppSecret(SecurityUser securityUser) { log.trace("Executing generateSecret for user [{}]", securityUser.getId()); - Integer qrSecretKeyLength = systemSecurityService.getSecuritySettings().getQrSecretKeyLength(); - String secret = StringUtils.generateSafeToken(qrSecretKeyLength == null ? DEFAULT_QR_SECRET_KEY_LENGTH : qrSecretKeyLength); + Integer mobileSecretKeyLength = systemSecurityService.getSecuritySettings().getMobileSecretKeyLength(); + String secret = StringUtils.generateSafeToken(mobileSecretKeyLength == null ? DEFAULT_MOBILE_SECRET_KEY_LENGTH : mobileSecretKeyLength); cache.put(secret, tokenFactory.createTokenPair(securityUser)); return secret; } @@ -58,9 +58,9 @@ public class QRServiceImpl extends AbstractCachedService { +public class MobileSecretCaffeineCache extends CaffeineTbTransactionalCache { - public QRSecretCaffeineCache(CacheManager cacheManager) { - super(cacheManager, CacheConstants.QR_SECRET_KEY_CACHE); + public MobileSecretCaffeineCache(CacheManager cacheManager) { + super(cacheManager, CacheConstants.MOBILE_SECRET_KEY_CACHE); } } diff --git a/application/src/main/java/org/thingsboard/server/service/qr/QRSecretEvictEvent.java b/application/src/main/java/org/thingsboard/server/service/qr/MobileSecretEvictEvent.java similarity index 95% rename from application/src/main/java/org/thingsboard/server/service/qr/QRSecretEvictEvent.java rename to application/src/main/java/org/thingsboard/server/service/qr/MobileSecretEvictEvent.java index bf0c7b67fe..1f97e9ab07 100644 --- a/application/src/main/java/org/thingsboard/server/service/qr/QRSecretEvictEvent.java +++ b/application/src/main/java/org/thingsboard/server/service/qr/MobileSecretEvictEvent.java @@ -18,7 +18,7 @@ package org.thingsboard.server.service.qr; import lombok.Data; @Data -public class QRSecretEvictEvent { +public class MobileSecretEvictEvent { private final String secret; diff --git a/application/src/main/java/org/thingsboard/server/service/qr/QRSecretRedisCache.java b/application/src/main/java/org/thingsboard/server/service/qr/MobileSecretRedisCache.java similarity index 78% rename from application/src/main/java/org/thingsboard/server/service/qr/QRSecretRedisCache.java rename to application/src/main/java/org/thingsboard/server/service/qr/MobileSecretRedisCache.java index 8efe664187..d5cb25c909 100644 --- a/application/src/main/java/org/thingsboard/server/service/qr/QRSecretRedisCache.java +++ b/application/src/main/java/org/thingsboard/server/service/qr/MobileSecretRedisCache.java @@ -28,9 +28,9 @@ import org.thingsboard.server.common.data.security.model.JwtPair; @ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "redis") @Service("QRSecretCache") -public class QRSecretRedisCache extends RedisTbTransactionalCache { +public class MobileSecretRedisCache extends RedisTbTransactionalCache { - public QRSecretRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) { - super(CacheConstants.QR_SECRET_KEY_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbJsonRedisSerializer<>(JwtPair.class)); + public MobileSecretRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) { + super(CacheConstants.MOBILE_SECRET_KEY_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbJsonRedisSerializer<>(JwtPair.class)); } } 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 index a804565f59..b12e6d4581 100644 --- 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 @@ -74,7 +74,7 @@ import static org.thingsboard.server.common.data.CacheConstants.SECURITY_SETTING @Slf4j public class DefaultSystemSecurityService implements SystemSecurityService { - public static final int DEFAULT_QR_SECRET_KEY_LENGTH = 64; + public static final int DEFAULT_MOBILE_SECRET_KEY_LENGTH = 64; @Autowired private AdminSettingsService adminSettingsService; @@ -110,7 +110,7 @@ public class DefaultSystemSecurityService implements SystemSecurityService { securitySettings.setPasswordPolicy(new UserPasswordPolicy()); securitySettings.getPasswordPolicy().setMinimumLength(6); securitySettings.getPasswordPolicy().setMaximumLength(72); - securitySettings.setQrSecretKeyLength(DEFAULT_QR_SECRET_KEY_LENGTH); + securitySettings.setMobileSecretKeyLength(DEFAULT_MOBILE_SECRET_KEY_LENGTH); } return securitySettings; } diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 2a0c8693ad..e010797d88 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -582,9 +582,9 @@ cache: mobileAppSettings: timeToLiveInMinutes: "${CACHE_SPECS_MOBILE_APP_SETTINGS_TTL:1440}" # Mobile application cache TTL maxSize: "${CACHE_SPECS_MOBILE_APP_SETTINGS_MAX_SIZE:10000}" # 0 means the cache is disabled - qrSecretKey: - timeToLiveInMinutes: "${CACHE_QR_SECRET_KEY_TTL:2}" # QR secret key cache TTL - maxSize: "${CACHE_QR_SECRET_KEY_MAX_SIZE:10000}" # 0 means the cache is disabled + mobileSecretKey: + timeToLiveInMinutes: "${CACHE_MOBILE_SECRET_KEY_TTL:2}" # QR secret key cache TTL + maxSize: "${CACHE_MOBILE_SECRET_KEY_MAX_SIZE:10000}" # 0 means the cache is disabled # Deliberately placed outside the 'specs' group above notificationRules: diff --git a/application/src/test/java/org/thingsboard/server/controller/AdminControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AdminControllerTest.java index 2d1ae9fdbb..430ff2eb09 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AdminControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AdminControllerTest.java @@ -23,7 +23,6 @@ import org.junit.Test; import org.mockito.Mockito; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.AdminSettings; -import org.thingsboard.server.common.data.mobile.MobileAppSettings; import org.thingsboard.server.common.data.security.model.JwtSettings; import org.thingsboard.server.dao.service.DaoSqlTest; @@ -182,27 +181,4 @@ public class AdminControllerTest extends AbstractControllerTest { resetJwtSettingsToDefault(); } - @Test - public void testSaveMobileAppSettings() throws Exception { - loginSysAdmin(); - MobileAppSettings mobileAppSettings = doGet("/api/admin/mobileAppSettings", MobileAppSettings.class); - assertThat(mobileAppSettings.getSettings().get("qrSecretKeyRefreshRateInMin").asInt()).isEqualTo(1); - - JsonNode jsonValue = mobileAppSettings.getSettings(); - ((ObjectNode) jsonValue).put("useDefault", false); - - doPost("/api/admin/mobileAppSettings", mobileAppSettings) - .andExpect(status().isOk()); - - doGet("/api/admin/mobileAppSettings") - .andExpect(status().isOk()) - .andExpect(content().contentType(contentType)) - .andExpect(jsonPath("$.settings.useDefault", is(false))); - - // check refresh rate should be less than secret ttl (5 min) - ((ObjectNode) jsonValue).put("qrSecretKeyRefreshRateInMin", 6); - doPost("/api/admin/mobileAppSettings", mobileAppSettings) - .andExpect(status().isBadRequest()); - } - } diff --git a/application/src/test/java/org/thingsboard/server/controller/MobileApplicationControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/MobileApplicationControllerTest.java new file mode 100644 index 0000000000..16624cefb1 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/MobileApplicationControllerTest.java @@ -0,0 +1,132 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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 lombok.extern.slf4j.Slf4j; +import org.junit.Before; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Value; +import org.thingsboard.server.common.data.mobile.AndroidConfig; +import org.thingsboard.server.common.data.mobile.IosConfig; +import org.thingsboard.server.common.data.mobile.MobileAppSettings; +import org.thingsboard.server.common.data.mobile.QRCodeConfig; +import org.thingsboard.server.common.data.security.model.JwtPair; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@Slf4j +@DaoSqlTest +public class MobileApplicationControllerTest extends AbstractControllerTest { + + @Value("${cache.specs.mobileSecretKey.timeToLiveInMinutes:2}") + private int mobileSecretKeyTtl; + private static final String ANDROID_PACKAGE_NAME = "testAppPackage"; + private static final String ANDROID_APP_SHA256 = "DF:28:32:66:8B:A7:D3:EC:7D:73:CF:CC"; + private static final String APPLE_APP_ID = "testId"; + private static final String TEST_LABEL = "Test label"; + + @Before + public void setUp() throws Exception { + loginSysAdmin(); + + QRCodeConfig qrCodeConfig = new QRCodeConfig(); + qrCodeConfig.setQrCodeLabel(TEST_LABEL); + + MobileAppSettings mobileAppSettings = new MobileAppSettings(); + mobileAppSettings.setUseDefault(true); + AndroidConfig androidConfig = AndroidConfig.builder() + .appPackage(ANDROID_PACKAGE_NAME) + .sha256CertFingerprints(ANDROID_APP_SHA256) + .enabled(true) + .build(); + + IosConfig iosConfig = IosConfig.builder() + .appId(APPLE_APP_ID) + .enabled(true) + .build(); + mobileAppSettings.setAndroidConfig(androidConfig); + mobileAppSettings.setIosConfig(iosConfig); + mobileAppSettings.setQrCodeConfig(qrCodeConfig); + + doPost("/api/mobile/app/settings", mobileAppSettings); + } + + @Test + public void testSaveMobileAppSettings() throws Exception { + loginSysAdmin(); + MobileAppSettings mobileAppSettings = doGet("/api/mobile/app/settings", MobileAppSettings.class); + assertThat(mobileAppSettings.getQrCodeConfig().getQrCodeLabel()).isEqualTo(TEST_LABEL); + assertThat(mobileAppSettings.isUseDefault()).isTrue(); + + mobileAppSettings.setUseDefault(false); + + doPost("/api/mobile/app/settings", mobileAppSettings) + .andExpect(status().isOk()); + + MobileAppSettings updatedMobileAppSettings = doGet("/api/mobile/app/settings", MobileAppSettings.class); + assertThat(updatedMobileAppSettings.isUseDefault()).isFalse(); + } + + @Test + public void testGetApplicationAssociations() throws Exception { + JsonNode assetLinks = doGet("/.well-known/assetlinks.json", JsonNode.class); + assertThat(assetLinks.get(0).get("target").get("package_name").asText()).isEqualTo(ANDROID_PACKAGE_NAME); + assertThat(assetLinks.get(0).get("target").get("sha256_cert_fingerprints").get(0).asText()).isEqualTo(ANDROID_APP_SHA256); + + JsonNode appleAssociation = doGet("/.well-known/apple-app-site-association", JsonNode.class); + assertThat(appleAssociation.get("applinks").get("details").get(0).get("appID").asText()).isEqualTo(APPLE_APP_ID); + } + + @Test + public void testGetMobileDeepLink() throws Exception { + loginSysAdmin(); + String deepLink = doGet("/api/mobile/deepLink", String.class); + + Pattern expectedPattern = Pattern.compile("https://([^/]+)/api/noauth/qr\\?secret=([^&]+)&ttl=([^&]+)&host=([^&]+)"); + Matcher parsedDeepLink = expectedPattern.matcher(deepLink); + assertThat(parsedDeepLink.matches()).isTrue(); + String secret = parsedDeepLink.group(2); + String ttl = parsedDeepLink.group(3); + assertThat(ttl).isEqualTo(String.valueOf(mobileSecretKeyTtl)); + + JwtPair jwtPair = doGet("/api/noauth/qr/" + secret, JwtPair.class); + assertThat(jwtPair).isNotNull(); + + loginTenantAdmin(); + String tenantDeepLink = doGet("/api/mobile/deepLink", String.class); + Matcher tenantParsedDeepLink = expectedPattern.matcher(tenantDeepLink); + assertThat(tenantParsedDeepLink.matches()).isTrue(); + String tenantSecret = tenantParsedDeepLink.group(2); + + JwtPair tenantJwtPair = doGet("/api/noauth/qr/" + tenantSecret, JwtPair.class); + assertThat(tenantJwtPair).isNotNull(); + + loginCustomerUser(); + String customerDeepLink = doGet("/api/mobile/deepLink", String.class); + Matcher customerParsedDeepLink = expectedPattern.matcher(customerDeepLink); + assertThat(customerParsedDeepLink.matches()).isTrue(); + String customerSecret = customerParsedDeepLink.group(2); + + JwtPair customerJwtPair = doGet("/api/noauth/qr/" + customerSecret, JwtPair.class); + assertThat(customerJwtPair).isNotNull(); + } +} diff --git a/application/src/test/resources/application-test.properties b/application/src/test/resources/application-test.properties index 2e5944e0dc..92959e4552 100644 --- a/application/src/test/resources/application-test.properties +++ b/application/src/test/resources/application-test.properties @@ -8,7 +8,6 @@ transport.lwm2m.bootstrap.security.credentials.keystore.store_file=lwm2m/credent transport.lwm2m.security.trust-credentials.enabled=true transport.lwm2m.security.trust-credentials.type=KEYSTORE transport.lwm2m.security.trust-credentials.keystore.store_file=lwm2m/credentials/lwm2mtruststorechain.jks -cache.specs.qrSecretKey.timeToLiveInMinutes=5 # Edge disabled to speed up the context init. Will be enabled by @TestPropertySource in respective tests edges.enabled=false diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java index 8eb8cd8678..b05dac9d4d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java @@ -47,5 +47,5 @@ public class CacheConstants { public static final String RESOURCE_INFO_CACHE = "resourceInfo"; public static final String ALARM_TYPES_CACHE = "alarmTypes"; public static final String MOBILE_APP_SETTINGS_CACHE = "mobileAppSettings"; - public static final String QR_SECRET_KEY_CACHE = "qrSecretKey"; + public static final String MOBILE_SECRET_KEY_CACHE = "mobileSecretKey"; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/AndroidConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/AndroidConfig.java new file mode 100644 index 0000000000..e1fb5056fc --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/AndroidConfig.java @@ -0,0 +1,38 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.mobile; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.validation.NoXss; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode +public class AndroidConfig { + + private boolean enabled; + @NoXss + private String appPackage; + @NoXss + private String sha256CertFingerprints; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/BadgePosition.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/BadgePosition.java new file mode 100644 index 0000000000..89c46a5c12 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/BadgePosition.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.mobile; + +public enum BadgePosition { + + RIGHT, + LEFT; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/BadgeStyle.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/BadgeStyle.java new file mode 100644 index 0000000000..51606a2340 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/BadgeStyle.java @@ -0,0 +1,24 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.mobile; + + +public enum BadgeStyle { + + ORIGINAL, + WHITE; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/IosConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/IosConfig.java new file mode 100644 index 0000000000..f03b446b3b --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/IosConfig.java @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.mobile; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.validation.NoXss; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode +public class IosConfig { + + private boolean enabled; + @NoXss + private String appId; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/MobileAppSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/MobileAppSettings.java index 039143f4a9..9153265950 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/MobileAppSettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/MobileAppSettings.java @@ -15,49 +15,24 @@ */ package org.thingsboard.server.common.data.mobile; -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.databind.JsonNode; +import jakarta.validation.Valid; import lombok.Data; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.validation.Length; -import org.thingsboard.server.common.data.validation.NoXss; import java.io.Serializable; -import static org.thingsboard.server.common.data.BaseDataWithAdditionalInfo.getJson; -import static org.thingsboard.server.common.data.BaseDataWithAdditionalInfo.setJson; - @Data public class MobileAppSettings implements Serializable { private static final long serialVersionUID = 2628323657987010348L; private TenantId tenantId; + boolean useDefault; + @Valid + private AndroidConfig androidConfig; + @Valid + private IosConfig iosConfig; + @Valid + private QRCodeConfig qrCodeConfig; - @NoXss - @Length(fieldName = "appPackage") - private String appPackage; - - @NoXss - @Length(fieldName = "sha256CertFingerprints") - private String sha256CertFingerprints; - - @NoXss - @Length(fieldName = "appId") - private String appId; - - @NoXss - @Length(fieldName = "settings", max = 10000000) - private transient JsonNode settings; - - @JsonIgnore - private byte[] settingsBytes; - - public JsonNode getSettings() { - return getJson(() -> settings, () -> settingsBytes); - } - - public void setSettings(JsonNode settings) { - setJson(settings, json -> this.settings = json, bytes -> this.settingsBytes = bytes); - } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/QRCodeConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/QRCodeConfig.java new file mode 100644 index 0000000000..20de410137 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/QRCodeConfig.java @@ -0,0 +1,38 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.mobile; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.validation.NoXss; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode +public class QRCodeConfig { + + private boolean showOnHomePage; + private boolean badgeEnabled; + private boolean labelEnabled; + private BadgePosition badgePosition; + private BadgeStyle badgeStyle; + @NoXss + private String qrCodeLabel; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/SecuritySettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/SecuritySettings.java index b6ee1a17a5..aa4d303440 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/SecuritySettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/SecuritySettings.java @@ -32,6 +32,6 @@ public class SecuritySettings implements Serializable { private Integer maxFailedLoginAttempts; @Schema(description = "Email to use for notifications about locked users." ) private String userLockoutNotificationEmail; - @Schema(description = "QR secret key length" ) - private Integer qrSecretKeyLength; + @Schema(description = "Mobile secret key length" ) + private Integer mobileSecretKeyLength; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/mobile/BaseMobileAppSettingsService.java b/dao/src/main/java/org/thingsboard/server/dao/mobile/BaseMobileAppSettingsService.java index fa035cc32d..1c05cfbb92 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/mobile/BaseMobileAppSettingsService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/mobile/BaseMobileAppSettingsService.java @@ -15,35 +15,27 @@ */ package org.thingsboard.server.dao.mobile; -import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.event.TransactionalEventListener; -import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.mobile.BadgePosition; +import org.thingsboard.server.common.data.mobile.BadgeStyle; import org.thingsboard.server.common.data.mobile.MobileAppSettings; +import org.thingsboard.server.common.data.mobile.QRCodeConfig; import org.thingsboard.server.dao.entity.AbstractCachedService; -import org.thingsboard.server.dao.exception.DataValidationException; @Service @Slf4j @RequiredArgsConstructor public class BaseMobileAppSettingsService extends AbstractCachedService implements MobileAppSettingsService { - @Value("${cache.specs.qrSecretKey.timeToLiveInMinutes:2}") - private int secretKeyTtl; - - private static final int MIN_TIME_TO_DOWNLOAD_MOBILE_APP_IN_MIN = 1; + private static final String DEFAULT_QR_CODE_LABEL = "Scan to connect or download mobile app"; private final MobileAppSettingsDao mobileAppSettingsDao; @Override public MobileAppSettings saveMobileAppSettings(TenantId tenantId, MobileAppSettings settings) { - if (settings.getSettings() != null && settings.getSettings().get("qrSecretKeyRefreshRateInMin") != null - && (settings.getSettings().get("qrSecretKeyRefreshRateInMin").asInt() + MIN_TIME_TO_DOWNLOAD_MOBILE_APP_IN_MIN > secretKeyTtl)) { - throw new DataValidationException("Refresh rate should be less than server secret key ttl"); - } MobileAppSettings mobileAppSettings = mobileAppSettingsDao.save(tenantId, settings); publishEvictEvent(new MobileAppSettingsEvictEvent(tenantId)); return mobileAppSettings; @@ -66,27 +58,18 @@ public class BaseMobileAppSettingsService extends AbstractCachedService, Seria @Column(name = ModelConstants.TENANT_ID_COLUMN, columnDefinition = "uuid") protected UUID tenantId; - @Column(name = ModelConstants.MOBILE_APP_SETTINGS_APP_PACKAGE_PROPERTY) - private String appPackage; + @Column(name = ModelConstants.MOBILE_APP_SETTINGS_USE_DEFAULT_PROPERTY) + private boolean useDefault; - @Column(name = ModelConstants.MOBILE_APP_SETTINGS_SHA256_CERT_FINGERPRINTS_PROPERTY) - private String sha256CertFingerprints; + @Convert(converter = JsonConverter.class) + @Column(name = ModelConstants.MOBILE_APP_SETTINGS_ANDROID_CONFIG_PROPERTY) + private JsonNode androidConfig; - @Column(name = ModelConstants.MOBILE_APP_APP_ID_PROPERTY) - private String appId; + @Convert(converter = JsonConverter.class) + @Column(name = ModelConstants.MOBILE_APP_IOS_CONFIG_PROPERTY) + private JsonNode iosConfig; @Convert(converter = JsonConverter.class) - @Column(name = ModelConstants.MOBILE_APP_SETTINGS_PROPERTY) - private JsonNode settings; + @Column(name = ModelConstants.MOBILE_APP_QR_CODE_CONFIG_PROPERTY) + private JsonNode qrCodeConfig; public MobileAppSettingsEntity(MobileAppSettings mobileAppSettings) { this.tenantId = mobileAppSettings.getTenantId().getId(); - if (mobileAppSettings.getAppPackage() != null) { - this.appPackage = mobileAppSettings.getAppPackage(); + this.useDefault = mobileAppSettings.isUseDefault(); + if (mobileAppSettings.getAndroidConfig() != null) { + this.androidConfig = JacksonUtil.valueToTree(mobileAppSettings.getAndroidConfig()); } - if (mobileAppSettings.getSha256CertFingerprints() != null) { - this.sha256CertFingerprints = mobileAppSettings.getSha256CertFingerprints(); + if (mobileAppSettings.getIosConfig() != null) { + this.iosConfig = JacksonUtil.valueToTree(mobileAppSettings.getIosConfig()); } - if (mobileAppSettings.getAppId() != null) { - this.appId = mobileAppSettings.getAppId(); - } - if (mobileAppSettings.getSettings() != null) { - this.settings = mobileAppSettings.getSettings(); + if (mobileAppSettings.getQrCodeConfig() != null) { + this.qrCodeConfig = JacksonUtil.valueToTree(mobileAppSettings.getQrCodeConfig()); } } @@ -75,17 +79,15 @@ public class MobileAppSettingsEntity implements ToData, Seria public MobileAppSettings toData() { MobileAppSettings mobileAppSettings = new MobileAppSettings(); mobileAppSettings.setTenantId(TenantId.fromUUID(tenantId)); - if (appPackage != null) { - mobileAppSettings.setAppPackage(appPackage); - } - if (sha256CertFingerprints != null) { - mobileAppSettings.setSha256CertFingerprints(sha256CertFingerprints); + mobileAppSettings.setUseDefault(useDefault); + if (qrCodeConfig != null) { + mobileAppSettings.setAndroidConfig(JacksonUtil.convertValue(androidConfig, AndroidConfig.class)); } - if (appId != null) { - mobileAppSettings.setAppId(appId); + if (qrCodeConfig != null) { + mobileAppSettings.setIosConfig(JacksonUtil.convertValue(iosConfig, IosConfig.class)); } - if (settings != null) { - mobileAppSettings.setSettings(settings); + if (qrCodeConfig != null) { + mobileAppSettings.setQrCodeConfig(JacksonUtil.convertValue(qrCodeConfig, QRCodeConfig.class)); } return mobileAppSettings; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/mobile/JpaMobileAppDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/mobile/JpaMobileAppDao.java index f445cb0829..8c732e0ffc 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/mobile/JpaMobileAppDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/mobile/JpaMobileAppDao.java @@ -36,8 +36,8 @@ public class JpaMobileAppDao extends JpaAbstractDaoListeningExecutorService impl private MobileAppSettingsRepository mobileAppSettingsRepository; @Override - public MobileAppSettings save(TenantId tenantId, MobileAppSettings whiteLabeling) { - return DaoUtil.getData(mobileAppSettingsRepository.save(new MobileAppSettingsEntity(whiteLabeling))); + public MobileAppSettings save(TenantId tenantId, MobileAppSettings mobileAppSettings) { + return DaoUtil.getData(mobileAppSettingsRepository.save(new MobileAppSettingsEntity(mobileAppSettings))); } @Override diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index cc6b950be9..a984302f04 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -897,8 +897,8 @@ CREATE TABLE IF NOT EXISTS queue_stats ( CREATE TABLE IF NOT EXISTS mobile_app_settings ( tenant_id UUID NOT NULL, - app_package VARCHAR(100) UNIQUE, - sha256_cert_fingerprints VARCHAR(10000), - app_id VARCHAR(100) UNIQUE, - settings VARCHAR(100000) + use_default boolean, + android_config VARCHAR(1000), + ios_config VARCHAR(1000), + qr_code_config VARCHAR(100000) ); \ No newline at end of file From b216898924bfdae9704742afb038feb82ecc0e33 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Wed, 24 Apr 2024 20:07:29 +0300 Subject: [PATCH 10/84] updated default mobile app settings, fixed permission --- .../MobileApplicationController.java | 2 +- .../service/qr/MobileSecretCaffeineCache.java | 2 +- .../service/qr/MobileSecretRedisCache.java | 2 +- .../permission/CustomerUserPermissions.java | 1 + .../permission/TenantAdminPermissions.java | 2 +- .../MobileApplicationControllerTest.java | 28 ++++++++++++++++++ .../common/data/mobile/QRCodeConfig.java | 4 ++- .../mobile/BaseMobileAppSettingsService.java | 29 ++++++++++++++----- 8 files changed, 57 insertions(+), 13 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java b/application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java index a65d8506ff..0cd0884126 100644 --- a/application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java +++ b/application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java @@ -135,7 +135,7 @@ public class MobileApplicationController extends BaseController { SecurityUser currentUser = getCurrentUser(); accessControlService.checkPermission(currentUser, Resource.MOBILE_APP_SETTINGS, Operation.READ); - return mobileAppSettingsService.getMobileAppSettings(currentUser.getTenantId()); + return mobileAppSettingsService.getMobileAppSettings(TenantId.SYS_TENANT_ID); } @ApiOperation(value = "Get the deep link to the associated mobile application (getMobileAppDeepLink)", diff --git a/application/src/main/java/org/thingsboard/server/service/qr/MobileSecretCaffeineCache.java b/application/src/main/java/org/thingsboard/server/service/qr/MobileSecretCaffeineCache.java index fb4c5e64cc..916d58afd7 100644 --- a/application/src/main/java/org/thingsboard/server/service/qr/MobileSecretCaffeineCache.java +++ b/application/src/main/java/org/thingsboard/server/service/qr/MobileSecretCaffeineCache.java @@ -23,7 +23,7 @@ import org.thingsboard.server.common.data.CacheConstants; import org.thingsboard.server.common.data.security.model.JwtPair; @ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "caffeine", matchIfMissing = true) -@Service("QRSecretCache") +@Service("MobileSecretCache") public class MobileSecretCaffeineCache extends CaffeineTbTransactionalCache { public MobileSecretCaffeineCache(CacheManager cacheManager) { diff --git a/application/src/main/java/org/thingsboard/server/service/qr/MobileSecretRedisCache.java b/application/src/main/java/org/thingsboard/server/service/qr/MobileSecretRedisCache.java index d5cb25c909..d29a104ae9 100644 --- a/application/src/main/java/org/thingsboard/server/service/qr/MobileSecretRedisCache.java +++ b/application/src/main/java/org/thingsboard/server/service/qr/MobileSecretRedisCache.java @@ -27,7 +27,7 @@ import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.model.JwtPair; @ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "redis") -@Service("QRSecretCache") +@Service("MobileSecretCache") public class MobileSecretRedisCache extends RedisTbTransactionalCache { public MobileSecretRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) { 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 index d26e49e4ab..af1b34ae71 100644 --- 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 @@ -49,6 +49,7 @@ public class CustomerUserPermissions extends AbstractPermissions { put(Resource.DEVICE_PROFILE, profilePermissionChecker); put(Resource.ASSET_PROFILE, profilePermissionChecker); put(Resource.TB_RESOURCE, customerResourcePermissionChecker); + put(Resource.MOBILE_APP_SETTINGS, new PermissionChecker.GenericPermissionChecker(Operation.READ)); } private static final PermissionChecker customerAlarmPermissionChecker = new PermissionChecker() { 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 index f4117e1a36..10807e4b5a 100644 --- 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 @@ -50,7 +50,7 @@ public class TenantAdminPermissions extends AbstractPermissions { put(Resource.QUEUE, queuePermissionChecker); put(Resource.VERSION_CONTROL, PermissionChecker.allowAllPermissionChecker); put(Resource.NOTIFICATION, tenantEntityPermissionChecker); - put(Resource.MOBILE_APP_SETTINGS, PermissionChecker.allowAllPermissionChecker); + put(Resource.MOBILE_APP_SETTINGS, new PermissionChecker.GenericPermissionChecker(Operation.READ)); } public static final PermissionChecker tenantEntityPermissionChecker = new PermissionChecker() { diff --git a/application/src/test/java/org/thingsboard/server/controller/MobileApplicationControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/MobileApplicationControllerTest.java index 16624cefb1..9e493980b1 100644 --- a/application/src/test/java/org/thingsboard/server/controller/MobileApplicationControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/MobileApplicationControllerTest.java @@ -104,8 +104,10 @@ public class MobileApplicationControllerTest extends AbstractControllerTest { Pattern expectedPattern = Pattern.compile("https://([^/]+)/api/noauth/qr\\?secret=([^&]+)&ttl=([^&]+)&host=([^&]+)"); Matcher parsedDeepLink = expectedPattern.matcher(deepLink); assertThat(parsedDeepLink.matches()).isTrue(); + String appHost = parsedDeepLink.group(1); String secret = parsedDeepLink.group(2); String ttl = parsedDeepLink.group(3); + assertThat(appHost).isEqualTo("demo.thingsboard.io"); assertThat(ttl).isEqualTo(String.valueOf(mobileSecretKeyTtl)); JwtPair jwtPair = doGet("/api/noauth/qr/" + secret, JwtPair.class); @@ -128,5 +130,31 @@ public class MobileApplicationControllerTest extends AbstractControllerTest { JwtPair customerJwtPair = doGet("/api/noauth/qr/" + customerSecret, JwtPair.class); assertThat(customerJwtPair).isNotNull(); + + // update mobile setting to use custom one + loginSysAdmin(); + MobileAppSettings mobileAppSettings = doGet("/api/mobile/app/settings", MobileAppSettings.class); + mobileAppSettings.setUseDefault(false); + doPost("/api/mobile/app/settings", mobileAppSettings); + + String customAppDeepLink = doGet("/api/mobile/deepLink", String.class); + Pattern customAppExpectedPattern = Pattern.compile("https://([^/]+)/api/noauth/qr\\?secret=([^&]+)&ttl=([^&]+)"); + Matcher customAppParsedDeepLink = customAppExpectedPattern.matcher(customAppDeepLink); + assertThat(customAppParsedDeepLink.matches()).isTrue(); + assertThat(customAppParsedDeepLink.group(1)).isEqualTo("localhost"); + + loginTenantAdmin(); + String tenantCustomAppDeepLink = doGet("/api/mobile/deepLink", String.class); + Matcher tenantCustomAppParsedDeepLink = customAppExpectedPattern.matcher(tenantCustomAppDeepLink); + assertThat(tenantCustomAppParsedDeepLink.matches()).isTrue(); + assertThat(tenantCustomAppParsedDeepLink.group(1)).isEqualTo("localhost"); + + loginCustomerUser(); + String customerCustomAppDeepLink = doGet("/api/mobile/deepLink", String.class); + Matcher customerCustomAppParsedDeepLink = customAppExpectedPattern.matcher(customerCustomAppDeepLink); + assertThat(customerCustomAppParsedDeepLink.matches()).isTrue(); + assertThat(customerCustomAppParsedDeepLink.group(1)).isEqualTo("localhost"); + + } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/QRCodeConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/QRCodeConfig.java index 20de410137..b3f975870e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/QRCodeConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/QRCodeConfig.java @@ -16,12 +16,14 @@ package org.thingsboard.server.common.data.mobile; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.validation.NoXss; @Data +@Builder @NoArgsConstructor @AllArgsConstructor @EqualsAndHashCode @@ -29,7 +31,7 @@ public class QRCodeConfig { private boolean showOnHomePage; private boolean badgeEnabled; - private boolean labelEnabled; + private boolean qrCodeLabelEnabled; private BadgePosition badgePosition; private BadgeStyle badgeStyle; @NoXss diff --git a/dao/src/main/java/org/thingsboard/server/dao/mobile/BaseMobileAppSettingsService.java b/dao/src/main/java/org/thingsboard/server/dao/mobile/BaseMobileAppSettingsService.java index 1c05cfbb92..d11edafb1b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/mobile/BaseMobileAppSettingsService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/mobile/BaseMobileAppSettingsService.java @@ -20,8 +20,10 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.event.TransactionalEventListener; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.mobile.AndroidConfig; import org.thingsboard.server.common.data.mobile.BadgePosition; import org.thingsboard.server.common.data.mobile.BadgeStyle; +import org.thingsboard.server.common.data.mobile.IosConfig; import org.thingsboard.server.common.data.mobile.MobileAppSettings; import org.thingsboard.server.common.data.mobile.QRCodeConfig; import org.thingsboard.server.dao.entity.AbstractCachedService; @@ -45,7 +47,7 @@ public class BaseMobileAppSettingsService extends AbstractCachedService mobileAppSettingsDao.findByTenantId(tenantId), false); + () -> mobileAppSettingsDao.findByTenantId(tenantId), true); return constructMobileAppSettings(mobileAppSettings); } @@ -60,14 +62,25 @@ public class BaseMobileAppSettingsService extends AbstractCachedService Date: Thu, 25 Apr 2024 18:24:27 +0300 Subject: [PATCH 11/84] added mobile app settings cleanup for tenant deletion --- .../src/main/data/upgrade/3.6.4/schema_update.sql | 6 +++--- .../controller/MobileApplicationController.java | 13 +++++-------- .../controller/MobileApplicationControllerTest.java | 10 +++++----- .../common/data/mobile/MobileAppSettings.java | 2 +- .../dao/mobile/BaseMobileAppSettingsService.java | 12 +++++++++++- .../server/dao/mobile/MobileAppSettingsService.java | 2 ++ .../server/dao/model/ModelConstants.java | 2 +- .../dao/model/sql/MobileAppSettingsEntity.java | 8 ++++---- .../server/dao/tenant/TenantServiceImpl.java | 6 ++++++ dao/src/main/resources/sql/schema-entities.sql | 2 +- 10 files changed, 39 insertions(+), 24 deletions(-) diff --git a/application/src/main/data/upgrade/3.6.4/schema_update.sql b/application/src/main/data/upgrade/3.6.4/schema_update.sql index b7bbdc761f..5f00b7c2cf 100644 --- a/application/src/main/data/upgrade/3.6.4/schema_update.sql +++ b/application/src/main/data/upgrade/3.6.4/schema_update.sql @@ -135,14 +135,14 @@ DELETE FROM asset_profile WHERE name ='TbServiceQueue'; -- QUEUE STATS UPDATE END --- MOBILE APPS TABLE CREATE START +-- MOBILE APP SETTINGS TABLE CREATE START CREATE TABLE IF NOT EXISTS mobile_app_settings ( tenant_id UUID NOT NULL, - use_default boolean, + use_default_app boolean, android_config VARCHAR(1000), ios_config VARCHAR(1000), qr_code_config VARCHAR(100000) ); --- MOBILE APPS TABLE CREATE END \ No newline at end of file +-- MOBILE APP SETTINGS TABLE CREATE END \ No newline at end of file diff --git a/application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java b/application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java index 0cd0884126..e8ac13b077 100644 --- a/application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java +++ b/application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java @@ -106,7 +106,6 @@ public class MobileApplicationController extends BaseController { public ResponseEntity getAppleAppSiteAssociation() { MobileAppSettings mobileAppSettings = mobileAppSettingsService.getMobileAppSettings(TenantId.SYS_TENANT_ID); IosConfig iosConfig = mobileAppSettings.getIosConfig(); - if (iosConfig != null && iosConfig.isEnabled() && !iosConfig.getAppId().isBlank()) { return ResponseEntity.ok(JacksonUtil.toJsonNode(String.format(APPLE_APP_SITE_ASSOCIATION_PATTERN, iosConfig.getAppId()))); } else { @@ -115,7 +114,7 @@ public class MobileApplicationController extends BaseController { } @ApiOperation(value = "Create Or Update the Mobile application settings (saveMobileAppSettings)", - notes = "The payload contains associated android and iOS applications and platform qr code widget settings." + SYSTEM_AUTHORITY_PARAGRAPH) + notes = "The request payload contains configuration for android/iOS applications and platform qr code widget settings." + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") @PostMapping(value = "/api/mobile/app/settings") public MobileAppSettings saveMobileAppSettings(@Parameter(description = "A JSON value representing the mobile apps configuration") @@ -128,7 +127,7 @@ public class MobileApplicationController extends BaseController { } @ApiOperation(value = "Get Mobile application settings (getMobileAppSettings)", - notes = "The payload contains associated android and iOS applications and platform qr code widget settings." + AVAILABLE_FOR_ANY_AUTHORIZED_USER) + notes = "The response payload contains configuration for android/iOS applications and platform qr code widget settings." + AVAILABLE_FOR_ANY_AUTHORIZED_USER) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/api/mobile/app/settings") public MobileAppSettings getMobileAppSettings() throws ThingsboardException { @@ -139,18 +138,16 @@ public class MobileApplicationController extends BaseController { } @ApiOperation(value = "Get the deep link to the associated mobile application (getMobileAppDeepLink)", - notes = "Fetch the url that takes user to associated mobile application " + AVAILABLE_FOR_ANY_AUTHORIZED_USER) + notes = "Fetch the url that takes user to linked mobile application " + AVAILABLE_FOR_ANY_AUTHORIZED_USER) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/api/mobile/deepLink", produces = "text/plain") public String getMobileAppDeepLink(HttpServletRequest request) throws ThingsboardException, URISyntaxException { - SecurityUser currentUser = getCurrentUser(); - String secret = mobileAppSecretService.generateMobileAppSecret(currentUser); - + String secret = mobileAppSecretService.generateMobileAppSecret(getCurrentUser()); String baseUrl = systemSecurityService.getBaseUrl(TenantId.SYS_TENANT_ID, new CustomerId(EntityId.NULL_UUID), request); String platformDomain = new URI(baseUrl).getHost(); MobileAppSettings mobileAppSettings = mobileAppSettingsService.getMobileAppSettings(TenantId.SYS_TENANT_ID); String appDomain; - if (!mobileAppSettings.isUseDefault()) { + if (!mobileAppSettings.isUseDefaultApp()) { appDomain = platformDomain; } else { appDomain = DEFAULT_APP_DOMAIN; diff --git a/application/src/test/java/org/thingsboard/server/controller/MobileApplicationControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/MobileApplicationControllerTest.java index 9e493980b1..b7dd9b5b3b 100644 --- a/application/src/test/java/org/thingsboard/server/controller/MobileApplicationControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/MobileApplicationControllerTest.java @@ -52,7 +52,7 @@ public class MobileApplicationControllerTest extends AbstractControllerTest { qrCodeConfig.setQrCodeLabel(TEST_LABEL); MobileAppSettings mobileAppSettings = new MobileAppSettings(); - mobileAppSettings.setUseDefault(true); + mobileAppSettings.setUseDefaultApp(true); AndroidConfig androidConfig = AndroidConfig.builder() .appPackage(ANDROID_PACKAGE_NAME) .sha256CertFingerprints(ANDROID_APP_SHA256) @@ -75,15 +75,15 @@ public class MobileApplicationControllerTest extends AbstractControllerTest { loginSysAdmin(); MobileAppSettings mobileAppSettings = doGet("/api/mobile/app/settings", MobileAppSettings.class); assertThat(mobileAppSettings.getQrCodeConfig().getQrCodeLabel()).isEqualTo(TEST_LABEL); - assertThat(mobileAppSettings.isUseDefault()).isTrue(); + assertThat(mobileAppSettings.isUseDefaultApp()).isTrue(); - mobileAppSettings.setUseDefault(false); + mobileAppSettings.setUseDefaultApp(false); doPost("/api/mobile/app/settings", mobileAppSettings) .andExpect(status().isOk()); MobileAppSettings updatedMobileAppSettings = doGet("/api/mobile/app/settings", MobileAppSettings.class); - assertThat(updatedMobileAppSettings.isUseDefault()).isFalse(); + assertThat(updatedMobileAppSettings.isUseDefaultApp()).isFalse(); } @Test @@ -134,7 +134,7 @@ public class MobileApplicationControllerTest extends AbstractControllerTest { // update mobile setting to use custom one loginSysAdmin(); MobileAppSettings mobileAppSettings = doGet("/api/mobile/app/settings", MobileAppSettings.class); - mobileAppSettings.setUseDefault(false); + mobileAppSettings.setUseDefaultApp(false); doPost("/api/mobile/app/settings", mobileAppSettings); String customAppDeepLink = doGet("/api/mobile/deepLink", String.class); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/MobileAppSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/MobileAppSettings.java index 9153265950..217db862f8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/MobileAppSettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/MobileAppSettings.java @@ -27,7 +27,7 @@ public class MobileAppSettings implements Serializable { private static final long serialVersionUID = 2628323657987010348L; private TenantId tenantId; - boolean useDefault; + private boolean useDefaultApp; @Valid private AndroidConfig androidConfig; @Valid diff --git a/dao/src/main/java/org/thingsboard/server/dao/mobile/BaseMobileAppSettingsService.java b/dao/src/main/java/org/thingsboard/server/dao/mobile/BaseMobileAppSettingsService.java index d11edafb1b..a72498b3b2 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/mobile/BaseMobileAppSettingsService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/mobile/BaseMobileAppSettingsService.java @@ -28,11 +28,14 @@ import org.thingsboard.server.common.data.mobile.MobileAppSettings; import org.thingsboard.server.common.data.mobile.QRCodeConfig; import org.thingsboard.server.dao.entity.AbstractCachedService; +import static org.thingsboard.server.dao.service.Validator.validateId; + @Service @Slf4j @RequiredArgsConstructor public class BaseMobileAppSettingsService extends AbstractCachedService implements MobileAppSettingsService { + public static final String INCORRECT_TENANT_ID = "Incorrect tenantId "; private static final String DEFAULT_QR_CODE_LABEL = "Scan to connect or download mobile app"; private final MobileAppSettingsDao mobileAppSettingsDao; @@ -51,6 +54,13 @@ public class BaseMobileAppSettingsService extends AbstractCachedService INCORRECT_TENANT_ID + id); + mobileAppSettingsDao.removeByTenantId(tenantId); + } + @TransactionalEventListener(classes = MobileAppSettingsEvictEvent.class) @Override public void handleEvictEvent(MobileAppSettingsEvictEvent event) { @@ -60,7 +70,7 @@ public class BaseMobileAppSettingsService extends AbstractCachedService, Seria @Column(name = ModelConstants.TENANT_ID_COLUMN, columnDefinition = "uuid") protected UUID tenantId; - @Column(name = ModelConstants.MOBILE_APP_SETTINGS_USE_DEFAULT_PROPERTY) - private boolean useDefault; + @Column(name = ModelConstants.MOBILE_APP_SETTINGS_USE_DEFAULT_APP_PROPERTY) + private boolean useDefaultApp; @Convert(converter = JsonConverter.class) @Column(name = ModelConstants.MOBILE_APP_SETTINGS_ANDROID_CONFIG_PROPERTY) @@ -63,7 +63,7 @@ public class MobileAppSettingsEntity implements ToData, Seria public MobileAppSettingsEntity(MobileAppSettings mobileAppSettings) { this.tenantId = mobileAppSettings.getTenantId().getId(); - this.useDefault = mobileAppSettings.isUseDefault(); + this.useDefaultApp = mobileAppSettings.isUseDefaultApp(); if (mobileAppSettings.getAndroidConfig() != null) { this.androidConfig = JacksonUtil.valueToTree(mobileAppSettings.getAndroidConfig()); } @@ -79,7 +79,7 @@ public class MobileAppSettingsEntity implements ToData, Seria public MobileAppSettings toData() { MobileAppSettings mobileAppSettings = new MobileAppSettings(); mobileAppSettings.setTenantId(TenantId.fromUUID(tenantId)); - mobileAppSettings.setUseDefault(useDefault); + mobileAppSettings.setUseDefaultApp(useDefaultApp); if (qrCodeConfig != null) { mobileAppSettings.setAndroidConfig(JacksonUtil.convertValue(androidConfig, AndroidConfig.class)); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java index 8b3e65c927..7d256ceb27 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java @@ -42,6 +42,7 @@ import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.entity.AbstractCachedEntityService; import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; +import org.thingsboard.server.dao.mobile.MobileAppSettingsService; import org.thingsboard.server.dao.notification.NotificationRequestService; import org.thingsboard.server.dao.notification.NotificationRuleService; import org.thingsboard.server.dao.notification.NotificationSettingsService; @@ -137,6 +138,10 @@ public class TenantServiceImpl extends AbstractCachedEntityService Date: Tue, 30 Apr 2024 17:22:26 +0800 Subject: [PATCH 12/84] Fix wrong "Realtime - Interval" query in alarms table widget --- .../dao/sql/query/DefaultAlarmQueryRepository.java | 9 +++++++-- ui-ngx/src/app/core/api/alarm-data-subscription.ts | 3 +++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultAlarmQueryRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultAlarmQueryRepository.java index a711886e68..05da22f9a5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultAlarmQueryRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultAlarmQueryRepository.java @@ -206,8 +206,13 @@ public class DefaultAlarmQueryRepository implements AlarmQueryRepository { long startTs; long endTs; if (pageLink.getTimeWindow() > 0) { - endTs = System.currentTimeMillis(); - startTs = endTs - pageLink.getTimeWindow(); + if (pageLink.getStartTs() > 0) { + startTs = pageLink.getStartTs(); + endTs = startTs + pageLink.getTimeWindow(); + } else { + endTs = System.currentTimeMillis(); + startTs = endTs - pageLink.getTimeWindow(); + } } else { startTs = pageLink.getStartTs(); endTs = pageLink.getEndTs(); diff --git a/ui-ngx/src/app/core/api/alarm-data-subscription.ts b/ui-ngx/src/app/core/api/alarm-data-subscription.ts index 80b3c58ab8..6b1e90ac63 100644 --- a/ui-ngx/src/app/core/api/alarm-data-subscription.ts +++ b/ui-ngx/src/app/core/api/alarm-data-subscription.ts @@ -128,6 +128,9 @@ export class AlarmDataSubscription { this.alarmDataCommand.query.pageLink.startTs = this.subsTw.fixedWindow.startTimeMs; this.alarmDataCommand.query.pageLink.endTs = this.subsTw.fixedWindow.endTimeMs; } else { + if (this.subsTw.startTs) { + this.alarmDataCommand.query.pageLink.startTs = this.subsTw.startTs; + } this.alarmDataCommand.query.pageLink.timeWindow = this.subsTw.realtimeWindowMs; } From 0003caf4fb79c7dd4fc350ce56af64ef64616c03 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Tue, 30 Apr 2024 18:46:38 +0300 Subject: [PATCH 13/84] added id, createdTime to mobile app setting entity, added MobileAppSettingsValidator --- .../main/data/upgrade/3.6.4/schema_update.sql | 4 +- .../MobileApplicationController.java | 27 +++++- .../MobileApplicationControllerTest.java | 85 +++++++++++++++++-- .../common/data/id/MobileAppSettingsId.java | 37 ++++++++ .../common/data/mobile/MobileAppSettings.java | 13 ++- .../mobile/BaseMobileAppSettingsService.java | 30 +++++-- .../dao/mobile/MobileAppSettingsDao.java | 5 +- .../server/dao/model/ModelConstants.java | 4 +- .../model/sql/MobileAppSettingsEntity.java | 44 ++++------ .../MobileAppSettingsDataValidator.java | 51 +++++++++++ ...pDao.java => JpaMobileAppSettingsDao.java} | 22 +++-- .../mobile/MobileAppSettingsRepository.java | 2 + .../main/resources/sql/schema-entities.sql | 4 +- 13 files changed, 268 insertions(+), 60 deletions(-) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/MobileAppSettingsId.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/service/validator/MobileAppSettingsDataValidator.java rename dao/src/main/java/org/thingsboard/server/dao/sql/mobile/{JpaMobileAppDao.java => JpaMobileAppSettingsDao.java} (73%) diff --git a/application/src/main/data/upgrade/3.6.4/schema_update.sql b/application/src/main/data/upgrade/3.6.4/schema_update.sql index 5f00b7c2cf..03e8ca0a29 100644 --- a/application/src/main/data/upgrade/3.6.4/schema_update.sql +++ b/application/src/main/data/upgrade/3.6.4/schema_update.sql @@ -138,7 +138,9 @@ DELETE FROM asset_profile WHERE name ='TbServiceQueue'; -- MOBILE APP SETTINGS TABLE CREATE START CREATE TABLE IF NOT EXISTS mobile_app_settings ( - tenant_id UUID NOT NULL, + id uuid NOT NULL CONSTRAINT mobile_app_settings_pkey PRIMARY KEY, + created_time bigint NOT NULL, + tenant_id uuid UNIQUE NOT NULL, use_default_app boolean, android_config VARCHAR(1000), ios_config VARCHAR(1000), diff --git a/application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java b/application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java index e8ac13b077..96b1bf4d35 100644 --- a/application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java +++ b/application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java @@ -20,17 +20,17 @@ import io.swagger.v3.oas.annotations.Parameter; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; 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.RequestHeader; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.common.util.JacksonUtil; 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.mobile.AndroidConfig; import org.thingsboard.server.common.data.mobile.IosConfig; @@ -81,10 +81,13 @@ public class MobileApplicationController extends BaseController { " }\n" + "}"; + public static final String ANDROID_APPLICATION_STORE_LINK = "https://play.google.com/store/apps/details?id=org.thingsboard.demo.app"; + public static final String APPLE_APPLICATION_STORE_LINK = "https://apps.apple.com/us/app/thingsboard-live/id1594355695"; public static final String SECRET = "secret"; public static final String SECRET_PARAM_DESCRIPTION = "A string value representing short-live secret key"; public static final String DEFAULT_APP_DOMAIN = "demo.thingsboard.io"; public static final String DEEP_LINK_PATTERN = "https://%s/api/noauth/qr?secret=%s&ttl=%s"; + private final SystemSecurityService systemSecurityService; private final MobileAppSecretService mobileAppSecretService; private final MobileAppSettingsService mobileAppSettingsService; @@ -143,7 +146,7 @@ public class MobileApplicationController extends BaseController { @GetMapping(value = "/api/mobile/deepLink", produces = "text/plain") public String getMobileAppDeepLink(HttpServletRequest request) throws ThingsboardException, URISyntaxException { String secret = mobileAppSecretService.generateMobileAppSecret(getCurrentUser()); - String baseUrl = systemSecurityService.getBaseUrl(TenantId.SYS_TENANT_ID, new CustomerId(EntityId.NULL_UUID), request); + String baseUrl = systemSecurityService.getBaseUrl(TenantId.SYS_TENANT_ID, null, request); String platformDomain = new URI(baseUrl).getHost(); MobileAppSettings mobileAppSettings = mobileAppSettingsService.getMobileAppSettings(TenantId.SYS_TENANT_ID); String appDomain; @@ -156,7 +159,7 @@ public class MobileApplicationController extends BaseController { if (!appDomain.equals(platformDomain)) { deepLink = deepLink + "&host=" + baseUrl; } - return deepLink; + return "\"" + deepLink + "\""; } @ApiOperation(value = "Get User Token (getUserTokenByMobileSecret)", @@ -167,4 +170,20 @@ public class MobileApplicationController extends BaseController { checkParameter(SECRET, secret); return mobileAppSecretService.getJwtPair(secret); } + + @GetMapping(value = "/api/noauth/qr") + public ResponseEntity getApplicationRedirect(@RequestHeader(value = "User-Agent") String userAgent) { + if (userAgent.contains("Android")) { + return ResponseEntity.status(HttpStatus.FOUND) + .header("Location", ANDROID_APPLICATION_STORE_LINK) + .build(); + } else if (userAgent.contains("iPhone") || userAgent.contains("iPad")) { + return ResponseEntity.status(HttpStatus.FOUND) + .header("Location", APPLE_APPLICATION_STORE_LINK) + .build(); + } else { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .build(); + } + } } diff --git a/application/src/test/java/org/thingsboard/server/controller/MobileApplicationControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/MobileApplicationControllerTest.java index b7dd9b5b3b..c4f1301457 100644 --- a/application/src/test/java/org/thingsboard/server/controller/MobileApplicationControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/MobileApplicationControllerTest.java @@ -31,6 +31,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @Slf4j @@ -48,10 +49,10 @@ public class MobileApplicationControllerTest extends AbstractControllerTest { public void setUp() throws Exception { loginSysAdmin(); + MobileAppSettings mobileAppSettings = doGet("/api/mobile/app/settings", MobileAppSettings.class); QRCodeConfig qrCodeConfig = new QRCodeConfig(); qrCodeConfig.setQrCodeLabel(TEST_LABEL); - MobileAppSettings mobileAppSettings = new MobileAppSettings(); mobileAppSettings.setUseDefaultApp(true); AndroidConfig androidConfig = AndroidConfig.builder() .appPackage(ANDROID_PACKAGE_NAME) @@ -67,7 +68,8 @@ public class MobileApplicationControllerTest extends AbstractControllerTest { mobileAppSettings.setIosConfig(iosConfig); mobileAppSettings.setQrCodeConfig(qrCodeConfig); - doPost("/api/mobile/app/settings", mobileAppSettings); + doPost("/api/mobile/app/settings", mobileAppSettings) + .andExpect(status().isOk()); } @Test @@ -86,6 +88,79 @@ public class MobileApplicationControllerTest extends AbstractControllerTest { assertThat(updatedMobileAppSettings.isUseDefaultApp()).isFalse(); } + @Test + public void testShouldNotSaveMobileAppSettingsWithoutRequiredConfig() throws Exception { + loginSysAdmin(); + MobileAppSettings mobileAppSettings = doGet("/api/mobile/app/settings", MobileAppSettings.class); + + mobileAppSettings.setUseDefaultApp(false); + mobileAppSettings.setAndroidConfig(null); + mobileAppSettings.setIosConfig(null); + mobileAppSettings.setQrCodeConfig(null); + + doPost("/api/mobile/app/settings", mobileAppSettings) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("Android/ios settings are required to use custom application!"))); + + mobileAppSettings.setAndroidConfig(AndroidConfig.builder().enabled(false).build()); + doPost("/api/mobile/app/settings", mobileAppSettings) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("Android/ios settings are required to use custom application!"))); + + mobileAppSettings.setIosConfig(IosConfig.builder().enabled(false).build()); + doPost("/api/mobile/app/settings", mobileAppSettings) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("Qr code config is required!"))); + + mobileAppSettings.setQrCodeConfig(QRCodeConfig.builder().showOnHomePage(false).build()); + doPost("/api/mobile/app/settings", mobileAppSettings) + .andExpect(status().isOk()); + } + + @Test + public void testShouldNotSaveMobileAppSettingsWithoutRequiredAndroidConf() throws Exception { + loginSysAdmin(); + MobileAppSettings mobileAppSettings = doGet("/api/mobile/app/settings", MobileAppSettings.class); + AndroidConfig androidConfig = AndroidConfig.builder() + .enabled(true) + .appPackage(null) + .sha256CertFingerprints(null) + .build(); + mobileAppSettings.setAndroidConfig(androidConfig); + + doPost("/api/mobile/app/settings", mobileAppSettings) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("Application package and sha256 cert fingerprints are required for enabled android settings!"))); + + androidConfig.setAppPackage("test_app_package"); + doPost("/api/mobile/app/settings", mobileAppSettings) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("Application package and sha256 cert fingerprints are required for enabled android settings!"))); + + androidConfig.setSha256CertFingerprints("test_sha_256"); + doPost("/api/mobile/app/settings", mobileAppSettings) + .andExpect(status().isOk()); + } + + @Test + public void testShouldNotSaveMobileAppSettingsWithoutRequiredIosConf() throws Exception { + loginSysAdmin(); + MobileAppSettings mobileAppSettings = doGet("/api/mobile/app/settings", MobileAppSettings.class); + IosConfig iosConfig = IosConfig.builder() + .enabled(true) + .appId(null) + .build(); + mobileAppSettings.setIosConfig(iosConfig); + + doPost("/api/mobile/app/settings", mobileAppSettings) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("Application id is required for enabled ios settings!"))); + + iosConfig.setAppId("test_app_id"); + doPost("/api/mobile/app/settings", mobileAppSettings) + .andExpect(status().isOk()); + } + @Test public void testGetApplicationAssociations() throws Exception { JsonNode assetLinks = doGet("/.well-known/assetlinks.json", JsonNode.class); @@ -101,7 +176,7 @@ public class MobileApplicationControllerTest extends AbstractControllerTest { loginSysAdmin(); String deepLink = doGet("/api/mobile/deepLink", String.class); - Pattern expectedPattern = Pattern.compile("https://([^/]+)/api/noauth/qr\\?secret=([^&]+)&ttl=([^&]+)&host=([^&]+)"); + Pattern expectedPattern = Pattern.compile("\"https://([^/]+)/api/noauth/qr\\?secret=([^&]+)&ttl=([^&]+)&host=([^&]+)\""); Matcher parsedDeepLink = expectedPattern.matcher(deepLink); assertThat(parsedDeepLink.matches()).isTrue(); String appHost = parsedDeepLink.group(1); @@ -138,7 +213,7 @@ public class MobileApplicationControllerTest extends AbstractControllerTest { doPost("/api/mobile/app/settings", mobileAppSettings); String customAppDeepLink = doGet("/api/mobile/deepLink", String.class); - Pattern customAppExpectedPattern = Pattern.compile("https://([^/]+)/api/noauth/qr\\?secret=([^&]+)&ttl=([^&]+)"); + Pattern customAppExpectedPattern = Pattern.compile("\"https://([^/]+)/api/noauth/qr\\?secret=([^&]+)&ttl=([^&]+)\""); Matcher customAppParsedDeepLink = customAppExpectedPattern.matcher(customAppDeepLink); assertThat(customAppParsedDeepLink.matches()).isTrue(); assertThat(customAppParsedDeepLink.group(1)).isEqualTo("localhost"); @@ -154,7 +229,5 @@ public class MobileApplicationControllerTest extends AbstractControllerTest { Matcher customerCustomAppParsedDeepLink = customAppExpectedPattern.matcher(customerCustomAppDeepLink); assertThat(customerCustomAppParsedDeepLink.matches()).isTrue(); assertThat(customerCustomAppParsedDeepLink.group(1)).isEqualTo("localhost"); - - } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/MobileAppSettingsId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/MobileAppSettingsId.java new file mode 100644 index 0000000000..d152701fc4 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/MobileAppSettingsId.java @@ -0,0 +1,37 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.id; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.UUID; + +@Schema +public class MobileAppSettingsId extends UUIDBased { + + private static final long serialVersionUID = 1L; + + @JsonCreator + public MobileAppSettingsId(@JsonProperty("id") UUID id) { + super(id); + } + + public static MobileAppSettingsId fromString(String mobileAppSettingsId) { + return new MobileAppSettingsId(UUID.fromString(mobileAppSettingsId)); + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/MobileAppSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/MobileAppSettings.java index 217db862f8..9a7b88f22c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/MobileAppSettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/MobileAppSettings.java @@ -17,12 +17,13 @@ package org.thingsboard.server.common.data.mobile; import jakarta.validation.Valid; import lombok.Data; +import org.thingsboard.server.common.data.BaseData; +import org.thingsboard.server.common.data.HasTenantId; +import org.thingsboard.server.common.data.id.MobileAppSettingsId; import org.thingsboard.server.common.data.id.TenantId; -import java.io.Serializable; - @Data -public class MobileAppSettings implements Serializable { +public class MobileAppSettings extends BaseData implements HasTenantId { private static final long serialVersionUID = 2628323657987010348L; @@ -35,4 +36,10 @@ public class MobileAppSettings implements Serializable { @Valid private QRCodeConfig qrCodeConfig; + public MobileAppSettings() { + } + public MobileAppSettings(MobileAppSettingsId id) { + super(id); + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/mobile/BaseMobileAppSettingsService.java b/dao/src/main/java/org/thingsboard/server/dao/mobile/BaseMobileAppSettingsService.java index a72498b3b2..86de46fed1 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/mobile/BaseMobileAppSettingsService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/mobile/BaseMobileAppSettingsService.java @@ -17,6 +17,7 @@ package org.thingsboard.server.dao.mobile; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.hibernate.exception.ConstraintViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.event.TransactionalEventListener; import org.thingsboard.server.common.data.id.TenantId; @@ -26,24 +27,41 @@ import org.thingsboard.server.common.data.mobile.BadgeStyle; import org.thingsboard.server.common.data.mobile.IosConfig; import org.thingsboard.server.common.data.mobile.MobileAppSettings; import org.thingsboard.server.common.data.mobile.QRCodeConfig; -import org.thingsboard.server.dao.entity.AbstractCachedService; +import org.thingsboard.server.dao.entity.AbstractCachedEntityService; +import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.service.DataValidator; import static org.thingsboard.server.dao.service.Validator.validateId; @Service @Slf4j @RequiredArgsConstructor -public class BaseMobileAppSettingsService extends AbstractCachedService implements MobileAppSettingsService { +public class BaseMobileAppSettingsService extends AbstractCachedEntityService implements MobileAppSettingsService { public static final String INCORRECT_TENANT_ID = "Incorrect tenantId "; private static final String DEFAULT_QR_CODE_LABEL = "Scan to connect or download mobile app"; + private final MobileAppSettingsDao mobileAppSettingsDao; + private final DataValidator mobileAppSettingsDataValidator; @Override - public MobileAppSettings saveMobileAppSettings(TenantId tenantId, MobileAppSettings settings) { - MobileAppSettings mobileAppSettings = mobileAppSettingsDao.save(tenantId, settings); - publishEvictEvent(new MobileAppSettingsEvictEvent(tenantId)); - return mobileAppSettings; + public MobileAppSettings saveMobileAppSettings(TenantId tenantId, MobileAppSettings mobileAppSettings) { + mobileAppSettingsDataValidator.validate(mobileAppSettings, s -> tenantId); + try { + MobileAppSettings savedMobileAppSettings = mobileAppSettingsDao.save(tenantId, mobileAppSettings); + publishEvictEvent(new MobileAppSettingsEvictEvent(tenantId)); + return savedMobileAppSettings; + } catch (Exception exception) { + if (mobileAppSettings != null) { + handleEvictEvent(new MobileAppSettingsEvictEvent(tenantId)); + } + ConstraintViolationException e = extractConstraintViolationException(exception).orElse(null); + if (e != null && e.getConstraintName() != null && e.getConstraintName().equalsIgnoreCase("mobile_app_settings_tenant_id_key")) { + throw new DataValidationException("Mobile application for specified tenant already exists!"); + } else { + throw exception; + } + } } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsDao.java b/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsDao.java index 54661bccb0..094ead1a5b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsDao.java @@ -17,11 +17,10 @@ package org.thingsboard.server.dao.mobile; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.mobile.MobileAppSettings; +import org.thingsboard.server.dao.Dao; -public interface MobileAppSettingsDao { - - MobileAppSettings save(TenantId tenantId, MobileAppSettings appSettings); +public interface MobileAppSettingsDao extends Dao { MobileAppSettings findByTenantId(TenantId tenantId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index 2b770cbc8e..623d899293 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -665,8 +665,8 @@ public class ModelConstants { public static final String MOBILE_APP_SETTINGS_TABLE_NAME = "mobile_app_settings"; public static final String MOBILE_APP_SETTINGS_USE_DEFAULT_APP_PROPERTY = "use_default_app"; public static final String MOBILE_APP_SETTINGS_ANDROID_CONFIG_PROPERTY = "android_config"; - public static final String MOBILE_APP_IOS_CONFIG_PROPERTY = "ios_config"; - public static final String MOBILE_APP_QR_CODE_CONFIG_PROPERTY = "qr_code_config"; + public static final String MOBILE_APP_SETTINGS_IOS_CONFIG_PROPERTY = "ios_config"; + public static final String MOBILE_APP_SETTINGS_QR_CODE_CONFIG_PROPERTY = "qr_code_config"; protected static final String[] NONE_AGGREGATION_COLUMNS = new String[]{LONG_VALUE_COLUMN, DOUBLE_VALUE_COLUMN, BOOLEAN_VALUE_COLUMN, STRING_VALUE_COLUMN, JSON_VALUE_COLUMN, KEY_COLUMN, TS_COLUMN}; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/MobileAppSettingsEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/MobileAppSettingsEntity.java index aa3f77a58d..0e6ad933dc 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/MobileAppSettingsEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/MobileAppSettingsEntity.java @@ -19,30 +19,27 @@ import com.fasterxml.jackson.databind.JsonNode; import jakarta.persistence.Column; import jakarta.persistence.Convert; import jakarta.persistence.Entity; -import jakarta.persistence.Id; import jakarta.persistence.Table; import lombok.Data; import lombok.NoArgsConstructor; -import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.id.MobileAppSettingsId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.mobile.AndroidConfig; import org.thingsboard.server.common.data.mobile.IosConfig; import org.thingsboard.server.common.data.mobile.MobileAppSettings; import org.thingsboard.server.common.data.mobile.QRCodeConfig; +import org.thingsboard.server.dao.model.BaseSqlEntity; import org.thingsboard.server.dao.model.ModelConstants; -import org.thingsboard.server.dao.model.ToData; import org.thingsboard.server.dao.util.mapping.JsonConverter; -import java.io.Serializable; import java.util.UUID; @Data @NoArgsConstructor @Entity @Table(name = ModelConstants.MOBILE_APP_SETTINGS_TABLE_NAME) -public class MobileAppSettingsEntity implements ToData, Serializable { +public class MobileAppSettingsEntity extends BaseSqlEntity { - @Id @Column(name = ModelConstants.TENANT_ID_COLUMN, columnDefinition = "uuid") protected UUID tenantId; @@ -54,41 +51,34 @@ public class MobileAppSettingsEntity implements ToData, Seria private JsonNode androidConfig; @Convert(converter = JsonConverter.class) - @Column(name = ModelConstants.MOBILE_APP_IOS_CONFIG_PROPERTY) + @Column(name = ModelConstants.MOBILE_APP_SETTINGS_IOS_CONFIG_PROPERTY) private JsonNode iosConfig; @Convert(converter = JsonConverter.class) - @Column(name = ModelConstants.MOBILE_APP_QR_CODE_CONFIG_PROPERTY) + @Column(name = ModelConstants.MOBILE_APP_SETTINGS_QR_CODE_CONFIG_PROPERTY) private JsonNode qrCodeConfig; public MobileAppSettingsEntity(MobileAppSettings mobileAppSettings) { + if (mobileAppSettings.getId() != null) { + this.setId(mobileAppSettings.getId().getId()); + } + this.setCreatedTime(mobileAppSettings.getCreatedTime()); this.tenantId = mobileAppSettings.getTenantId().getId(); this.useDefaultApp = mobileAppSettings.isUseDefaultApp(); - if (mobileAppSettings.getAndroidConfig() != null) { - this.androidConfig = JacksonUtil.valueToTree(mobileAppSettings.getAndroidConfig()); - } - if (mobileAppSettings.getIosConfig() != null) { - this.iosConfig = JacksonUtil.valueToTree(mobileAppSettings.getIosConfig()); - } - if (mobileAppSettings.getQrCodeConfig() != null) { - this.qrCodeConfig = JacksonUtil.valueToTree(mobileAppSettings.getQrCodeConfig()); - } + this.androidConfig = toJson(mobileAppSettings.getAndroidConfig()); + this.iosConfig = toJson(mobileAppSettings.getIosConfig()); + this.qrCodeConfig = toJson(mobileAppSettings.getQrCodeConfig()); } @Override public MobileAppSettings toData() { - MobileAppSettings mobileAppSettings = new MobileAppSettings(); + MobileAppSettings mobileAppSettings = new MobileAppSettings(new MobileAppSettingsId(getUuid())); + mobileAppSettings.setCreatedTime(createdTime); mobileAppSettings.setTenantId(TenantId.fromUUID(tenantId)); mobileAppSettings.setUseDefaultApp(useDefaultApp); - if (qrCodeConfig != null) { - mobileAppSettings.setAndroidConfig(JacksonUtil.convertValue(androidConfig, AndroidConfig.class)); - } - if (qrCodeConfig != null) { - mobileAppSettings.setIosConfig(JacksonUtil.convertValue(iosConfig, IosConfig.class)); - } - if (qrCodeConfig != null) { - mobileAppSettings.setQrCodeConfig(JacksonUtil.convertValue(qrCodeConfig, QRCodeConfig.class)); - } + mobileAppSettings.setAndroidConfig(fromJson(androidConfig, AndroidConfig.class)); + mobileAppSettings.setIosConfig(fromJson(iosConfig, IosConfig.class)); + mobileAppSettings.setQrCodeConfig(fromJson(qrCodeConfig, QRCodeConfig.class)); return mobileAppSettings; } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/MobileAppSettingsDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/MobileAppSettingsDataValidator.java new file mode 100644 index 0000000000..6400cd08af --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/MobileAppSettingsDataValidator.java @@ -0,0 +1,51 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.validator; + +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.mobile.AndroidConfig; +import org.thingsboard.server.common.data.mobile.IosConfig; +import org.thingsboard.server.common.data.mobile.MobileAppSettings; +import org.thingsboard.server.common.data.mobile.QRCodeConfig; +import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.service.DataValidator; + +@Component +@AllArgsConstructor +public class MobileAppSettingsDataValidator extends DataValidator { + + @Override + protected void validateDataImpl(TenantId tenantId, MobileAppSettings mobileAppSettings) { + AndroidConfig androidConfig = mobileAppSettings.getAndroidConfig(); + IosConfig iosConfig = mobileAppSettings.getIosConfig(); + QRCodeConfig qrCodeConfig = mobileAppSettings.getQrCodeConfig(); + if (!mobileAppSettings.isUseDefaultApp() && (androidConfig == null || iosConfig == null)) { + throw new DataValidationException("Android/ios settings are required to use custom application!"); + } + if (qrCodeConfig == null) { + throw new DataValidationException("Qr code config is required!"); + } + if (androidConfig != null && androidConfig.isEnabled() && + (androidConfig.getAppPackage() == null || androidConfig.getSha256CertFingerprints() == null)) { + throw new DataValidationException("Application package and sha256 cert fingerprints are required for enabled android settings!"); + } + if (iosConfig != null && iosConfig.isEnabled() && iosConfig.getAppId() == null) { + throw new DataValidationException("Application id is required for enabled ios settings!"); + } + } +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/mobile/JpaMobileAppDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/mobile/JpaMobileAppSettingsDao.java similarity index 73% rename from dao/src/main/java/org/thingsboard/server/dao/sql/mobile/JpaMobileAppDao.java rename to dao/src/main/java/org/thingsboard/server/dao/sql/mobile/JpaMobileAppSettingsDao.java index 8c732e0ffc..e4286bb949 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/mobile/JpaMobileAppDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/mobile/JpaMobileAppSettingsDao.java @@ -17,32 +17,31 @@ package org.thingsboard.server.dao.sql.mobile; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.mobile.MobileAppSettings; import org.thingsboard.server.dao.DaoUtil; import org.thingsboard.server.dao.mobile.MobileAppSettingsDao; import org.thingsboard.server.dao.model.sql.MobileAppSettingsEntity; -import org.thingsboard.server.dao.sql.JpaAbstractDaoListeningExecutorService; +import org.thingsboard.server.dao.sql.JpaAbstractDao; import org.thingsboard.server.dao.util.SqlDao; +import java.util.UUID; + @Component @Slf4j @SqlDao -public class JpaMobileAppDao extends JpaAbstractDaoListeningExecutorService implements MobileAppSettingsDao { +public class JpaMobileAppSettingsDao extends JpaAbstractDao implements MobileAppSettingsDao { @Autowired private MobileAppSettingsRepository mobileAppSettingsRepository; - @Override - public MobileAppSettings save(TenantId tenantId, MobileAppSettings mobileAppSettings) { - return DaoUtil.getData(mobileAppSettingsRepository.save(new MobileAppSettingsEntity(mobileAppSettings))); - } @Override public MobileAppSettings findByTenantId(TenantId tenantId) { - return DaoUtil.getData(mobileAppSettingsRepository.findById(tenantId.getId())); + return DaoUtil.getData(mobileAppSettingsRepository.findByTenantId(tenantId.getId())); } @Override @@ -50,4 +49,13 @@ public class JpaMobileAppDao extends JpaAbstractDaoListeningExecutorService impl mobileAppSettingsRepository.deleteByTenantId(tenantId.getId()); } + @Override + protected Class getEntityClass() { + return MobileAppSettingsEntity.class; + } + + @Override + protected JpaRepository getRepository() { + return mobileAppSettingsRepository; + } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/mobile/MobileAppSettingsRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/mobile/MobileAppSettingsRepository.java index a2339e1354..bb6e62f3fe 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/mobile/MobileAppSettingsRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/mobile/MobileAppSettingsRepository.java @@ -27,6 +27,8 @@ import java.util.UUID; public interface MobileAppSettingsRepository extends JpaRepository { + MobileAppSettingsEntity findByTenantId(@Param("tenantId") UUID tenantId); + @Transactional @Modifying @Query("DELETE FROM MobileAppSettingsEntity r WHERE r.tenantId = :tenantId") diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index 72dae5394b..91df2e81bc 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -896,7 +896,9 @@ CREATE TABLE IF NOT EXISTS queue_stats ( ); CREATE TABLE IF NOT EXISTS mobile_app_settings ( - tenant_id UUID NOT NULL, + id uuid NOT NULL CONSTRAINT mobile_app_settings_pkey PRIMARY KEY, + created_time bigint NOT NULL, + tenant_id uuid UNIQUE NOT NULL, use_default_app boolean, android_config VARCHAR(1000), ios_config VARCHAR(1000), From 5a8bded35fdd66b84a8f50b735c93e2b95746025 Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Tue, 30 Apr 2024 18:28:37 +0200 Subject: [PATCH 14/84] JUnit5 migration in msa. Signed-off-by: Oleksandra Matviienko --- msa/black-box-tests/pom.xml | 5 ----- msa/vc-executor/pom.xml | 5 ----- 2 files changed, 10 deletions(-) diff --git a/msa/black-box-tests/pom.xml b/msa/black-box-tests/pom.xml index f1b7525438..f433b1930c 100644 --- a/msa/black-box-tests/pom.xml +++ b/msa/black-box-tests/pom.xml @@ -63,11 +63,6 @@ spring-boot-starter-test test - - org.junit.vintage - junit-vintage-engine - test - org.testng testng diff --git a/msa/vc-executor/pom.xml b/msa/vc-executor/pom.xml index 34340ab08c..081ebd1d3c 100644 --- a/msa/vc-executor/pom.xml +++ b/msa/vc-executor/pom.xml @@ -75,11 +75,6 @@ spring-boot-starter-test test - - org.junit.vintage - junit-vintage-engine - test - org.awaitility awaitility From f9348633886edd5bed543a6bcb8463031375f373 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Thu, 2 May 2024 12:56:47 +0300 Subject: [PATCH 15/84] fixed api test --- .../server/config/ThingsboardSecurityConfiguration.java | 2 +- .../server/controller/MobileApplicationController.java | 2 -- .../server/controller/MobileApplicationControllerTest.java | 6 ++++++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java index eda8d3e89d..c83f6500b2 100644 --- a/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java +++ b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java @@ -77,7 +77,7 @@ public class ThingsboardSecurityConfiguration { 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/**", "/api/images/public/**"}; + protected static final String[] NON_TOKEN_BASED_AUTH_ENTRY_POINTS = new String[] {"/index.html", "/assets/**", "/static/**", "/api/noauth/**", "/webjars/**", "/api/license/**", "/api/images/public/**", "/.well-known/**"}; public static final String TOKEN_BASED_AUTH_ENTRY_POINT = "/api/**"; public static final String WS_ENTRY_POINT = "/api/ws/**"; public static final String MAIL_OAUTH2_PROCESSING_ENTRY_POINT = "/api/admin/mail/oauth2/code"; diff --git a/application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java b/application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java index 96b1bf4d35..ddd49ab975 100644 --- a/application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java +++ b/application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java @@ -125,7 +125,6 @@ public class MobileApplicationController extends BaseController { SecurityUser currentUser = getCurrentUser(); accessControlService.checkPermission(currentUser, Resource.MOBILE_APP_SETTINGS, Operation.WRITE); mobileAppSettings.setTenantId(getTenantId()); - return mobileAppSettingsService.saveMobileAppSettings(currentUser.getTenantId(), mobileAppSettings); } @@ -136,7 +135,6 @@ public class MobileApplicationController extends BaseController { public MobileAppSettings getMobileAppSettings() throws ThingsboardException { SecurityUser currentUser = getCurrentUser(); accessControlService.checkPermission(currentUser, Resource.MOBILE_APP_SETTINGS, Operation.READ); - return mobileAppSettingsService.getMobileAppSettings(TenantId.SYS_TENANT_ID); } diff --git a/application/src/test/java/org/thingsboard/server/controller/MobileApplicationControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/MobileApplicationControllerTest.java index c4f1301457..ad049295ea 100644 --- a/application/src/test/java/org/thingsboard/server/controller/MobileApplicationControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/MobileApplicationControllerTest.java @@ -163,6 +163,12 @@ public class MobileApplicationControllerTest extends AbstractControllerTest { @Test public void testGetApplicationAssociations() throws Exception { + loginSysAdmin(); + MobileAppSettings mobileAppSettings = doGet("/api/mobile/app/settings", MobileAppSettings.class); + mobileAppSettings.setUseDefaultApp(false); + doPost("/api/mobile/app/settings", mobileAppSettings) + .andExpect(status().isOk()); + JsonNode assetLinks = doGet("/.well-known/assetlinks.json", JsonNode.class); assertThat(assetLinks.get(0).get("target").get("package_name").asText()).isEqualTo(ANDROID_PACKAGE_NAME); assertThat(assetLinks.get(0).get("target").get("sha256_cert_fingerprints").get(0).asText()).isEqualTo(ANDROID_APP_SHA256); From 0bf5290a329ed5ac9b43a0b7dc1cd66e2d5e8128 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Thu, 2 May 2024 19:33:39 +0300 Subject: [PATCH 16/84] code formatting --- .../server/config/ThingsboardSecurityConfiguration.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java index 693dd4bfec..a3111cb137 100644 --- a/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java +++ b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java @@ -76,7 +76,7 @@ public class ThingsboardSecurityConfiguration { 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/**", "/api/images/public/**", "/.well-known/**"}; + protected static final String[] NON_TOKEN_BASED_AUTH_ENTRY_POINTS = new String[]{"/index.html", "/assets/**", "/static/**", "/api/noauth/**", "/webjars/**", "/api/license/**", "/api/images/public/**", "/.well-known/**"}; public static final String TOKEN_BASED_AUTH_ENTRY_POINT = "/api/**"; public static final String WS_ENTRY_POINT = "/api/ws/**"; public static final String MAIL_OAUTH2_PROCESSING_ENTRY_POINT = "/api/admin/mail/oauth2/code"; From a057f42e95425b629d86dfd5687dd14932703321 Mon Sep 17 00:00:00 2001 From: rusikv Date: Thu, 2 May 2024 21:37:11 +0300 Subject: [PATCH 17/84] UI: mobile app QR code UI initial implementation --- .../widget_bundles/home_page_widgets.json | 3 +- .../widget_types/mobile_app_qr_code.json | 21 ++ .../src/app/core/http/mobile-app.service.ts | 45 ++++ ui-ngx/src/app/core/services/menu.service.ts | 8 + ui-ngx/src/app/core/services/utils.service.ts | 5 +- .../mobile-app-qrcode-widget.component.html | 40 ++++ .../mobile-app-qrcode-widget.component.scss | 56 +++++ .../lib/mobile-app-qrcode-widget.component.ts | 111 ++++++++++ .../widget/widget-components.module.ts | 3 + .../home/pages/admin/admin-routing.module.ts | 14 ++ .../modules/home/pages/admin/admin.module.ts | 18 +- .../admin/mobile-app-settings.component.html | 161 ++++++++++++++ .../admin/mobile-app-settings.component.scss | 28 +++ .../admin/mobile-app-settings.component.ts | 197 ++++++++++++++++++ .../app/shared/models/mobile-app.models.ts | 81 +++++++ .../android_store_en_black_badge.svg | 22 ++ .../ios_store_en_black_badge.svg | 46 ++++ .../ios_store_en_white_badge.svg | 46 ++++ .../dashboard/tenant_admin_home_page.json | 53 ++++- .../assets/locale/locale.constant-en_US.json | 27 +++ ui-ngx/src/form.scss | 15 ++ 21 files changed, 987 insertions(+), 13 deletions(-) create mode 100644 application/src/main/data/json/system/widget_types/mobile_app_qr_code.json create mode 100644 ui-ngx/src/app/core/http/mobile-app.service.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.ts create mode 100644 ui-ngx/src/app/shared/models/mobile-app.models.ts create mode 100644 ui-ngx/src/assets/android-ios-stores-badges/android_store_en_black_badge.svg create mode 100755 ui-ngx/src/assets/android-ios-stores-badges/ios_store_en_black_badge.svg create mode 100755 ui-ngx/src/assets/android-ios-stores-badges/ios_store_en_white_badge.svg diff --git a/application/src/main/data/json/system/widget_bundles/home_page_widgets.json b/application/src/main/data/json/system/widget_bundles/home_page_widgets.json index a30d6ee76f..5465f750e5 100644 --- a/application/src/main/data/json/system/widget_bundles/home_page_widgets.json +++ b/application/src/main/data/json/system/widget_bundles/home_page_widgets.json @@ -13,6 +13,7 @@ "home_page_widgets.quick_links", "home_page_widgets.documentation_links", "home_page_widgets.dashboards", - "home_page_widgets.usage_info" + "home_page_widgets.usage_info", + "home_page_widgets.mobile_app_qr_code" ] } \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_types/mobile_app_qr_code.json b/application/src/main/data/json/system/widget_types/mobile_app_qr_code.json new file mode 100644 index 0000000000..2e3600d05b --- /dev/null +++ b/application/src/main/data/json/system/widget_types/mobile_app_qr_code.json @@ -0,0 +1,21 @@ +{ + "fqn": "home_page_widgets.mobile_app_qr_code", + "name": "Mobile app QR code", + "deprecated": false, + "image": null, + "description": null, + "descriptor": { + "type": "static", + "sizeX": 6, + "sizeY": 3, + "resources": [], + "templateHtml": "\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n self.ctx.$scope.mobileAppQrcodeWidget.ngOnInit();\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.mobileAppQrcodeWidget.ngOnDestroy();\n}", + "settingsSchema": "", + "dataKeySettingsSchema": "", + "settingsDirective": "", + "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}" + }, + "tags": null +} \ No newline at end of file diff --git a/ui-ngx/src/app/core/http/mobile-app.service.ts b/ui-ngx/src/app/core/http/mobile-app.service.ts new file mode 100644 index 0000000000..75d5f337bc --- /dev/null +++ b/ui-ngx/src/app/core/http/mobile-app.service.ts @@ -0,0 +1,45 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils'; +import { Observable } from 'rxjs'; +import { MobileAppSettings } from '@shared/models/mobile-app.models'; + +@Injectable({ + providedIn: 'root' +}) +export class MobileAppService { + + constructor( + private http: HttpClient + ) { + } + + public getMobileAppSettings(config?: RequestConfig): Observable { + return this.http.get(`/api/mobile/app/settings`, defaultHttpOptionsFromConfig(config)); + } + + public saveMobileAppSettings(mobileAppSettings: MobileAppSettings, config?: RequestConfig): Observable { + return this.http.post(`/api/mobile/app/settings`, mobileAppSettings, defaultHttpOptionsFromConfig(config)); + } + + public getMobileAppDeepLink( config?: RequestConfig): Observable { + return this.http.get(`/api/mobile/deepLink`, defaultHttpOptionsFromConfig(config)); + } + +} diff --git a/ui-ngx/src/app/core/services/menu.service.ts b/ui-ngx/src/app/core/services/menu.service.ts index 1f2d76a8b8..ffb5b5f0a6 100644 --- a/ui-ngx/src/app/core/services/menu.service.ts +++ b/ui-ngx/src/app/core/services/menu.service.ts @@ -245,6 +245,14 @@ export class MenuService { path: '/settings/queues', icon: 'swap_calls' }, + { + id: 'mobile_app_settings', + name: 'admin.mobile-app.mobile-app', + fullName: 'admin.mobile-app.mobile-app', + type: 'link', + path: '/settings/mobile-app', + icon: 'smartphone' + } ] }, { diff --git a/ui-ngx/src/app/core/services/utils.service.ts b/ui-ngx/src/app/core/services/utils.service.ts index 70b04ee4eb..d7238c450d 100644 --- a/ui-ngx/src/app/core/services/utils.service.ts +++ b/ui-ngx/src/app/core/services/utils.service.ts @@ -30,7 +30,7 @@ import { guid, hashCode, isDefined, - isDefinedAndNotNull, + isDefinedAndNotNull, isNotEmptyStr, isString, isUndefined, objToBase64, @@ -386,8 +386,7 @@ export class UtilsService { this.window.performance.now() : Date.now(); } - public getQueryParam(name: string): string { - const url = this.window.location.href; + public getQueryParam(name: string, url = this.window.location.href): string { name = name.replace(/[\[\]]/g, '\\$&'); const regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)'); const results = regex.exec(url); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.html new file mode 100644 index 0000000000..53f95cb57e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.html @@ -0,0 +1,40 @@ + +
admin.mobile-app.connect-mobile-app
+
+
+ +
+ + + + + + + + +
+
+
+ {{ mobileAppSettings.qrCodeConfig.qrCodeLabel }} +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.scss new file mode 100644 index 0000000000..1d088ec482 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.scss @@ -0,0 +1,56 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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%; + display: flex; + flex-direction: column; + background-color: transparent; +} + +.tb-title { + align-self: start; + font-weight: 600; + font-size: 20px; + line-height: 24px; + letter-spacing: 0.1px; + color: rgba(0, 0, 0, 0.76); + @media #{$mat-md-lg} { + font-weight: 500; + font-size: 14px; + line-height: 20px; + letter-spacing: 0.25px; + } +} + +.tb-qrcode-label { + font-size: 14px; + color: rgba(0, 0, 0, 0.54); +} + +.tb-qrcode { + box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.08); + border-radius: 6px; +} + +.tb-badge-container { + @media #{$mat-lt-lg} { + display: none; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.ts new file mode 100644 index 0000000000..c9c8541613 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.ts @@ -0,0 +1,111 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES 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, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { AppState } from '@core/core.state'; +import { Store } from '@ngrx/store'; +import { BadgePosition, BadgeStyle, badgeStyleURLMap, MobileAppSettings } from '@shared/models/mobile-app.models'; +import { MobileAppService } from '@core/http/mobile-app.service'; +import { WidgetContext } from '@home/models/widget-component.models'; +import { UtilsService } from '@core/services/utils.service'; +import { interval, mergeMap, Observable, Subject, takeUntil } from 'rxjs'; +import { MINUTE } from '@shared/models/time/time.models'; +import { coerceBoolean } from '@shared/decorators/coercion'; + +@Component({ + selector: 'tb-mobile-app-qrcode-widget', + templateUrl: './mobile-app-qrcode-widget.component.html', + styleUrls: ['./mobile-app-qrcode-widget.component.scss'] +}) +export class MobileAppQrcodeWidgetComponent extends PageComponent implements OnInit, AfterViewInit, OnDestroy { + + @Input() + ctx: WidgetContext; + + @Input() + @coerceBoolean() + previewMode: boolean; + + @Input() + set mobileAppSettings(settings: MobileAppSettings) { + if (settings) { + this.mobileAppSettingsValue = settings; + } + }; + + get mobileAppSettings() { + return this.mobileAppSettingsValue; + } + + @ViewChild('canvas', {static: false}) canvasRef: ElementRef; + + private readonly destroy$ = new Subject(); + + badgeStyle = BadgeStyle; + badgePosition = BadgePosition; + badgeStyleURLMap = badgeStyleURLMap; + + private mobileAppSettingsValue: MobileAppSettings; + private deepLinkTTL: number; + + constructor(protected store: Store, + protected cd: ChangeDetectorRef, + private mobileAppService: MobileAppService, + private utilsService: UtilsService) { + super(store); + } + + ngOnInit(): void { + if (this.ctx) { + this.ctx.$scope.mobileAppQrcodeWidget = this; + } + if (!this.mobileAppSettings) { + this.mobileAppService.getMobileAppSettings().subscribe((settings => { + this.mobileAppSettings = settings; + this.cd.detectChanges(); + })); + } + } + + ngAfterViewInit(): void { + this.getMobileAppDeepLink().subscribe(link => { + this.deepLinkTTL = Number(this.utilsService.getQueryParam('ttl', link)) * MINUTE; + this.updateQRCode(link); + interval(this.deepLinkTTL).pipe( + takeUntil(this.destroy$), + mergeMap(() => this.getMobileAppDeepLink()) + ).subscribe(link => this.updateQRCode(link)); + }); + } + + ngOnDestroy() { + super.ngOnDestroy(); + this.destroy$.next(); + this.destroy$.complete(); + } + + getMobileAppDeepLink(): Observable { + return this.mobileAppService.getMobileAppDeepLink(); + } + + updateQRCode(link: string) { + import('qrcode').then((QRCode) => { + QRCode.toCanvas(this.canvasRef.nativeElement, link, { width: 125}); + }); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts index 69185b6dd2..e25f0d3925 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts @@ -92,6 +92,7 @@ import { PieChartWidgetComponent } from '@home/components/widget/lib/chart/pie-c import { BarChartWidgetComponent } from '@home/components/widget/lib/chart/bar-chart-widget.component'; import { PolarAreaWidgetComponent } from '@home/components/widget/lib/chart/polar-area-widget.component'; import { RadarChartWidgetComponent } from '@home/components/widget/lib/chart/radar-chart-widget.component'; +import { MobileAppQrcodeWidgetComponent } from '@home/components/widget/lib/mobile-app-qrcode-widget.component'; @NgModule({ declarations: @@ -112,6 +113,7 @@ import { RadarChartWidgetComponent } from '@home/components/widget/lib/chart/rad NavigationCardsWidgetComponent, NavigationCardWidgetComponent, QrCodeWidgetComponent, + MobileAppQrcodeWidgetComponent, MarkdownWidgetComponent, SelectEntityDialogComponent, LegendComponent, @@ -176,6 +178,7 @@ import { RadarChartWidgetComponent } from '@home/components/widget/lib/chart/rad NavigationCardsWidgetComponent, NavigationCardWidgetComponent, QrCodeWidgetComponent, + MobileAppQrcodeWidgetComponent, MarkdownWidgetComponent, LegendComponent, FlotWidgetComponent, diff --git a/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts b/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts index 63fb9a14de..f05fc05a25 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts @@ -40,6 +40,7 @@ import { widgetsLibraryRoutes } from '@home/pages/widget/widget-library-routing. import { RouterTabsComponent } from '@home/components/router-tabs.component'; import { auditLogsRoutes } from '@home/pages/audit-log/audit-log-routing.module'; import { ImageGalleryComponent } from '@shared/components/image/image-gallery.component'; +import { MobileAppSettingsComponent } from '@home/pages/admin/mobile-app-settings.component'; @Injectable() export class OAuth2LoginProcessingUrlResolver implements Resolve { @@ -270,6 +271,19 @@ const routes: Routes = [ } } }, + { + path: 'mobile-app', + component: MobileAppSettingsComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + auth: [Authority.SYS_ADMIN], + title: 'admin.mobile-app.mobile-app', + breadcrumb: { + label: 'admin.mobile-app.mobile-app', + icon: 'smartphone' + } + } + }, { path: 'security-settings', redirectTo: '/security-settings/general' diff --git a/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts b/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts index 1ab60687a3..f44782e814 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts @@ -33,6 +33,8 @@ import { QueueComponent } from '@home/pages/admin/queue/queue.component'; import { RepositoryAdminSettingsComponent } from '@home/pages/admin/repository-admin-settings.component'; import { AutoCommitAdminSettingsComponent } from '@home/pages/admin/auto-commit-admin-settings.component'; import { TwoFactorAuthSettingsComponent } from '@home/pages/admin/two-factor-auth-settings.component'; +import { MobileAppSettingsComponent } from '@home/pages/admin/mobile-app-settings.component'; +import { WidgetComponentsModule } from '@home/components/widget/widget-components.module'; @NgModule({ declarations: @@ -49,13 +51,15 @@ import { TwoFactorAuthSettingsComponent } from '@home/pages/admin/two-factor-aut QueueComponent, RepositoryAdminSettingsComponent, AutoCommitAdminSettingsComponent, - TwoFactorAuthSettingsComponent + TwoFactorAuthSettingsComponent, + MobileAppSettingsComponent ], - imports: [ - CommonModule, - SharedModule, - HomeComponentsModule, - AdminRoutingModule - ] + imports: [ + CommonModule, + SharedModule, + HomeComponentsModule, + AdminRoutingModule, + WidgetComponentsModule + ] }) export class AdminModule { } diff --git a/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.html b/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.html new file mode 100644 index 0000000000..5b1cb710e0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.html @@ -0,0 +1,161 @@ + + + + + admin.mobile-app.mobile-app-qr-code-widget-settings + + + + +
+ +
+
+
+
admin.mobile-app.applications
+ + {{ 'admin.mobile-app.default' | translate }} + {{ 'admin.mobile-app.custom' | translate }} + +
+
+
+ + {{ 'admin.mobile-app.android' | translate }} + +
+
+
+
{{ 'admin.mobile-app.app-package-name' | translate }}
+ + + + warning + + +
+
+
+
+
{{ 'admin.mobile-app.sha256-certificate-fingerprints' | translate }}
+ + + + warning + + +
+
+
+
+
+ + {{ 'admin.mobile-app.ios' | translate }} + +
+
+
+
{{ 'admin.mobile-app.app-id' | translate }}
+ + + + warning + + +
+
+
+
+
+
+
admin.mobile-app.appearance-on-home-page
+ + {{ 'admin.mobile-app.enabled' | translate }} + {{ 'admin.mobile-app.disabled' | translate }} + +
+
+
+ + {{ 'admin.mobile-app.badges' | translate }} + + + + + {{ badgeStyle.value | translate }} + + + + + + + {{ badgePosition.value | translate }} + + + +
+
+
+
+ + {{ 'admin.mobile-app.label' | translate }} + + + + +
+
+
+
admin.mobile-app.preview
+ + +
+
+
+
+ +
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.scss b/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.scss new file mode 100644 index 0000000000..5f04c04e8f --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.scss @@ -0,0 +1,28 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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-fixed-width { + min-width: 230px; +} + +.tb-qrcode-preview { + background-color: rgba(0, 0, 0, 0.04); + box-shadow: none; + + .tb-form-panel-title { + color: rgba(0, 0, 0, 0.38); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.ts b/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.ts new file mode 100644 index 0000000000..c342b68d1f --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.ts @@ -0,0 +1,197 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES 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 } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { PageComponent } from '@shared/components/page.component'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { HasConfirmForm } from '@core/guards/confirm-on-exit.guard'; +import { Subject, takeUntil } from 'rxjs'; +import { MobileAppService } from '@core/http/mobile-app.service'; +import { + BadgePosition, + badgePositionTranslationsMap, + BadgeStyle, + badgeStyleTranslationsMap, + MobileAppSettings +} from '@shared/models/mobile-app.models'; + +@Component({ + selector: 'tb-mobile-app-settings', + templateUrl: './mobile-app-settings.component.html', + styleUrls: ['mobile-app-settings.component.scss', './settings-card.scss'] +}) +export class MobileAppSettingsComponent extends PageComponent implements HasConfirmForm, OnDestroy { + + mobileAppSettingsForm: FormGroup; + + mobileAppSettings: MobileAppSettings; + + private readonly destroy$ = new Subject(); + + badgePositionTranslationsMap = badgePositionTranslationsMap; + badgeStyleTranslationsMap = badgeStyleTranslationsMap; + + constructor(protected store: Store, + private mobileAppService: MobileAppService, + public fb: FormBuilder) { + super(store); + this.buildMobileAppSettingsForm(); + this.mobileAppService.getMobileAppSettings() + .subscribe(settings => this.processMobileAppSettings(settings)); + this.mobileAppSettingsForm.get('useDefaultApp').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(value => { + if (value) { + this.mobileAppSettingsForm.get('androidConfig.appPackage').clearValidators(); + this.mobileAppSettingsForm.get('androidConfig.sha256CertFingerprints').clearValidators(); + this.mobileAppSettingsForm.get('iosConfig.appId').clearValidators(); + } else { + this.mobileAppSettingsForm.get('androidConfig.appPackage').setValidators([Validators.required]); + this.mobileAppSettingsForm.get('androidConfig.sha256CertFingerprints').setValidators([Validators.required]); + this.mobileAppSettingsForm.get('iosConfig.appId').setValidators([Validators.required]); + } + this.mobileAppSettingsForm.get('androidConfig.appPackage').updateValueAndValidity(); + this.mobileAppSettingsForm.get('androidConfig.sha256CertFingerprints').updateValueAndValidity(); + this.mobileAppSettingsForm.get('iosConfig.appId').updateValueAndValidity(); + }); + this.mobileAppSettingsForm.get('androidConfig.enabled').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(value => { + if (!this.mobileAppSettingsForm.get('useDefaultApp').value) { + if (value) { + this.mobileAppSettingsForm.get('androidConfig.appPackage').setValidators([Validators.required]); + this.mobileAppSettingsForm.get('androidConfig.sha256CertFingerprints').setValidators([Validators.required]); + } else { + this.mobileAppSettingsForm.get('androidConfig.appPackage').clearValidators(); + this.mobileAppSettingsForm.get('androidConfig.sha256CertFingerprints').clearValidators(); + } + this.mobileAppSettingsForm.get('androidConfig.appPackage').updateValueAndValidity(); + this.mobileAppSettingsForm.get('androidConfig.sha256CertFingerprints').updateValueAndValidity(); + } + if (this.mobileAppSettingsForm.get('qrCodeConfig.showOnHomePage').value) { + if (value) { + this.mobileAppSettingsForm.get('qrCodeConfig.badgeEnabled').enable(); + if (this.mobileAppSettingsForm.get('qrCodeConfig.badgeEnabled').value) { + this.mobileAppSettingsForm.get('qrCodeConfig.badgeStyle').enable(); + this.mobileAppSettingsForm.get('qrCodeConfig.badgePosition').enable(); + } + } else { + if (!this.mobileAppSettingsForm.get('iosConfig.enabled').value) { + this.mobileAppSettingsForm.get('qrCodeConfig.badgeEnabled').disable(); + this.mobileAppSettingsForm.get('qrCodeConfig.badgeStyle').disable(); + this.mobileAppSettingsForm.get('qrCodeConfig.badgePosition').disable(); + } + } + } + }); + this.mobileAppSettingsForm.get('iosConfig.enabled').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(value => { + if (!this.mobileAppSettingsForm.get('useDefaultApp').value) { + if (value) { + this.mobileAppSettingsForm.get('iosConfig.appId').setValidators([Validators.required]); + } else { + this.mobileAppSettingsForm.get('iosConfig.appId').clearValidators(); + } + this.mobileAppSettingsForm.get('iosConfig.appId').updateValueAndValidity(); + } + if (this.mobileAppSettingsForm.get('qrCodeConfig.showOnHomePage').value) { + if (value) { + this.mobileAppSettingsForm.get('qrCodeConfig.badgeEnabled').enable(); + if (this.mobileAppSettingsForm.get('qrCodeConfig.badgeEnabled').value) { + this.mobileAppSettingsForm.get('qrCodeConfig.badgeStyle').enable(); + this.mobileAppSettingsForm.get('qrCodeConfig.badgePosition').enable(); + } + } else { + if (!this.mobileAppSettingsForm.get('androidConfig.enabled').value) { + this.mobileAppSettingsForm.get('qrCodeConfig.badgeEnabled').disable(); + this.mobileAppSettingsForm.get('qrCodeConfig.badgeStyle').disable(); + this.mobileAppSettingsForm.get('qrCodeConfig.badgePosition').disable(); + } + } + } + }); + this.mobileAppSettingsForm.get('qrCodeConfig.badgeEnabled').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(value => { + if (value) { + if (this.mobileAppSettingsForm.get('androidConfig.enabled').value || this.mobileAppSettingsForm.get('iosConfig.enabled').value) { + this.mobileAppSettingsForm.get('qrCodeConfig.badgeStyle').enable(); + this.mobileAppSettingsForm.get('qrCodeConfig.badgePosition').enable(); + } + } else { + this.mobileAppSettingsForm.get('qrCodeConfig.badgeStyle').disable(); + this.mobileAppSettingsForm.get('qrCodeConfig.badgePosition').disable(); + } + }); + this.mobileAppSettingsForm.get('qrCodeConfig.qrCodeLabelEnabled').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(value => { + if (value) { + this.mobileAppSettingsForm.get('qrCodeConfig.qrCodeLabel').enable(); + } else { + this.mobileAppSettingsForm.get('qrCodeConfig.qrCodeLabel').disable(); + } + }); + } + + ngOnDestroy() { + super.ngOnDestroy(); + this.destroy$.next(); + this.destroy$.complete(); + } + + private buildMobileAppSettingsForm() { + this.mobileAppSettingsForm = this.fb.group({ + useDefaultApp: [true, []], + androidConfig: this.fb.group({ + enabled: [true, []], + appPackage: ['', []], + sha256CertFingerprints: ['', []] + }), + iosConfig: this.fb.group({ + enabled: [true, []], + appId: ['', []] + }), + qrCodeConfig: this.fb.group({ + showOnHomePage: [true, []], + badgeEnabled: [true, []], + badgeStyle: [{value: BadgeStyle.ORIGINAL, disabled: true}, []], + badgePosition: [{value: BadgePosition.RIGHT, disabled: true}, []], + qrCodeLabelEnabled: [true, []], + qrCodeLabel: ['', []] + }) + }); + } + + private processMobileAppSettings(mobileAppSettings: MobileAppSettings): void { + this.mobileAppSettings = {...mobileAppSettings}; + this.mobileAppSettingsForm.reset(this.mobileAppSettings); + } + + save(): void { + this.mobileAppSettings = {...this.mobileAppSettings, ...this.mobileAppSettingsForm.getRawValue()}; + this.mobileAppService.saveMobileAppSettings(this.mobileAppSettings) + .subscribe((settings) => this.processMobileAppSettings(settings)); + } + + confirmForm(): FormGroup { + return this.mobileAppSettingsForm; + } + +} diff --git a/ui-ngx/src/app/shared/models/mobile-app.models.ts b/ui-ngx/src/app/shared/models/mobile-app.models.ts new file mode 100644 index 0000000000..0787f17747 --- /dev/null +++ b/ui-ngx/src/app/shared/models/mobile-app.models.ts @@ -0,0 +1,81 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES 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'; + +export interface MobileAppSettings { + tenantId: TenantId; + useDefaultApp: boolean; + androidConfig: AndroidConfig; + iosConfig: IosConfig; + qrCodeConfig: QRCodeConfig; +} + +export interface AndroidConfig { + enabled: boolean; + appPackage: string; + sha256CertFingerprints: string +} + +export interface IosConfig { + enabled: boolean; + appId: string; +} + +export interface QRCodeConfig { + showOnHomePage: boolean; + badgeEnabled: boolean; + badgePosition: BadgePosition; + badgeStyle: BadgeStyle; + qrCodeLabelEnabled: boolean; + qrCodeLabel: string; +} + +export interface MobileOSBadgeURL { + iOS: string; + android: string; +} + +export enum BadgePosition { + RIGHT = 'RIGHT', + LEFT = 'LEFT' +} + +export const badgePositionTranslationsMap = new Map([ + [BadgePosition.RIGHT, 'admin.mobile-app.right'], + [BadgePosition.LEFT, 'admin.mobile-app.left'] +]); + +export enum BadgeStyle { + ORIGINAL = 'ORIGINAL', + WHITE = 'WHITE' +} + +export const badgeStyleTranslationsMap = new Map([ + [BadgeStyle.ORIGINAL, 'admin.mobile-app.original'], + [BadgeStyle.WHITE, 'admin.mobile-app.white'] +]); + +export const badgeStyleURLMap = new Map([ + [BadgeStyle.ORIGINAL, { + iOS: 'assets/android-ios-stores-badges/ios_store_en_black_badge.svg', + android: 'assets/android-ios-stores-badges/android_store_en_black_badge.svg' + }], + [BadgeStyle.WHITE, { + iOS: 'assets/android-ios-stores-badges/ios_store_en_white_badge.svg', + android: 'assets/android-ios-stores-badges/android_store_en_black_badge.svg' + }] +]); diff --git a/ui-ngx/src/assets/android-ios-stores-badges/android_store_en_black_badge.svg b/ui-ngx/src/assets/android-ios-stores-badges/android_store_en_black_badge.svg new file mode 100644 index 0000000000..02b88dab6f --- /dev/null +++ b/ui-ngx/src/assets/android-ios-stores-badges/android_store_en_black_badge.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/android-ios-stores-badges/ios_store_en_black_badge.svg b/ui-ngx/src/assets/android-ios-stores-badges/ios_store_en_black_badge.svg new file mode 100755 index 0000000000..072b425a1a --- /dev/null +++ b/ui-ngx/src/assets/android-ios-stores-badges/ios_store_en_black_badge.svg @@ -0,0 +1,46 @@ + + Download_on_the_App_Store_Badge_US-UK_RGB_blk_4SVG_092917 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/android-ios-stores-badges/ios_store_en_white_badge.svg b/ui-ngx/src/assets/android-ios-stores-badges/ios_store_en_white_badge.svg new file mode 100755 index 0000000000..16c0496ca7 --- /dev/null +++ b/ui-ngx/src/assets/android-ios-stores-badges/ios_store_en_white_badge.svg @@ -0,0 +1,46 @@ + + Download_on_the_App_Store_Badge_US-UK_RGB_wht_092917 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json b/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json index 85669b8bf4..d850e640b7 100644 --- a/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json +++ b/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json @@ -1143,6 +1143,49 @@ "row": 0, "col": 0, "id": "8e71a398-caf5-540d-cec5-6e5dc264343e" + }, + "b9d704a2-f579-dbff-f123-f953d92081fd": { + "typeFullFqn": "system.home_page_widgets.mobile_app_qr_code", + "type": "static", + "sizeX": 6, + "sizeY": 3, + "config": { + "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": false + }, + "row": 0, + "col": 0, + "id": "b9d704a2-f579-dbff-f123-f953d92081fd" } }, "states": { @@ -1169,7 +1212,7 @@ }, "a23185ad-dc46-806c-0e50-5b21fb080ace": { "sizeX": 35, - "sizeY": 58, + "sizeY": 42, "row": 0, "col": 85, "mobileHide": true @@ -1212,6 +1255,12 @@ "row": 0, "col": 43, "mobileOrder": 0 + }, + "b9d704a2-f579-dbff-f123-f953d92081fd": { + "sizeX": 35, + "sizeY": 16, + "row": 42, + "col": 85 } }, "gridSettings": { @@ -1416,4 +1465,4 @@ } }, "name": "Tenant Administrator Home Page" -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index b13d169cdc..1a50f9f62b 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -419,6 +419,33 @@ "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.", + "mobile-app": { + "mobile-app": "Mobile app", + "mobile-app-qr-code-widget-settings": "Mobile app QR code widget settings", + "applications": "Applications", + "default": "Default", + "custom": "Custom", + "android": "Android", + "app-package-name": "App package name", + "app-package-name-required": "App package name is required", + "sha256-certificate-fingerprints": "SHA256 certificate fingerprints", + "sha256-certificate-fingerprints-required": "SHA256 certificate fingerprints is required", + "ios": "iOS", + "app-id": "App ID", + "app-id-required": "App ID is required", + "appearance-on-home-page": "Appearance on Home page", + "enabled": "Enabled", + "disabled": "Disabled", + "badges": "Badges", + "original": "Original", + "white": "White", + "label": "Label", + "right": "Right", + "left": "Left", + "set": "Set", + "preview": "Preview", + "connect-mobile-app": "Connect mobile app" + }, "2fa": { "2fa": "Two-factor authentication", "available-providers": "Available providers", diff --git a/ui-ngx/src/form.scss b/ui-ngx/src/form.scss index 1f9ca93c88..a692994144 100644 --- a/ui-ngx/src/form.scss +++ b/ui-ngx/src/form.scss @@ -257,9 +257,15 @@ &.row { flex-direction: row; } + &.row-reverse { + flex-direction: row-reverse; + } &.column { flex-direction: column; } + &.center { + justify-content: center; + } &.flex-start { justify-content: flex-start; } @@ -278,6 +284,15 @@ &.fill-width { width: 100%; } + &.fill-height { + height: 100%; + } + &.shrink { + flex: 0; + } + &.no-gap { + gap: 0; + } } .tb-form-panel, .tb-form-row { From 316305aea8f9e3abcff13b6cc1a40dca06322985 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Fri, 3 May 2024 13:06:46 +0200 Subject: [PATCH 18/84] msa tests: JUnit4 org.junit.Assert replaced with assertJ --- .../org/thingsboard/server/msa/AbstractLwm2mClientTest.java | 3 +-- .../server/msa/connectivity/lwm2m/LwM2MTestClient.java | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/AbstractLwm2mClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/AbstractLwm2mClientTest.java index d0a8b53c32..c0ae8e9776 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/AbstractLwm2mClientTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/AbstractLwm2mClientTest.java @@ -19,7 +19,6 @@ import lombok.extern.slf4j.Slf4j; import org.apache.commons.io.IOUtils; import org.eclipse.leshan.client.object.Security; import org.eclipse.leshan.core.util.Hex; -import org.junit.Assert; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.server.common.data.Device; @@ -141,7 +140,7 @@ public class AbstractLwm2mClientTest extends AbstractContainerTest{ log.warn("msa basicTestConnection update -> finishState: [{}] states: {}", finishState, lwM2MTestClient.getClientStates()); return lwM2MTestClient.getClientStates().contains(finishState) || lwM2MTestClient.getClientStates().contains(ON_UPDATE_SUCCESS); }); - Assert.assertTrue(lwM2MTestClient.getClientStates().containsAll(expectedStatusesRegistrationLwm2mSuccess)); + assertThat(lwM2MTestClient.getClientStates()).containsAll(expectedStatusesRegistrationLwm2mSuccess); } public LwM2MTestClient createNewClient(Security security, diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/lwm2m/LwM2MTestClient.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/lwm2m/LwM2MTestClient.java index 9b1ab4bd22..c9f193704b 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/lwm2m/LwM2MTestClient.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/lwm2m/LwM2MTestClient.java @@ -50,7 +50,6 @@ import org.eclipse.leshan.core.request.BootstrapRequest; import org.eclipse.leshan.core.request.DeregisterRequest; import org.eclipse.leshan.core.request.RegisterRequest; import org.eclipse.leshan.core.request.UpdateRequest; -import org.junit.Assert; import org.thingsboard.server.msa.connectivity.lwm2m.Lwm2mTestHelper.LwM2MClientState; import java.io.IOException; @@ -63,6 +62,7 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.TimeUnit; +import static org.assertj.core.api.Assertions.assertThat; import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_CONNECTION_ID_LENGTH; import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_RECOMMENDED_CIPHER_SUITES_ONLY; import static org.eclipse.leshan.core.LwM2mId.ACCESS_CONTROL; @@ -107,7 +107,7 @@ public class LwM2MTestClient { private Map clientDtlsCid; public void init(Security security, int clientPort) throws InvalidDDFFileException, IOException { - Assert.assertNull("client already initialized", leshanClient); + assertThat(leshanClient).as("client already initialized").isNull(); List models = new ArrayList<>(); for (String resourceName : resources) { From eae2d94e8b7341006608ddab94b32f3cafc1ab19 Mon Sep 17 00:00:00 2001 From: AlexDoanTB <47384595+AlexDoanTB@users.noreply.github.com> Date: Fri, 3 May 2024 16:50:41 +0300 Subject: [PATCH 19/84] Update ---bug-report.md Added disclaimer --- .github/ISSUE_TEMPLATE/---bug-report.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/---bug-report.md b/.github/ISSUE_TEMPLATE/---bug-report.md index f5d093f424..34557d5979 100644 --- a/.github/ISSUE_TEMPLATE/---bug-report.md +++ b/.github/ISSUE_TEMPLATE/---bug-report.md @@ -12,8 +12,8 @@ A clear and concise description of what the bug is. **Your Server Environment** -* [https://demo.thingsboard.io](demo.thingsboard.io) -* [https://thingsboard.cloud](thingsboard.cloud) +* Live Demo +* ThingsBoard Cloud * own setup * Deployment: monolith or microservices * Deployment type: deb, rpm, exe, docker-compose, k8s, ami @@ -60,3 +60,8 @@ If applicable, please add screenshots to help explain your problem. **Additional context** Please feel free to add any other context about the problem here. + +________________________________________________________________ +**Disclaimer** + +We appreciate your contribution whether it is a bug report, feature request, or pull request with improvement (hopefully). Please comply with the Community ethics policy, and do not expect us to answer your requests immediately. Also, do not treat *GitHub issues* as a support channel. From e4ffd3e39fcac0ebd519503974f296313b4eeb2b Mon Sep 17 00:00:00 2001 From: AlexDoanTB <47384595+AlexDoanTB@users.noreply.github.com> Date: Fri, 3 May 2024 16:54:21 +0300 Subject: [PATCH 20/84] Update feature_request.md Updated ISSUE_TEMPLATE. Added **Disclaimer** field to a Feature Request template. --- .github/ISSUE_TEMPLATE/feature_request.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 1168adaf15..84da3f39f7 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -18,3 +18,8 @@ A clear and concise description of any alternative solutions or features you've **Additional context** Add any other context or screenshots about the feature request here. + +_____________________________________________________ +**Disclaimer** + +We appreciate your contribution whether it is a bug report, feature request, or pull request with improvement (hopefully). Please comply with the Community ethics policy, and do not expect us to answer your requests immediately. Also, do not treat *GitHub issues* as a support channel. From cbefb1742f2837b57f4af2a16bfeead9748d4aac Mon Sep 17 00:00:00 2001 From: AlexDoanTB <47384595+AlexDoanTB@users.noreply.github.com> Date: Fri, 3 May 2024 16:55:52 +0300 Subject: [PATCH 21/84] Update question.md Updated ISSUE_TEMPLATE. Added **Disclaimer** field to a Question template. --- .github/ISSUE_TEMPLATE/question.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md index dea6318ac5..db847cef34 100644 --- a/.github/ISSUE_TEMPLATE/question.md +++ b/.github/ISSUE_TEMPLATE/question.md @@ -23,3 +23,8 @@ Clear and concise details. * OS: name and version * ThingsBoard: version * Browser: name and version + +___________________________________________________________ +**Disclaimer** + +We appreciate your contribution whether it is a bug report, feature request, or pull request with improvement (hopefully). Please comply with the Community ethics policy, and do not expect us to answer your requests immediately. Also, do not treat *GitHub issues* as a support channel. From 742af6ebf853833244e47356a36f908dda0cd67a Mon Sep 17 00:00:00 2001 From: AlexDoanTB <47384595+AlexDoanTB@users.noreply.github.com> Date: Fri, 3 May 2024 17:37:04 +0300 Subject: [PATCH 22/84] Update question.md Fixed link --- .github/ISSUE_TEMPLATE/question.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md index db847cef34..a7c2b94488 100644 --- a/.github/ISSUE_TEMPLATE/question.md +++ b/.github/ISSUE_TEMPLATE/question.md @@ -27,4 +27,4 @@ Clear and concise details. ___________________________________________________________ **Disclaimer** -We appreciate your contribution whether it is a bug report, feature request, or pull request with improvement (hopefully). Please comply with the Community ethics policy, and do not expect us to answer your requests immediately. Also, do not treat *GitHub issues* as a support channel. +We appreciate your contribution whether it is a bug report, feature request, or pull request with improvement (hopefully). Please comply with the [Community ethics policy](https://opensource.guide/building-community/), and do not expect us to answer your requests immediately. Also, do not treat *GitHub issues* as a support channel. From a23b665eedda3ea797859c7bc150b0bbe06da2e3 Mon Sep 17 00:00:00 2001 From: AlexDoanTB <47384595+AlexDoanTB@users.noreply.github.com> Date: Fri, 3 May 2024 17:39:03 +0300 Subject: [PATCH 23/84] Update feature_request.md Fixed link --- .github/ISSUE_TEMPLATE/feature_request.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 84da3f39f7..005141b2e5 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -22,4 +22,4 @@ Add any other context or screenshots about the feature request here. _____________________________________________________ **Disclaimer** -We appreciate your contribution whether it is a bug report, feature request, or pull request with improvement (hopefully). Please comply with the Community ethics policy, and do not expect us to answer your requests immediately. Also, do not treat *GitHub issues* as a support channel. +We appreciate your contribution whether it is a bug report, feature request, or pull request with improvement (hopefully). Please comply with the [Community ethics policy](https://opensource.guide/building-community/), and do not expect us to answer your requests immediately. Also, do not treat *GitHub issues* as a support channel. From bcb3f721f3d54cc3c45926d5f8d3b60bfb5684bd Mon Sep 17 00:00:00 2001 From: AlexDoanTB <47384595+AlexDoanTB@users.noreply.github.com> Date: Fri, 3 May 2024 17:41:12 +0300 Subject: [PATCH 24/84] Update ---bug-report.md Fixed links --- .github/ISSUE_TEMPLATE/---bug-report.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/---bug-report.md b/.github/ISSUE_TEMPLATE/---bug-report.md index 34557d5979..e883305f89 100644 --- a/.github/ISSUE_TEMPLATE/---bug-report.md +++ b/.github/ISSUE_TEMPLATE/---bug-report.md @@ -12,8 +12,8 @@ A clear and concise description of what the bug is. **Your Server Environment** -* Live Demo -* ThingsBoard Cloud +* [Live Demo](https://demo.thingsboard.io) +* [ThingsBoard Cloud](https://thingsboard.cloud) * own setup * Deployment: monolith or microservices * Deployment type: deb, rpm, exe, docker-compose, k8s, ami @@ -64,4 +64,4 @@ Please feel free to add any other context about the problem here. ________________________________________________________________ **Disclaimer** -We appreciate your contribution whether it is a bug report, feature request, or pull request with improvement (hopefully). Please comply with the Community ethics policy, and do not expect us to answer your requests immediately. Also, do not treat *GitHub issues* as a support channel. +We appreciate your contribution whether it is a bug report, feature request, or pull request with improvement (hopefully). Please comply with the [Community ethics policy](https://opensource.guide/building-community/), and do not expect us to answer your requests immediately. Also, do not treat *GitHub issues* as a support channel. From 4ee969fa1ef5a6321e072f66d058909b2cc166ec Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Sun, 5 May 2024 18:12:26 +0300 Subject: [PATCH 25/84] fixed mobile application validator --- .../MobileApplicationControllerTest.java | 22 +++++++++++++++---- .../MobileAppSettingsDataValidator.java | 10 ++++----- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/MobileApplicationControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/MobileApplicationControllerTest.java index ad049295ea..39e2ef6a40 100644 --- a/application/src/test/java/org/thingsboard/server/controller/MobileApplicationControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/MobileApplicationControllerTest.java @@ -110,7 +110,7 @@ public class MobileApplicationControllerTest extends AbstractControllerTest { mobileAppSettings.setIosConfig(IosConfig.builder().enabled(false).build()); doPost("/api/mobile/app/settings", mobileAppSettings) .andExpect(status().isBadRequest()) - .andExpect(statusReason(containsString("Qr code config is required!"))); + .andExpect(statusReason(containsString("Qr code configuration is required!"))); mobileAppSettings.setQrCodeConfig(QRCodeConfig.builder().showOnHomePage(false).build()); doPost("/api/mobile/app/settings", mobileAppSettings) @@ -121,6 +121,7 @@ public class MobileApplicationControllerTest extends AbstractControllerTest { public void testShouldNotSaveMobileAppSettingsWithoutRequiredAndroidConf() throws Exception { loginSysAdmin(); MobileAppSettings mobileAppSettings = doGet("/api/mobile/app/settings", MobileAppSettings.class); + mobileAppSettings.setUseDefaultApp(false); AndroidConfig androidConfig = AndroidConfig.builder() .enabled(true) .appPackage(null) @@ -130,12 +131,12 @@ public class MobileApplicationControllerTest extends AbstractControllerTest { doPost("/api/mobile/app/settings", mobileAppSettings) .andExpect(status().isBadRequest()) - .andExpect(statusReason(containsString("Application package and sha256 cert fingerprints are required for enabled android settings!"))); + .andExpect(statusReason(containsString("Application package and sha256 cert fingerprints are required for custom android application!"))); androidConfig.setAppPackage("test_app_package"); doPost("/api/mobile/app/settings", mobileAppSettings) .andExpect(status().isBadRequest()) - .andExpect(statusReason(containsString("Application package and sha256 cert fingerprints are required for enabled android settings!"))); + .andExpect(statusReason(containsString("Application package and sha256 cert fingerprints are required for custom android application!"))); androidConfig.setSha256CertFingerprints("test_sha_256"); doPost("/api/mobile/app/settings", mobileAppSettings) @@ -146,6 +147,7 @@ public class MobileApplicationControllerTest extends AbstractControllerTest { public void testShouldNotSaveMobileAppSettingsWithoutRequiredIosConf() throws Exception { loginSysAdmin(); MobileAppSettings mobileAppSettings = doGet("/api/mobile/app/settings", MobileAppSettings.class); + mobileAppSettings.setUseDefaultApp(false); IosConfig iosConfig = IosConfig.builder() .enabled(true) .appId(null) @@ -154,13 +156,25 @@ public class MobileApplicationControllerTest extends AbstractControllerTest { doPost("/api/mobile/app/settings", mobileAppSettings) .andExpect(status().isBadRequest()) - .andExpect(statusReason(containsString("Application id is required for enabled ios settings!"))); + .andExpect(statusReason(containsString("Application id is required for custom ios application!"))); iosConfig.setAppId("test_app_id"); doPost("/api/mobile/app/settings", mobileAppSettings) .andExpect(status().isOk()); } + @Test + public void testShouldSaveMobileAppSettingsForDefaultApp() throws Exception { + loginSysAdmin(); + MobileAppSettings mobileAppSettings = doGet("/api/mobile/app/settings", MobileAppSettings.class); + mobileAppSettings.setUseDefaultApp(true); + mobileAppSettings.setIosConfig(null); + mobileAppSettings.setAndroidConfig(null); + + doPost("/api/mobile/app/settings", mobileAppSettings) + .andExpect(status().isOk()); + } + @Test public void testGetApplicationAssociations() throws Exception { loginSysAdmin(); diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/MobileAppSettingsDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/MobileAppSettingsDataValidator.java index 6400cd08af..c867b08cc4 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/MobileAppSettingsDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/MobileAppSettingsDataValidator.java @@ -38,14 +38,14 @@ public class MobileAppSettingsDataValidator extends DataValidator Date: Mon, 6 May 2024 16:37:57 +0300 Subject: [PATCH 26/84] Update feature_request.md Link change to a more accurate Acceptable Use document --- .github/ISSUE_TEMPLATE/feature_request.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 005141b2e5..9430e961d5 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -22,4 +22,4 @@ Add any other context or screenshots about the feature request here. _____________________________________________________ **Disclaimer** -We appreciate your contribution whether it is a bug report, feature request, or pull request with improvement (hopefully). Please comply with the [Community ethics policy](https://opensource.guide/building-community/), and do not expect us to answer your requests immediately. Also, do not treat *GitHub issues* as a support channel. +We appreciate your contribution whether it is a bug report, feature request, or pull request with improvement (hopefully). Please comply with the [Community ethics policy](https://docs.github.com/en/site-policy/acceptable-use-policies/github-acceptable-use-policies)), and do not expect us to answer your requests immediately. Also, do not treat *GitHub issues* as a support channel. From d5d3a022b14c72c92c5add984859587b3852b788 Mon Sep 17 00:00:00 2001 From: AlexDoanTB <47384595+AlexDoanTB@users.noreply.github.com> Date: Mon, 6 May 2024 16:38:39 +0300 Subject: [PATCH 27/84] Update ---bug-report.md Fixed link --- .github/ISSUE_TEMPLATE/---bug-report.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/---bug-report.md b/.github/ISSUE_TEMPLATE/---bug-report.md index e883305f89..a126a28e34 100644 --- a/.github/ISSUE_TEMPLATE/---bug-report.md +++ b/.github/ISSUE_TEMPLATE/---bug-report.md @@ -64,4 +64,4 @@ Please feel free to add any other context about the problem here. ________________________________________________________________ **Disclaimer** -We appreciate your contribution whether it is a bug report, feature request, or pull request with improvement (hopefully). Please comply with the [Community ethics policy](https://opensource.guide/building-community/), and do not expect us to answer your requests immediately. Also, do not treat *GitHub issues* as a support channel. +We appreciate your contribution whether it is a bug report, feature request, or pull request with improvement (hopefully). Please comply with the [Community ethics policy](https://docs.github.com/en/site-policy/acceptable-use-policies/github-acceptable-use-policies), and do not expect us to answer your requests immediately. Also, do not treat *GitHub issues* as a support channel. From 0597a6365be82689a7a52a61580e9d2873ad3b3a Mon Sep 17 00:00:00 2001 From: AlexDoanTB <47384595+AlexDoanTB@users.noreply.github.com> Date: Mon, 6 May 2024 16:39:25 +0300 Subject: [PATCH 28/84] Update question.md Fix link --- .github/ISSUE_TEMPLATE/question.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md index a7c2b94488..7f4becfe82 100644 --- a/.github/ISSUE_TEMPLATE/question.md +++ b/.github/ISSUE_TEMPLATE/question.md @@ -27,4 +27,4 @@ Clear and concise details. ___________________________________________________________ **Disclaimer** -We appreciate your contribution whether it is a bug report, feature request, or pull request with improvement (hopefully). Please comply with the [Community ethics policy](https://opensource.guide/building-community/), and do not expect us to answer your requests immediately. Also, do not treat *GitHub issues* as a support channel. +We appreciate your contribution whether it is a bug report, feature request, or pull request with improvement (hopefully). Please comply with the [Community ethics policy](https://docs.github.com/en/site-policy/acceptable-use-policies/github-acceptable-use-policies), and do not expect us to answer your requests immediately. Also, do not treat *GitHub issues* as a support channel. From 2b377d8605a35a1fe5abb14c6824ac2e031405b5 Mon Sep 17 00:00:00 2001 From: Artem Dzhereleiko Date: Tue, 7 May 2024 10:01:44 +0300 Subject: [PATCH 29/84] UI: Fixed paste hex color with prefix --- .../app/shared/components/color-picker/hex-input.component.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui-ngx/src/app/shared/components/color-picker/hex-input.component.ts b/ui-ngx/src/app/shared/components/color-picker/hex-input.component.ts index fcd91a2397..9d65cf02a6 100644 --- a/ui-ngx/src/app/shared/components/color-picker/hex-input.component.ts +++ b/ui-ngx/src/app/shared/components/color-picker/hex-input.component.ts @@ -59,14 +59,14 @@ export class HexInputComponent { } public onInputChange(event: KeyboardEvent, inputValue: string): void { - const value = inputValue.toLowerCase(); + const value = inputValue.replace('#', '').toLowerCase(); if ( ((event.keyCode === 13 || event.key.toLowerCase() === 'enter') && value.length === 3) || value.length === 6 || value.length === 8 ) { const hex = parseInt(value, 16); const hexStr = hex.toString(16); - if (hexStr.padStart(value.length, '0') === value && this.value !== value) { + if (hexStr.padStart(value.length, '0') === value && this.value.toLowerCase() !== value) { const newColor = new Color(`#${value}`); this.colorChange.emit(newColor); } From 953c2150023ccb081782ff5217c7a52c262d58ea Mon Sep 17 00:00:00 2001 From: rusikv Date: Tue, 7 May 2024 11:08:39 +0300 Subject: [PATCH 30/84] UI: added separate mobile qrcode widget to cards bundle --- .../json/system/widget_bundles/cards.json | 1 + .../widget_bundles/home_page_widgets.json | 2 +- .../widget_types/home_mobile_app_qr_code.json | 21 +++ .../widget_types/mobile_app_qr_code.json | 10 +- .../src/app/core/http/mobile-app.service.ts | 10 +- .../cards/mobile-app-qr-code-widge.models.ts | 50 ++++++ .../mobile-app-qrcode-widget.component.html | 6 +- .../mobile-app-qrcode-widget.component.scss | 3 +- .../lib/mobile-app-qrcode-widget.component.ts | 14 +- ...app-qr-code-widget-settings.component.html | 130 +++++++++++++++ ...app-qr-code-widget-settings.component.scss | 19 +++ ...e-app-qr-code-widget-settings.component.ts | 156 ++++++++++++++++++ .../lib/settings/widget-settings.module.ts | 6 + .../admin/mobile-app-settings.component.ts | 6 +- .../app/shared/models/mobile-app.models.ts | 2 +- .../dashboard/tenant_admin_home_page.json | 12 +- ui-ngx/src/form.scss | 3 - 17 files changed, 415 insertions(+), 36 deletions(-) create mode 100644 application/src/main/data/json/system/widget_types/home_mobile_app_qr_code.json create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/cards/mobile-app-qr-code-widge.models.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.ts diff --git a/application/src/main/data/json/system/widget_bundles/cards.json b/application/src/main/data/json/system/widget_bundles/cards.json index 39996839f1..5a839d4c82 100644 --- a/application/src/main/data/json/system/widget_bundles/cards.json +++ b/application/src/main/data/json/system/widget_bundles/cards.json @@ -17,6 +17,7 @@ "cards.label_widget", "cards.dashboard_state_widget", "cards.qr_code", + "cards.mobile_app_qr_code", "cards.attributes_card", "cards.html_card", "cards.html_value_card", diff --git a/application/src/main/data/json/system/widget_bundles/home_page_widgets.json b/application/src/main/data/json/system/widget_bundles/home_page_widgets.json index 5465f750e5..0af30e4df2 100644 --- a/application/src/main/data/json/system/widget_bundles/home_page_widgets.json +++ b/application/src/main/data/json/system/widget_bundles/home_page_widgets.json @@ -14,6 +14,6 @@ "home_page_widgets.documentation_links", "home_page_widgets.dashboards", "home_page_widgets.usage_info", - "home_page_widgets.mobile_app_qr_code" + "home_page_widgets.home_mobile_app_qr_code" ] } \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_types/home_mobile_app_qr_code.json b/application/src/main/data/json/system/widget_types/home_mobile_app_qr_code.json new file mode 100644 index 0000000000..88109fc902 --- /dev/null +++ b/application/src/main/data/json/system/widget_types/home_mobile_app_qr_code.json @@ -0,0 +1,21 @@ +{ + "fqn": "home_page_widgets.home_mobile_app_qr_code", + "name": "Mobile app QR code", + "deprecated": false, + "image": null, + "description": null, + "descriptor": { + "type": "static", + "sizeX": 6, + "sizeY": 3, + "resources": [], + "templateHtml": "\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n}", + "settingsSchema": "", + "dataKeySettingsSchema": "", + "settingsDirective": "", + "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\":\"Mobile app QR code\",\"dropShadow\":true}" + }, + "tags": null +} \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_types/mobile_app_qr_code.json b/application/src/main/data/json/system/widget_types/mobile_app_qr_code.json index 2e3600d05b..82ce9025ed 100644 --- a/application/src/main/data/json/system/widget_types/mobile_app_qr_code.json +++ b/application/src/main/data/json/system/widget_types/mobile_app_qr_code.json @@ -1,21 +1,21 @@ { - "fqn": "home_page_widgets.mobile_app_qr_code", + "fqn": "cards.mobile_app_qr_code", "name": "Mobile app QR code", "deprecated": false, "image": null, "description": null, "descriptor": { "type": "static", - "sizeX": 6, + "sizeX": 7.5, "sizeY": 3, "resources": [], "templateHtml": "\n", "templateCss": "", - "controllerScript": "self.onInit = function() {\n self.ctx.$scope.mobileAppQrcodeWidget.ngOnInit();\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.mobileAppQrcodeWidget.ngOnDestroy();\n}", + "controllerScript": "self.onInit = function() {\n}\n", "settingsSchema": "", "dataKeySettingsSchema": "", - "settingsDirective": "", - "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}" + "settingsDirective": "tb-mobile-app-qr-code-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\":{\"useDefaultApp\":true,\"androidConfig\":{\"enabled\":true,\"appPackage\":\"\",\"sha256CertFingerprints\":\"\"},\"iosConfig\":{\"enabled\":true,\"appId\":\"\"},\"qrCodeConfig\":{\"badgeEnabled\":true,\"badgeStyle\":\"ORIGINAL\",\"badgePosition\":\"RIGHT\",\"qrCodeLabelEnabled\":true,\"qrCodeLabel\":\"Scan to connect or download mobile app\"}},\"title\":\"Mobile app QR code\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"widgetCss\":\"\",\"pageSize\":1024,\"noDataDisplayMessage\":\"\",\"showTitleIcon\":false,\"titleTooltip\":\"\",\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}" }, "tags": null } \ No newline at end of file diff --git a/ui-ngx/src/app/core/http/mobile-app.service.ts b/ui-ngx/src/app/core/http/mobile-app.service.ts index 75d5f337bc..bb4dc6c863 100644 --- a/ui-ngx/src/app/core/http/mobile-app.service.ts +++ b/ui-ngx/src/app/core/http/mobile-app.service.ts @@ -18,7 +18,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils'; import { Observable } from 'rxjs'; -import { MobileAppSettings } from '@shared/models/mobile-app.models'; +import { MobileAppQRCodeSettings } from '@shared/models/mobile-app.models'; @Injectable({ providedIn: 'root' @@ -30,12 +30,12 @@ export class MobileAppService { ) { } - public getMobileAppSettings(config?: RequestConfig): Observable { - return this.http.get(`/api/mobile/app/settings`, defaultHttpOptionsFromConfig(config)); + public getMobileAppSettings(config?: RequestConfig): Observable { + return this.http.get(`/api/mobile/app/settings`, defaultHttpOptionsFromConfig(config)); } - public saveMobileAppSettings(mobileAppSettings: MobileAppSettings, config?: RequestConfig): Observable { - return this.http.post(`/api/mobile/app/settings`, mobileAppSettings, defaultHttpOptionsFromConfig(config)); + public saveMobileAppSettings(mobileAppSettings: MobileAppQRCodeSettings, config?: RequestConfig): Observable { + return this.http.post(`/api/mobile/app/settings`, mobileAppSettings, defaultHttpOptionsFromConfig(config)); } public getMobileAppDeepLink( config?: RequestConfig): Observable { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/mobile-app-qr-code-widge.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/cards/mobile-app-qr-code-widge.models.ts new file mode 100644 index 0000000000..4027e0445b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/mobile-app-qr-code-widge.models.ts @@ -0,0 +1,50 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + AndroidConfig, + BadgePosition, + BadgeStyle, + IosConfig, + QRCodeConfig +} from '@shared/models/mobile-app.models'; + +export type MobileAppQrCodeWidgetSettings = { + useDefaultApp: boolean; + androidConfig: AndroidConfig; + iosConfig: IosConfig; + qrCodeConfig: Omit; +} + +export const mobileAppQrCodeWidgetDefaultSettings: MobileAppQrCodeWidgetSettings = { + useDefaultApp: true, + androidConfig: { + enabled: true, + appPackage: '', + sha256CertFingerprints: '' + }, + iosConfig: { + enabled: true, + appId: '' + }, + qrCodeConfig: { + badgeEnabled: true, + badgeStyle: BadgeStyle.ORIGINAL, + badgePosition: BadgePosition.RIGHT, + qrCodeLabelEnabled: true, + qrCodeLabel: 'Scan to connect or download mobile app' + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.html index 53f95cb57e..e3f9ca11c3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.html @@ -15,15 +15,15 @@ limitations under the License. --> -
admin.mobile-app.connect-mobile-app
+
admin.mobile-app.connect-mobile-app
-
+ [class.tb-badge-container]="!previewMode && !ctx"> diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.scss index 1d088ec482..5dafcc7521 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.scss @@ -17,7 +17,6 @@ @import '../../../../../../scss/constants'; :host { - width: 100%; height: 100%; display: flex; flex-direction: column; @@ -25,12 +24,14 @@ } .tb-title { + padding-bottom: 12px; align-self: start; font-weight: 600; font-size: 20px; line-height: 24px; letter-spacing: 0.1px; color: rgba(0, 0, 0, 0.76); + @media #{$mat-md-lg} { font-weight: 500; font-size: 14px; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.ts index c9c8541613..7296b47f80 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.ts @@ -18,13 +18,14 @@ import { AfterViewInit, ChangeDetectorRef, Component, ElementRef, Input, OnDestr import { PageComponent } from '@shared/components/page.component'; import { AppState } from '@core/core.state'; import { Store } from '@ngrx/store'; -import { BadgePosition, BadgeStyle, badgeStyleURLMap, MobileAppSettings } from '@shared/models/mobile-app.models'; +import { BadgePosition, BadgeStyle, badgeStyleURLMap, MobileAppQRCodeSettings } from '@shared/models/mobile-app.models'; import { MobileAppService } from '@core/http/mobile-app.service'; import { WidgetContext } from '@home/models/widget-component.models'; import { UtilsService } from '@core/services/utils.service'; import { interval, mergeMap, Observable, Subject, takeUntil } from 'rxjs'; import { MINUTE } from '@shared/models/time/time.models'; import { coerceBoolean } from '@shared/decorators/coercion'; +import { MobileAppQrCodeWidgetSettings } from '@home/components/widget/lib/cards/mobile-app-qr-code-widge.models'; @Component({ selector: 'tb-mobile-app-qrcode-widget', @@ -41,7 +42,7 @@ export class MobileAppQrcodeWidgetComponent extends PageComponent implements OnI previewMode: boolean; @Input() - set mobileAppSettings(settings: MobileAppSettings) { + set mobileAppSettings(settings: MobileAppQRCodeSettings | MobileAppQrCodeWidgetSettings) { if (settings) { this.mobileAppSettingsValue = settings; } @@ -59,7 +60,7 @@ export class MobileAppQrcodeWidgetComponent extends PageComponent implements OnI badgePosition = BadgePosition; badgeStyleURLMap = badgeStyleURLMap; - private mobileAppSettingsValue: MobileAppSettings; + private mobileAppSettingsValue: MobileAppQRCodeSettings | MobileAppQrCodeWidgetSettings; private deepLinkTTL: number; constructor(protected store: Store, @@ -71,9 +72,8 @@ export class MobileAppQrcodeWidgetComponent extends PageComponent implements OnI ngOnInit(): void { if (this.ctx) { - this.ctx.$scope.mobileAppQrcodeWidget = this; - } - if (!this.mobileAppSettings) { + this.mobileAppSettings = this.ctx.settings; + } else { this.mobileAppService.getMobileAppSettings().subscribe((settings => { this.mobileAppSettings = settings; this.cd.detectChanges(); @@ -104,7 +104,7 @@ export class MobileAppQrcodeWidgetComponent extends PageComponent implements OnI updateQRCode(link: string) { import('qrcode').then((QRCode) => { - QRCode.toCanvas(this.canvasRef.nativeElement, link, { width: 125}); + QRCode.toCanvas(this.canvasRef.nativeElement, link, { width: 90 }); }); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.html new file mode 100644 index 0000000000..cdef956141 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.html @@ -0,0 +1,130 @@ + +
+
+
+
admin.mobile-app.applications
+ + {{ 'admin.mobile-app.default' | translate }} + {{ 'admin.mobile-app.custom' | translate }} + +
+
+
+ + {{ 'admin.mobile-app.android' | translate }} + +
+
+
+
{{ 'admin.mobile-app.app-package-name' | translate }}
+ + + + warning + + +
+
+
+
+
{{ 'admin.mobile-app.sha256-certificate-fingerprints' | translate }}
+ + + + warning + + +
+
+
+
+
+ + {{ 'admin.mobile-app.ios' | translate }} + +
+
+
+
{{ 'admin.mobile-app.app-id' | translate }}
+ + + + warning + + +
+
+
+
+
+
+
+ + {{ 'admin.mobile-app.badges' | translate }} + + + + + {{ badgeStyle.value | translate }} + + + + + + + {{ badgePosition.value | translate }} + + + +
+
+
+
+ + {{ 'admin.mobile-app.label' | translate }} + + + + +
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.scss new file mode 100644 index 0000000000..d0a2c9ac16 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.scss @@ -0,0 +1,19 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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-fixed-width { + min-width: 230px; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.ts new file mode 100644 index 0000000000..61999d7d41 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.ts @@ -0,0 +1,156 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES 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 { UntypedFormBuilder, UntypedFormGroup, Validators } from "@angular/forms"; +import { WidgetSettings, WidgetSettingsComponent } from "@shared/models/widget.models"; +import { AppState } from '@core/core.state'; +import { Store } from "@ngrx/store"; +import { badgePositionTranslationsMap, badgeStyleTranslationsMap } from '@shared/models/mobile-app.models'; +import { mobileAppQrCodeWidgetDefaultSettings } from '@home/components/widget/lib/cards/mobile-app-qr-code-widge.models'; + +@Component({ + selector: 'tb-mobile-app-qr-code-widget-settings', + templateUrl: './mobile-app-qr-code-widget-settings.component.html', + styleUrls: ['/mobile-app-qr-code-widget-settings.component.scss', './../widget-settings.scss'] +}) +export class MobileAppQrCodeWidgetSettingsComponent extends WidgetSettingsComponent { + + mobileAppQRCodeWidgetSettingsForm: UntypedFormGroup; + + badgePositionTranslationsMap = badgePositionTranslationsMap; + badgeStyleTranslationsMap = badgeStyleTranslationsMap; + + constructor(protected store: Store, + private fb: UntypedFormBuilder) { + super(store); + } + + protected settingsForm(): UntypedFormGroup { + return this.mobileAppQRCodeWidgetSettingsForm; + } + + protected defaultSettings(): WidgetSettings { + return {...mobileAppQrCodeWidgetDefaultSettings}; + } + + protected onSettingsSet(settings: WidgetSettings) { + this.mobileAppQRCodeWidgetSettingsForm = this.fb.group({ + useDefaultApp: [settings.useDefaultApp, []], + androidConfig: this.fb.group({ + enabled: [settings.androidConfig.enabled, []], + appPackage: [settings.androidConfig.appPackage, []], + sha256CertFingerprints: [settings.androidConfig.sha256CertFingerprints, []] + }), + iosConfig: this.fb.group({ + enabled: [settings.iosConfig.enabled, []], + appId: [settings.iosConfig.appId, []] + }), + qrCodeConfig: this.fb.group({ + badgeEnabled: [settings.qrCodeConfig.badgeEnabled, []], + badgeStyle: [{value: settings.qrCodeConfig.badgeStyle, disabled: true}, []], + badgePosition: [{value: settings.qrCodeConfig.badgePosition, disabled: true}, []], + qrCodeLabelEnabled: [settings.qrCodeConfig.qrCodeLabelEnabled, []], + qrCodeLabel: [settings.qrCodeConfig.qrCodeLabel, []] + }) + }); + } + + protected validatorTriggers(): string[] { + return ['useDefaultApp', 'androidConfig.enabled', 'iosConfig.enabled', 'qrCodeConfig.badgeEnabled', 'qrCodeConfig.qrCodeLabelEnabled']; + } + + protected updateValidators(emitEvent: boolean) { + const useDefaultApp = this.mobileAppQRCodeWidgetSettingsForm.get('useDefaultApp').value; + const androidEnabled = this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.enabled').value; + const iosEnabled = this.mobileAppQRCodeWidgetSettingsForm.get('iosConfig.enabled').value; + const badgeEnabled = this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeEnabled').value; + const qrCodeLabelEnabled = this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.qrCodeLabelEnabled').value; + + if (useDefaultApp) { + this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.appPackage').clearValidators(); + this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.sha256CertFingerprints').clearValidators(); + this.mobileAppQRCodeWidgetSettingsForm.get('iosConfig.appId').clearValidators(); + } else { + this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.appPackage').setValidators([Validators.required]); + this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.sha256CertFingerprints').setValidators([Validators.required]); + this.mobileAppQRCodeWidgetSettingsForm.get('iosConfig.appId').setValidators([Validators.required]); + } + + if (androidEnabled) { + if (!useDefaultApp) { + this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.appPackage').setValidators([Validators.required]); + this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.sha256CertFingerprints').setValidators([Validators.required]); + } + this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeEnabled').enable({emitEvent: false}); + if (badgeEnabled) { + this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeStyle').enable(); + this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgePosition').enable(); + } + } else { + if (!useDefaultApp) { + this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.appPackage').clearValidators(); + this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.sha256CertFingerprints').clearValidators(); + } + if (!iosEnabled) { + this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeEnabled').disable({emitEvent: false}); + this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeStyle').disable(); + this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgePosition').disable(); + } + } + + if (iosEnabled) { + if (!useDefaultApp) { + this.mobileAppQRCodeWidgetSettingsForm.get('iosConfig.appId').setValidators([Validators.required]); + } + this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeEnabled').enable({emitEvent: false}); + if (badgeEnabled) { + this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeStyle').enable(); + this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgePosition').enable(); + } + } else { + if (!useDefaultApp) { + this.mobileAppQRCodeWidgetSettingsForm.get('iosConfig.appId').clearValidators(); + } + if (!androidEnabled) { + this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeEnabled').disable({emitEvent: false}); + this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeStyle').disable(); + this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgePosition').disable(); + } + } + + if (badgeEnabled) { + if (androidEnabled || iosEnabled) { + this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeStyle').enable(); + this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgePosition').enable(); + } + } else { + this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeStyle').disable(); + this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgePosition').disable(); + } + + if (qrCodeLabelEnabled) { + this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.qrCodeLabel').enable(); + } else { + this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.qrCodeLabel').disable(); + } + + this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.appPackage').updateValueAndValidity({emitEvent}); + this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.sha256CertFingerprints').updateValueAndValidity({emitEvent}); + this.mobileAppQRCodeWidgetSettingsForm.get('iosConfig.appId').updateValueAndValidity({emitEvent}); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts index c71f0fd658..f093c4c568 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts @@ -354,10 +354,14 @@ import { import { RadarChartWidgetSettingsComponent } from '@home/components/widget/lib/settings/chart/radar-chart-widget-settings.component'; +import { + MobileAppQrCodeWidgetSettingsComponent +} from '@home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component'; @NgModule({ declarations: [ QrCodeWidgetSettingsComponent, + MobileAppQrCodeWidgetSettingsComponent, TimeseriesTableWidgetSettingsComponent, TimeseriesTableKeySettingsComponent, TimeseriesTableLatestKeySettingsComponent, @@ -490,6 +494,7 @@ import { ], exports: [ QrCodeWidgetSettingsComponent, + MobileAppQrCodeWidgetSettingsComponent, TimeseriesTableWidgetSettingsComponent, TimeseriesTableKeySettingsComponent, TimeseriesTableLatestKeySettingsComponent, @@ -620,6 +625,7 @@ export class WidgetSettingsModule { export const widgetSettingsComponentsMap: {[key: string]: Type} = { 'tb-qrcode-widget-settings': QrCodeWidgetSettingsComponent, + 'tb-mobile-app-qr-code-widget-settings': MobileAppQrCodeWidgetSettingsComponent, 'tb-timeseries-table-widget-settings': TimeseriesTableWidgetSettingsComponent, 'tb-timeseries-table-key-settings': TimeseriesTableKeySettingsComponent, 'tb-timeseries-table-latest-key-settings': TimeseriesTableLatestKeySettingsComponent, diff --git a/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.ts b/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.ts index c342b68d1f..aa2aca3cb9 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.ts @@ -27,7 +27,7 @@ import { badgePositionTranslationsMap, BadgeStyle, badgeStyleTranslationsMap, - MobileAppSettings + MobileAppQRCodeSettings } from '@shared/models/mobile-app.models'; @Component({ @@ -39,7 +39,7 @@ export class MobileAppSettingsComponent extends PageComponent implements HasConf mobileAppSettingsForm: FormGroup; - mobileAppSettings: MobileAppSettings; + mobileAppSettings: MobileAppQRCodeSettings; private readonly destroy$ = new Subject(); @@ -179,7 +179,7 @@ export class MobileAppSettingsComponent extends PageComponent implements HasConf }); } - private processMobileAppSettings(mobileAppSettings: MobileAppSettings): void { + private processMobileAppSettings(mobileAppSettings: MobileAppQRCodeSettings): void { this.mobileAppSettings = {...mobileAppSettings}; this.mobileAppSettingsForm.reset(this.mobileAppSettings); } diff --git a/ui-ngx/src/app/shared/models/mobile-app.models.ts b/ui-ngx/src/app/shared/models/mobile-app.models.ts index 0787f17747..02139bd913 100644 --- a/ui-ngx/src/app/shared/models/mobile-app.models.ts +++ b/ui-ngx/src/app/shared/models/mobile-app.models.ts @@ -16,7 +16,7 @@ import { TenantId } from '@shared/models/id/tenant-id'; -export interface MobileAppSettings { +export interface MobileAppQRCodeSettings { tenantId: TenantId; useDefaultApp: boolean; androidConfig: AndroidConfig; diff --git a/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json b/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json index d850e640b7..e69b43b1fe 100644 --- a/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json +++ b/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json @@ -1145,7 +1145,7 @@ "id": "8e71a398-caf5-540d-cec5-6e5dc264343e" }, "b9d704a2-f579-dbff-f123-f953d92081fd": { - "typeFullFqn": "system.home_page_widgets.mobile_app_qr_code", + "typeFullFqn": "system.home_page_widgets.home_mobile_app_qr_code", "type": "static", "sizeX": 6, "sizeY": 3, @@ -1176,12 +1176,10 @@ "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": false + "settings": {}, + "title": "Mobile app QR code", + "dropShadow": false, + "enableFullscreen": false }, "row": 0, "col": 0, diff --git a/ui-ngx/src/form.scss b/ui-ngx/src/form.scss index a692994144..fa1eab0ece 100644 --- a/ui-ngx/src/form.scss +++ b/ui-ngx/src/form.scss @@ -290,9 +290,6 @@ &.shrink { flex: 0; } - &.no-gap { - gap: 0; - } } .tb-form-panel, .tb-form-row { From 012457e0db9801fd023d9aac28195985573ab5e8 Mon Sep 17 00:00:00 2001 From: ViacheslavKlimov Date: Tue, 7 May 2024 11:55:06 +0300 Subject: [PATCH 31/84] Add mobileQrEnabled to system params --- .../server/controller/SystemInfoController.java | 10 ++++++++++ .../thingsboard/server/common/data/SystemParams.java | 1 + 2 files changed, 11 insertions(+) diff --git a/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java index cb2033b29d..bc12171306 100644 --- a/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java +++ b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java @@ -34,10 +34,13 @@ import org.thingsboard.server.common.data.SystemParams; 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.mobile.MobileAppSettings; +import org.thingsboard.server.common.data.mobile.QRCodeConfig; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.settings.UserSettings; import org.thingsboard.server.common.data.settings.UserSettingsType; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; +import org.thingsboard.server.dao.mobile.MobileAppSettingsService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.UserPrincipal; @@ -45,6 +48,7 @@ import org.thingsboard.server.service.sync.vc.EntitiesVersionControlService; import java.util.Collections; import java.util.List; +import java.util.Optional; import java.util.stream.Collectors; @Hidden @@ -72,6 +76,9 @@ public class SystemInfoController extends BaseController { @Autowired private EntitiesVersionControlService versionControlService; + @Autowired + private MobileAppSettingsService mobileAppSettingsService; + @PostConstruct public void init() { JsonNode info = buildInfoObject(); @@ -135,6 +142,9 @@ public class SystemInfoController extends BaseController { DefaultTenantProfileConfiguration tenantProfileConfiguration = tenantProfileCache.get(tenantId).getDefaultProfileConfiguration(); systemParams.setMaxResourceSize(tenantProfileConfiguration.getMaxResourceSize()); } + systemParams.setMobileQrEnabled(Optional.ofNullable(mobileAppSettingsService.getMobileAppSettings(TenantId.SYS_TENANT_ID)) + .map(MobileAppSettings::getQrCodeConfig).map(QRCodeConfig::isShowOnHomePage) + .orElse(false)); return systemParams; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java b/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java index e022e56c5b..5b4312f7b0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java @@ -31,4 +31,5 @@ public class SystemParams { JsonNode userSettings; long maxDatapointsLimit; long maxResourceSize; + boolean mobileQrEnabled; } From 0d7d9b481bd93008f44c022c5927c98a4d4e4fa2 Mon Sep 17 00:00:00 2001 From: ViacheslavKlimov Date: Tue, 7 May 2024 12:09:36 +0300 Subject: [PATCH 32/84] Refactoring for mobile settings api --- .../main/data/upgrade/3.6.4/schema_update.sql | 5 +++-- .../MobileApplicationController.java | 7 ++++--- .../secret}/MobileAppSecretService.java | 2 +- .../secret}/MobileAppSecretServiceImpl.java | 2 +- .../secret}/MobileSecretCaffeineCache.java | 2 +- .../secret}/MobileSecretEvictEvent.java | 2 +- .../secret}/MobileSecretRedisCache.java | 2 +- .../mobile/BaseMobileAppSettingsService.java | 20 ++++++++----------- .../mobile/MobileAppSettingsRedisCache.java | 2 +- .../model/sql/MobileAppSettingsEntity.java | 9 +++++---- .../main/resources/sql/schema-entities.sql | 7 ++++--- 11 files changed, 30 insertions(+), 30 deletions(-) rename application/src/main/java/org/thingsboard/server/service/{qr => mobile/secret}/MobileAppSecretService.java (94%) rename application/src/main/java/org/thingsboard/server/service/{qr => mobile/secret}/MobileAppSecretServiceImpl.java (98%) rename application/src/main/java/org/thingsboard/server/service/{qr => mobile/secret}/MobileSecretCaffeineCache.java (96%) rename application/src/main/java/org/thingsboard/server/service/{qr => mobile/secret}/MobileSecretEvictEvent.java (93%) rename application/src/main/java/org/thingsboard/server/service/{qr => mobile/secret}/MobileSecretRedisCache.java (96%) diff --git a/application/src/main/data/upgrade/3.6.4/schema_update.sql b/application/src/main/data/upgrade/3.6.4/schema_update.sql index 287991ffd1..9a5204379c 100644 --- a/application/src/main/data/upgrade/3.6.4/schema_update.sql +++ b/application/src/main/data/upgrade/3.6.4/schema_update.sql @@ -157,11 +157,12 @@ DELETE FROM asset_profile WHERE name ='TbServiceQueue'; CREATE TABLE IF NOT EXISTS mobile_app_settings ( id uuid NOT NULL CONSTRAINT mobile_app_settings_pkey PRIMARY KEY, created_time bigint NOT NULL, - tenant_id uuid UNIQUE NOT NULL, + tenant_id uuid NOT NULL, use_default_app boolean, android_config VARCHAR(1000), ios_config VARCHAR(1000), - qr_code_config VARCHAR(100000) + qr_code_config VARCHAR(100000), + CONSTRAINT mobile_app_settings_tenant_id_unq_key UNIQUE (tenant_id) ); -- MOBILE APP SETTINGS TABLE CREATE END \ No newline at end of file diff --git a/application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java b/application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java index ddd49ab975..626f6f3b0f 100644 --- a/application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java +++ b/application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java @@ -39,7 +39,7 @@ import org.thingsboard.server.common.data.security.model.JwtPair; import org.thingsboard.server.config.annotations.ApiOperation; import org.thingsboard.server.dao.mobile.MobileAppSettingsService; import org.thingsboard.server.queue.util.TbCoreComponent; -import org.thingsboard.server.service.qr.MobileAppSecretService; +import org.thingsboard.server.service.mobile.secret.MobileAppSecretService; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.permission.Operation; import org.thingsboard.server.service.security.permission.Resource; @@ -84,7 +84,7 @@ public class MobileApplicationController extends BaseController { public static final String ANDROID_APPLICATION_STORE_LINK = "https://play.google.com/store/apps/details?id=org.thingsboard.demo.app"; public static final String APPLE_APPLICATION_STORE_LINK = "https://apps.apple.com/us/app/thingsboard-live/id1594355695"; public static final String SECRET = "secret"; - public static final String SECRET_PARAM_DESCRIPTION = "A string value representing short-live secret key"; + public static final String SECRET_PARAM_DESCRIPTION = "A string value representing short-lived secret key"; public static final String DEFAULT_APP_DOMAIN = "demo.thingsboard.io"; public static final String DEEP_LINK_PATTERN = "https://%s/api/noauth/qr?secret=%s&ttl=%s"; @@ -164,7 +164,7 @@ public class MobileApplicationController extends BaseController { notes = "Returns the token of the User based on the provided secret key.") @GetMapping(value = "/api/noauth/qr/{secret}") public JwtPair getUserTokenByMobileSecret(@Parameter(description = SECRET_PARAM_DESCRIPTION) - @PathVariable(SECRET) String secret) throws ThingsboardException { + @PathVariable(SECRET) String secret) throws ThingsboardException { checkParameter(SECRET, secret); return mobileAppSecretService.getJwtPair(secret); } @@ -184,4 +184,5 @@ public class MobileApplicationController extends BaseController { .build(); } } + } diff --git a/application/src/main/java/org/thingsboard/server/service/qr/MobileAppSecretService.java b/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileAppSecretService.java similarity index 94% rename from application/src/main/java/org/thingsboard/server/service/qr/MobileAppSecretService.java rename to application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileAppSecretService.java index 943fd45c54..83c1b55c6e 100644 --- a/application/src/main/java/org/thingsboard/server/service/qr/MobileAppSecretService.java +++ b/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileAppSecretService.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.service.qr; +package org.thingsboard.server.service.mobile.secret; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.security.model.JwtPair; diff --git a/application/src/main/java/org/thingsboard/server/service/qr/MobileAppSecretServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileAppSecretServiceImpl.java similarity index 98% rename from application/src/main/java/org/thingsboard/server/service/qr/MobileAppSecretServiceImpl.java rename to application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileAppSecretServiceImpl.java index f8d8629d10..bef31622ff 100644 --- a/application/src/main/java/org/thingsboard/server/service/qr/MobileAppSecretServiceImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileAppSecretServiceImpl.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.service.qr; +package org.thingsboard.server.service.mobile.secret; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/application/src/main/java/org/thingsboard/server/service/qr/MobileSecretCaffeineCache.java b/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileSecretCaffeineCache.java similarity index 96% rename from application/src/main/java/org/thingsboard/server/service/qr/MobileSecretCaffeineCache.java rename to application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileSecretCaffeineCache.java index 916d58afd7..ce21041d27 100644 --- a/application/src/main/java/org/thingsboard/server/service/qr/MobileSecretCaffeineCache.java +++ b/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileSecretCaffeineCache.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.service.qr; +package org.thingsboard.server.service.mobile.secret; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.cache.CacheManager; diff --git a/application/src/main/java/org/thingsboard/server/service/qr/MobileSecretEvictEvent.java b/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileSecretEvictEvent.java similarity index 93% rename from application/src/main/java/org/thingsboard/server/service/qr/MobileSecretEvictEvent.java rename to application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileSecretEvictEvent.java index 1f97e9ab07..b015291d78 100644 --- a/application/src/main/java/org/thingsboard/server/service/qr/MobileSecretEvictEvent.java +++ b/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileSecretEvictEvent.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.service.qr; +package org.thingsboard.server.service.mobile.secret; import lombok.Data; diff --git a/application/src/main/java/org/thingsboard/server/service/qr/MobileSecretRedisCache.java b/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileSecretRedisCache.java similarity index 96% rename from application/src/main/java/org/thingsboard/server/service/qr/MobileSecretRedisCache.java rename to application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileSecretRedisCache.java index d29a104ae9..81fa8672f8 100644 --- a/application/src/main/java/org/thingsboard/server/service/qr/MobileSecretRedisCache.java +++ b/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileSecretRedisCache.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.service.qr; +package org.thingsboard.server.service.mobile.secret; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.data.redis.connection.RedisConnectionFactory; diff --git a/dao/src/main/java/org/thingsboard/server/dao/mobile/BaseMobileAppSettingsService.java b/dao/src/main/java/org/thingsboard/server/dao/mobile/BaseMobileAppSettingsService.java index 86de46fed1..4e8a2cd503 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/mobile/BaseMobileAppSettingsService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/mobile/BaseMobileAppSettingsService.java @@ -17,7 +17,6 @@ package org.thingsboard.server.dao.mobile; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.hibernate.exception.ConstraintViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.event.TransactionalEventListener; import org.thingsboard.server.common.data.id.TenantId; @@ -28,9 +27,10 @@ import org.thingsboard.server.common.data.mobile.IosConfig; import org.thingsboard.server.common.data.mobile.MobileAppSettings; import org.thingsboard.server.common.data.mobile.QRCodeConfig; import org.thingsboard.server.dao.entity.AbstractCachedEntityService; -import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.service.DataValidator; +import java.util.Map; + import static org.thingsboard.server.dao.service.Validator.validateId; @Service @@ -51,16 +51,12 @@ public class BaseMobileAppSettingsService extends AbstractCachedEntityService { private JsonNode qrCodeConfig; public MobileAppSettingsEntity(MobileAppSettings mobileAppSettings) { - if (mobileAppSettings.getId() != null) { - this.setId(mobileAppSettings.getId().getId()); - } + this.setId(mobileAppSettings.getUuidId()); this.setCreatedTime(mobileAppSettings.getCreatedTime()); this.tenantId = mobileAppSettings.getTenantId().getId(); this.useDefaultApp = mobileAppSettings.isUseDefaultApp(); this.androidConfig = toJson(mobileAppSettings.getAndroidConfig()); this.iosConfig = toJson(mobileAppSettings.getIosConfig()); this.qrCodeConfig = toJson(mobileAppSettings.getQrCodeConfig()); - } + } @Override public MobileAppSettings toData() { @@ -81,4 +81,5 @@ public class MobileAppSettingsEntity extends BaseSqlEntity { mobileAppSettings.setQrCodeConfig(fromJson(qrCodeConfig, QRCodeConfig.class)); return mobileAppSettings; } + } diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index 3a96580011..f2978b7f4a 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -900,9 +900,10 @@ CREATE TABLE IF NOT EXISTS queue_stats ( CREATE TABLE IF NOT EXISTS mobile_app_settings ( id uuid NOT NULL CONSTRAINT mobile_app_settings_pkey PRIMARY KEY, created_time bigint NOT NULL, - tenant_id uuid UNIQUE NOT NULL, + tenant_id uuid NOT NULL, use_default_app boolean, android_config VARCHAR(1000), ios_config VARCHAR(1000), - qr_code_config VARCHAR(100000) -); \ No newline at end of file + qr_code_config VARCHAR(100000), + CONSTRAINT mobile_app_settings_tenant_id_unq_key UNIQUE (tenant_id) +); From 6514f6c8676711ef4291daf7e1e66f3fca9dc666 Mon Sep 17 00:00:00 2001 From: YevhenBondarenko Date: Tue, 7 May 2024 15:51:30 +0300 Subject: [PATCH 33/84] added Login audit log for activateUser --- .../org/thingsboard/server/controller/AuthController.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/AuthController.java b/application/src/main/java/org/thingsboard/server/controller/AuthController.java index 2898689760..571f32d171 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AuthController.java @@ -256,7 +256,9 @@ public class AuthController extends BaseController { } } - return tokenFactory.createTokenPair(securityUser); + var tokenPair = tokenFactory.createTokenPair(securityUser); + systemSecurityService.logLoginAction(user, new RestAuthenticationDetails(request), ActionType.LOGIN, null); + return tokenPair; } @ApiOperation(value = "Reset password (resetPassword)", From bdf577710e6dc7d8101bd80ecf7157ec9db1b8fb Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Tue, 7 May 2024 15:41:23 +0200 Subject: [PATCH 34/84] Mocks the few JUnit4 classes that testcontainers needs for successful compilation. Signed-off-by: Oleksandra Matviienko --- msa/black-box-tests/pom.xml | 5 +++++ pom.xml | 9 +++++++++ 2 files changed, 14 insertions(+) diff --git a/msa/black-box-tests/pom.xml b/msa/black-box-tests/pom.xml index f433b1930c..4c81e0f31f 100644 --- a/msa/black-box-tests/pom.xml +++ b/msa/black-box-tests/pom.xml @@ -43,6 +43,11 @@ testcontainers test + + software.xdev + testcontainers-junit4-mock + test + org.zeroturnaround zt-exec diff --git a/pom.xml b/pom.xml index 74c095c857..bf15107acd 100755 --- a/pom.xml +++ b/pom.xml @@ -148,6 +148,7 @@ 5.4.0 2.2 1.19.7 + 1.0.1 1.12 4.19.1 5.8.0 @@ -2125,6 +2126,14 @@ + + + software.xdev + testcontainers-junit4-mock + ${testcontainers-junit4-mock.version} + test + org.zeroturnaround zt-exec From d163b5679c3d2cbef72fa521aa5f6c8fede6a1d9 Mon Sep 17 00:00:00 2001 From: rusikv Date: Tue, 7 May 2024 17:14:31 +0300 Subject: [PATCH 35/84] UI: mobile qrcode improvements --- ...ts => mobile-app-qr-code-widget.models.ts} | 0 .../mobile-app-qrcode-widget.component.html | 3 +- .../mobile-app-qrcode-widget.component.scss | 2 +- .../lib/mobile-app-qrcode-widget.component.ts | 7 ++- ...e-app-qr-code-widget-settings.component.ts | 2 +- .../admin/mobile-app-settings.component.ts | 3 +- .../android_store_en_black_badge.svg | 49 ++++++++++++------- .../dashboard/tenant_admin_home_page.json | 4 +- 8 files changed, 45 insertions(+), 25 deletions(-) rename ui-ngx/src/app/modules/home/components/widget/lib/cards/{mobile-app-qr-code-widge.models.ts => mobile-app-qr-code-widget.models.ts} (100%) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/mobile-app-qr-code-widge.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/cards/mobile-app-qr-code-widget.models.ts similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/cards/mobile-app-qr-code-widge.models.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/cards/mobile-app-qr-code-widget.models.ts diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.html index e3f9ca11c3..35092807dc 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.html @@ -20,9 +20,10 @@
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.scss index 5dafcc7521..6d229abb0a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.scss @@ -51,7 +51,7 @@ } .tb-badge-container { - @media #{$mat-lt-lg} { + @media #{$mat-md} { display: none; } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.ts index 7296b47f80..c7557c75c2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.ts @@ -25,7 +25,7 @@ import { UtilsService } from '@core/services/utils.service'; import { interval, mergeMap, Observable, Subject, takeUntil } from 'rxjs'; import { MINUTE } from '@shared/models/time/time.models'; import { coerceBoolean } from '@shared/decorators/coercion'; -import { MobileAppQrCodeWidgetSettings } from '@home/components/widget/lib/cards/mobile-app-qr-code-widge.models'; +import { MobileAppQrCodeWidgetSettings } from '@home/components/widget/lib/cards/mobile-app-qr-code-widget.models'; @Component({ selector: 'tb-mobile-app-qrcode-widget', @@ -104,7 +104,10 @@ export class MobileAppQrcodeWidgetComponent extends PageComponent implements OnI updateQRCode(link: string) { import('qrcode').then((QRCode) => { - QRCode.toCanvas(this.canvasRef.nativeElement, link, { width: 90 }); + QRCode.toCanvas(this.canvasRef.nativeElement, link, { width: 100 }); + // QRCode.toCanvas(this.canvasRef.nativeElement, link); + // this.canvasRef.nativeElement.style.width = '4.6vw'; + // this.canvasRef.nativeElement.style.height = '4.6vw'; }); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.ts index 61999d7d41..662a9d3dd3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.ts @@ -20,7 +20,7 @@ import { WidgetSettings, WidgetSettingsComponent } from "@shared/models/widget.m import { AppState } from '@core/core.state'; import { Store } from "@ngrx/store"; import { badgePositionTranslationsMap, badgeStyleTranslationsMap } from '@shared/models/mobile-app.models'; -import { mobileAppQrCodeWidgetDefaultSettings } from '@home/components/widget/lib/cards/mobile-app-qr-code-widge.models'; +import { mobileAppQrCodeWidgetDefaultSettings } from '@home/components/widget/lib/cards/mobile-app-qr-code-widget.models'; @Component({ selector: 'tb-mobile-app-qr-code-widget-settings', diff --git a/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.ts b/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.ts index aa2aca3cb9..7959b1b800 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.ts @@ -29,6 +29,7 @@ import { badgeStyleTranslationsMap, MobileAppQRCodeSettings } from '@shared/models/mobile-app.models'; +import { AuthService } from '@core/auth/auth.service'; @Component({ selector: 'tb-mobile-app-settings', @@ -48,7 +49,7 @@ export class MobileAppSettingsComponent extends PageComponent implements HasConf constructor(protected store: Store, private mobileAppService: MobileAppService, - public fb: FormBuilder) { + private fb: FormBuilder) { super(store); this.buildMobileAppSettingsForm(); this.mobileAppService.getMobileAppSettings() diff --git a/ui-ngx/src/assets/android-ios-stores-badges/android_store_en_black_badge.svg b/ui-ngx/src/assets/android-ios-stores-badges/android_store_en_black_badge.svg index 02b88dab6f..2ef3c8fc10 100644 --- a/ui-ngx/src/assets/android-ios-stores-badges/android_store_en_black_badge.svg +++ b/ui-ngx/src/assets/android-ios-stores-badges/android_store_en_black_badge.svg @@ -1,22 +1,35 @@ - - - - - - - - - - - - - - - - + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json b/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json index e69b43b1fe..95e33e211b 100644 --- a/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json +++ b/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json @@ -1258,7 +1258,9 @@ "sizeX": 35, "sizeY": 16, "row": 42, - "col": 85 + "col": 85, + "mobileOrder": 5, + "mobileHeight": 7 } }, "gridSettings": { From 2bbeb6ab34c0c67249159032e809ec639b86a9b9 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Tue, 7 May 2024 18:05:22 +0300 Subject: [PATCH 36/84] UI: Added support mobileQrEnabled settings in tenant admin home page dashboard --- ui-ngx/src/app/core/auth/auth.models.ts | 1 + ui-ngx/src/app/core/auth/auth.reducer.ts | 1 + ui-ngx/src/app/core/auth/auth.selectors.ts | 11 + .../home-links/home-links-routing.module.ts | 17 +- .../dashboard/tenant_admin_home_page.json | 189 +++++++++++++----- 5 files changed, 167 insertions(+), 52 deletions(-) diff --git a/ui-ngx/src/app/core/auth/auth.models.ts b/ui-ngx/src/app/core/auth/auth.models.ts index 7f76f25854..b73847ef83 100644 --- a/ui-ngx/src/app/core/auth/auth.models.ts +++ b/ui-ngx/src/app/core/auth/auth.models.ts @@ -24,6 +24,7 @@ export interface SysParamsState { hasRepository: boolean; tbelEnabled: boolean; persistDeviceStateToTelemetry: boolean; + mobileQrEnabled: boolean; userSettings: UserSettings; maxResourceSize: number; } diff --git a/ui-ngx/src/app/core/auth/auth.reducer.ts b/ui-ngx/src/app/core/auth/auth.reducer.ts index f755438600..cc9d01532a 100644 --- a/ui-ngx/src/app/core/auth/auth.reducer.ts +++ b/ui-ngx/src/app/core/auth/auth.reducer.ts @@ -29,6 +29,7 @@ const emptyUserAuthState: AuthPayload = { hasRepository: false, tbelEnabled: false, persistDeviceStateToTelemetry: false, + mobileQrEnabled: false, maxResourceSize: 0, userSettings: initialUserSettings }; diff --git a/ui-ngx/src/app/core/auth/auth.selectors.ts b/ui-ngx/src/app/core/auth/auth.selectors.ts index 0cb13c9865..0a97ef5665 100644 --- a/ui-ngx/src/app/core/auth/auth.selectors.ts +++ b/ui-ngx/src/app/core/auth/auth.selectors.ts @@ -78,6 +78,17 @@ export const selectPersistDeviceStateToTelemetry = createSelector( (state: AuthState) => state.persistDeviceStateToTelemetry ); +export const selectMobileQrEnabled = createSelector( + selectAuthState, + (state: AuthState) => state.mobileQrEnabled +); + +export const selectPersistDeviceStateToTelemetryAndMobileQrEnabled = createSelector( + selectPersistDeviceStateToTelemetry, + selectMobileQrEnabled, + (persistDeviceStateToTelemetry, mobileQrEnabled) => ({persistDeviceStateToTelemetry, mobileQrEnabled}) +); + export const selectUserSettings = createSelector( selectAuthState, (state: AuthState) => state.userSettings 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 index 6c5789b218..3b6b909225 100644 --- 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 @@ -25,7 +25,7 @@ import { DashboardService } from '@core/http/dashboard.service'; import { select, Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { map } from 'rxjs/operators'; -import { getCurrentAuthUser, selectPersistDeviceStateToTelemetry } from '@core/auth/auth.selectors'; +import { getCurrentAuthUser, selectPersistDeviceStateToTelemetryAndMobileQrEnabled } from '@core/auth/auth.selectors'; import { EntityKeyType } from '@shared/models/query/query.models'; import { ResourcesService } from '@core/services/resources.service'; @@ -35,16 +35,25 @@ const customerUserHomePageJson = '/assets/dashboard/customer_user_home_page.json const updateDeviceActivityKeyFilterIfNeeded = (store: Store, dashboard$: Observable): Observable => - store.pipe(select(selectPersistDeviceStateToTelemetry)).pipe( - mergeMap((persistToTelemetry) => dashboard$.pipe( + store.pipe(select(selectPersistDeviceStateToTelemetryAndMobileQrEnabled)).pipe( + mergeMap((params) => dashboard$.pipe( map((dashboard) => { - if (persistToTelemetry) { + if (params.persistDeviceStateToTelemetry) { for (const filterId of Object.keys(dashboard.configuration.filters)) { if (['Active Devices', 'Inactive Devices'].includes(dashboard.configuration.filters[filterId].filter)) { dashboard.configuration.filters[filterId].keyFilters[0].key.type = EntityKeyType.TIME_SERIES; } } } + if (params.mobileQrEnabled) { + for (const widgetId of Object.keys(dashboard.configuration.widgets)) { + if (dashboard.configuration.widgets[widgetId].config.title === 'Select show mobile QR code') { + dashboard.configuration.widgets[widgetId].config.settings.markdownTextFunction = + (dashboard.configuration.widgets[widgetId].config.settings.markdownTextFunction as string) + .replace('\'${mobileQrEnabled}\'', String(params.mobileQrEnabled)); + } + } + } return dashboard; }) )) diff --git a/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json b/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json index 95e33e211b..b9cba6eb08 100644 --- a/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json +++ b/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json @@ -101,38 +101,6 @@ "id": "867f82cf-ecf2-2d5c-35cb-08c6f2edc3a4", "typeFullFqn": "system.home_page_widgets.documentation_links" }, - "a23185ad-dc46-806c-0e50-5b21fb080ace": { - "type": "static", - "sizeX": 7.5, - "sizeY": 6.5, - "config": { - "datasources": [], - "timewindow": { - "realtime": { - "timewindowMs": 60000 - } - }, - "showTitle": false, - "backgroundColor": "rgb(255, 255, 255)", - "color": "rgba(0, 0, 0, 0.87)", - "padding": "16px", - "settings": { - "columns": 3 - }, - "title": "Getting started", - "dropShadow": false, - "enableFullscreen": false, - "widgetStyle": {}, - "widgetCss": "", - "pageSize": 1024, - "noDataDisplayMessage": "", - "showLegend": false - }, - "row": 0, - "col": 0, - "id": "a23185ad-dc46-806c-0e50-5b21fb080ace", - "typeFullFqn": "system.home_page_widgets.getting_started" - }, "ebbd0a6e-8a47-e770-5086-7f4974250f2d": { "type": "static", "sizeX": 7.5, @@ -1144,7 +1112,108 @@ "col": 0, "id": "8e71a398-caf5-540d-cec5-6e5dc264343e" }, - "b9d704a2-f579-dbff-f123-f953d92081fd": { + "35bda856-2495-4f72-e35e-925bac8b8da7": { + "typeFullFqn": "system.cards.markdown_card", + "type": "latest", + "sizeX": 5, + "sizeY": 3.5, + "config": { + "datasources": [], + "timewindow": { + "displayValue": "", + "selectedTab": 0, + "realtime": { + "realtimeType": 1, + "interval": 1000, + "timewindowMs": 60000, + "quickInterval": "CURRENT_DAY" + }, + "history": { + "historyType": 0, + "interval": 1000, + "timewindowMs": 60000, + "fixedTimewindow": { + "startTimeMs": 1715005524783, + "endTimeMs": 1715091924783 + }, + "quickInterval": "CURRENT_DAY" + }, + "aggregation": { + "type": "AVG", + "limit": 25000 + } + }, + "showTitle": false, + "backgroundColor": "#fff", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "0px", + "settings": { + "useMarkdownTextFunction": true, + "markdownTextPattern": "### Markdown/HTML card\n - **Current entity**: ${entityName}.\n - **Current value**: ${Random}.", + "markdownTextFunction": "let html = '
';\nlet supportMobile = '${mobileQrEnabled}' || false;\nif (supportMobile && typeof supportMobile === 'boolean') {\n html += '';\n} else {\n html += '';\n}\nhtml += '
';\nreturn html;", + "applyDefaultMarkdownStyle": false, + "markdownCss": "" + }, + "title": "Select show mobile QR code", + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "dropShadow": true, + "enableFullscreen": false, + "widgetStyle": { + "border": "none", + "boxShadow": "none", + "padding": 0 + }, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "showLegend": false, + "useDashboardTimewindow": true, + "displayTimewindow": true, + "widgetCss": "", + "pageSize": 1024, + "noDataDisplayMessage": "" + }, + "row": 0, + "col": 0, + "id": "35bda856-2495-4f72-e35e-925bac8b8da7" + }, + "ed33e56f-293b-aaaa-ad19-0cf499dc926f": { + "type": "static", + "sizeX": 7.5, + "sizeY": 6.5, + "config": { + "datasources": [], + "timewindow": { + "realtime": { + "timewindowMs": 60000 + } + }, + "showTitle": false, + "backgroundColor": "rgb(255, 255, 255)", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "16px", + "settings": { + "columns": 3 + }, + "title": "Getting started", + "dropShadow": false, + "enableFullscreen": false, + "widgetStyle": {}, + "widgetCss": "", + "pageSize": 1024, + "noDataDisplayMessage": "", + "showLegend": false + }, + "row": 0, + "col": 0, + "id": "ed33e56f-293b-aaaa-ad19-0cf499dc926f", + "typeFullFqn": "system.home_page_widgets.getting_started" + }, + "8c5207d8-7103-4dc2-db48-d61c0c8c61fd": { "typeFullFqn": "system.home_page_widgets.home_mobile_app_qr_code", "type": "static", "sizeX": 6, @@ -1183,7 +1252,7 @@ }, "row": 0, "col": 0, - "id": "b9d704a2-f579-dbff-f123-f953d92081fd" + "id": "8c5207d8-7103-4dc2-db48-d61c0c8c61fd" } }, "states": { @@ -1208,13 +1277,6 @@ "col": 28, "mobileHide": true }, - "a23185ad-dc46-806c-0e50-5b21fb080ace": { - "sizeX": 35, - "sizeY": 42, - "row": 0, - "col": 85, - "mobileHide": true - }, "ebbd0a6e-8a47-e770-5086-7f4974250f2d": { "sizeX": 28, "sizeY": 16, @@ -1254,13 +1316,11 @@ "col": 43, "mobileOrder": 0 }, - "b9d704a2-f579-dbff-f123-f953d92081fd": { + "35bda856-2495-4f72-e35e-925bac8b8da7": { "sizeX": 35, - "sizeY": 16, - "row": 42, - "col": 85, - "mobileOrder": 5, - "mobileHeight": 7 + "sizeY": 58, + "row": 0, + "col": 85 } }, "gridSettings": { @@ -1330,6 +1390,39 @@ } } } + }, + "mobile_app_qr_code": { + "name": "Mobile App QR code", + "root": false, + "layouts": { + "main": { + "widgets": { + "ed33e56f-293b-aaaa-ad19-0cf499dc926f": { + "sizeX": 120, + "sizeY": 45, + "row": 0, + "col": 0 + }, + "8c5207d8-7103-4dc2-db48-d61c0c8c61fd": { + "sizeX": 120, + "sizeY": 17, + "row": 45, + "col": 0 + } + }, + "gridSettings": { + "backgroundColor": "#eeeeee", + "columns": 120, + "margin": 12, + "outerMargin": false, + "backgroundSizeMode": "100%", + "autoFillHeight": true, + "backgroundImageUrl": null, + "mobileAutoFillHeight": false, + "mobileRowHeight": 20 + } + } + } } }, "entityAliases": { @@ -1465,4 +1558,4 @@ } }, "name": "Tenant Administrator Home Page" -} +} \ No newline at end of file From 814bc331aebc09facd586d958cb8ba4245f2372c Mon Sep 17 00:00:00 2001 From: YevhenBondarenko Date: Wed, 8 May 2024 18:06:18 +0300 Subject: [PATCH 37/84] fixed tests related to the login audit log --- .../server/controller/AuditLogControllerTest.java | 6 +++--- .../thingsboard/server/controller/TwoFactorAuthTest.java | 8 +++----- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest.java index 31f398dc03..5005e6ad34 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest.java @@ -122,7 +122,7 @@ public class AuditLogControllerTest extends AbstractControllerTest { } } while (pageData.hasNext()); - Assert.assertEquals(11, loadedAuditLogs.size()); + Assert.assertEquals(11 + 1, loadedAuditLogs.size()); loadedAuditLogs = new ArrayList<>(); pageLink = new TimePageLink(5); @@ -136,7 +136,7 @@ public class AuditLogControllerTest extends AbstractControllerTest { } } while (pageData.hasNext()); - Assert.assertEquals(11, loadedAuditLogs.size()); + Assert.assertEquals(11 + 1, loadedAuditLogs.size()); loadedAuditLogs = new ArrayList<>(); pageLink = new TimePageLink(5); @@ -150,7 +150,7 @@ public class AuditLogControllerTest extends AbstractControllerTest { } } while (pageData.hasNext()); - Assert.assertEquals(11, loadedAuditLogs.size()); + Assert.assertEquals(11 + 1, loadedAuditLogs.size()); } @Test diff --git a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java index 38286589bd..63703ae27b 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java @@ -298,15 +298,13 @@ public class TwoFactorAuthTest extends AbstractControllerTest { logInWithPreVerificationToken(username, password); await("async audit log saving").during(1, TimeUnit.SECONDS); - assertThat(getLogInAuditLogs()).isEmpty(); - assertThat(userService.findUserById(tenantId, user.getId()).getAdditionalInfo() - .get("lastLoginTs")).isNull(); doPost("/api/auth/2fa/verification/check?providerType=TOTP&verificationCode=incorrect") .andExpect(status().isBadRequest()); + // there is the first login audit log after user activation await("async audit log saving").atMost(1, TimeUnit.SECONDS) - .until(() -> getLogInAuditLogs().size() == 1); + .until(() -> getLogInAuditLogs().size() == 2); assertThat(getLogInAuditLogs().get(0)).satisfies(failedLogInAuditLog -> { assertThat(failedLogInAuditLog.getActionStatus()).isEqualTo(ActionStatus.FAILURE); assertThat(failedLogInAuditLog.getActionFailureDetails()).containsIgnoringCase("verification code is incorrect"); @@ -316,7 +314,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { doPost("/api/auth/2fa/verification/check?providerType=TOTP&verificationCode=" + getCorrectTotp(totpTwoFaAccountConfig)) .andExpect(status().isOk()); await("async audit log saving").atMost(1, TimeUnit.SECONDS) - .until(() -> getLogInAuditLogs().size() == 2); + .until(() -> getLogInAuditLogs().size() == 3); assertThat(getLogInAuditLogs().get(0)).satisfies(successfulLogInAuditLog -> { assertThat(successfulLogInAuditLog.getActionStatus()).isEqualTo(ActionStatus.SUCCESS); assertThat(successfulLogInAuditLog.getUserName()).isEqualTo(username); From 5dc91d857c7e066ea6321117bf30da8a99422f37 Mon Sep 17 00:00:00 2001 From: YevhenBondarenko Date: Thu, 9 May 2024 12:22:07 +0300 Subject: [PATCH 38/84] fixed platform allowable values for oauth in swagger --- .../org/thingsboard/server/controller/OAuth2Controller.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/OAuth2Controller.java b/application/src/main/java/org/thingsboard/server/controller/OAuth2Controller.java index c0a9d6d26f..325cc3b025 100644 --- a/application/src/main/java/org/thingsboard/server/controller/OAuth2Controller.java +++ b/application/src/main/java/org/thingsboard/server/controller/OAuth2Controller.java @@ -68,7 +68,7 @@ public class OAuth2Controller extends BaseController { @Parameter(description = "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", - schema = @Schema(allowableValues = "WEB, ANDROID, IOS")) + schema = @Schema(allowableValues = {"WEB", "ANDROID", "IOS"})) @RequestParam(required = false) String platform) throws ThingsboardException { if (log.isDebugEnabled()) { log.debug("Executing getOAuth2Clients: [{}][{}][{}]", request.getScheme(), request.getServerName(), request.getServerPort()); From 0bc04222ba91302efce90c62b849ed6c83729770 Mon Sep 17 00:00:00 2001 From: rusikv Date: Thu, 9 May 2024 16:44:38 +0300 Subject: [PATCH 39/84] UI: added mobile app qr code to sys admin home page --- ui-ngx/src/app/core/services/utils.service.ts | 2 +- .../mobile-app-qrcode-widget.component.scss | 6 + .../home-links/home-links-routing.module.ts | 40 ++- .../assets/dashboard/sys_admin_home_page.json | 229 +++++++++++++++--- .../dashboard/tenant_admin_home_page.json | 13 +- 5 files changed, 232 insertions(+), 58 deletions(-) diff --git a/ui-ngx/src/app/core/services/utils.service.ts b/ui-ngx/src/app/core/services/utils.service.ts index d7238c450d..097c3b8d49 100644 --- a/ui-ngx/src/app/core/services/utils.service.ts +++ b/ui-ngx/src/app/core/services/utils.service.ts @@ -30,7 +30,7 @@ import { guid, hashCode, isDefined, - isDefinedAndNotNull, isNotEmptyStr, + isDefinedAndNotNull, isString, isUndefined, objToBase64, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.scss index 6d229abb0a..d00baa119e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.scss @@ -33,6 +33,7 @@ color: rgba(0, 0, 0, 0.76); @media #{$mat-md-lg} { + padding-bottom: 0; font-weight: 500; font-size: 14px; line-height: 20px; @@ -43,6 +44,11 @@ .tb-qrcode-label { font-size: 14px; color: rgba(0, 0, 0, 0.54); + + @media #{$mat-md-lg} { + font-size: 11px; + line-height: 16px; + } } .tb-qrcode { 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 index 3b6b909225..9c6adf91ca 100644 --- 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 @@ -25,7 +25,11 @@ import { DashboardService } from '@core/http/dashboard.service'; import { select, Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { map } from 'rxjs/operators'; -import { getCurrentAuthUser, selectPersistDeviceStateToTelemetryAndMobileQrEnabled } from '@core/auth/auth.selectors'; +import { + getCurrentAuthUser, + selectMobileQrEnabled, + selectPersistDeviceStateToTelemetryAndMobileQrEnabled +} from '@core/auth/auth.selectors'; import { EntityKeyType } from '@shared/models/query/query.models'; import { ResourcesService } from '@core/services/resources.service'; @@ -45,20 +49,32 @@ const updateDeviceActivityKeyFilterIfNeeded = (store: Store, } } } - if (params.mobileQrEnabled) { - for (const widgetId of Object.keys(dashboard.configuration.widgets)) { - if (dashboard.configuration.widgets[widgetId].config.title === 'Select show mobile QR code') { - dashboard.configuration.widgets[widgetId].config.settings.markdownTextFunction = - (dashboard.configuration.widgets[widgetId].config.settings.markdownTextFunction as string) - .replace('\'${mobileQrEnabled}\'', String(params.mobileQrEnabled)); - } - } - } - return dashboard; + return params.mobileQrEnabled ? toggleMobileQRCodeDisplay(dashboard) : dashboard; }) )) ); +const toggleMobileQRCodeDisplayIfNeeded = (store: Store, + dashboard$: Observable): Observable => + store.pipe(select(selectMobileQrEnabled)).pipe( + mergeMap((mobileQrEnabled) => dashboard$.pipe( + map((dashboard) => { + return mobileQrEnabled ? toggleMobileQRCodeDisplay(dashboard) : dashboard; + }) + )) + ); + +const toggleMobileQRCodeDisplay = (dashboard: HomeDashboard) => { + for (const widgetId of Object.keys(dashboard.configuration.widgets)) { + if (dashboard.configuration.widgets[widgetId].config.title === 'Select show mobile QR code') { + dashboard.configuration.widgets[widgetId].config.settings.markdownTextFunction = + (dashboard.configuration.widgets[widgetId].config.settings.markdownTextFunction as string) + .replace('\'${mobileQrEnabled}\'', String(true)); + } + } + return dashboard; +} + export const homeDashboardResolver: ResolveFn = ( route: ActivatedRouteSnapshot, state: RouterStateSnapshot, @@ -73,7 +89,7 @@ export const homeDashboardResolver: ResolveFn = ( const authority = getCurrentAuthUser(store).authority; switch (authority) { case Authority.SYS_ADMIN: - dashboard$ = resourcesService.loadJsonResource(sysAdminHomePageJson); + dashboard$ = toggleMobileQRCodeDisplayIfNeeded(store, resourcesService.loadJsonResource(sysAdminHomePageJson)); break; case Authority.TENANT_ADMIN: dashboard$ = updateDeviceActivityKeyFilterIfNeeded(store, resourcesService.loadJsonResource(tenantAdminHomePageJson)); diff --git a/ui-ngx/src/assets/dashboard/sys_admin_home_page.json b/ui-ngx/src/assets/dashboard/sys_admin_home_page.json index a47103059e..43998afbcb 100644 --- a/ui-ngx/src/assets/dashboard/sys_admin_home_page.json +++ b/ui-ngx/src/assets/dashboard/sys_admin_home_page.json @@ -377,38 +377,6 @@ "id": "867f82cf-ecf2-2d5c-35cb-08c6f2edc3a4", "typeFullFqn": "system.home_page_widgets.documentation_links" }, - "a23185ad-dc46-806c-0e50-5b21fb080ace": { - "type": "static", - "sizeX": 7.5, - "sizeY": 6.5, - "config": { - "datasources": [], - "timewindow": { - "realtime": { - "timewindowMs": 60000 - } - }, - "showTitle": false, - "backgroundColor": "rgb(255, 255, 255)", - "color": "rgba(0, 0, 0, 0.87)", - "padding": "16px", - "settings": { - "columns": 3 - }, - "title": "Getting started", - "dropShadow": false, - "enableFullscreen": false, - "widgetStyle": {}, - "widgetCss": "", - "pageSize": 1024, - "noDataDisplayMessage": "", - "showLegend": false - }, - "row": 0, - "col": 0, - "id": "a23185ad-dc46-806c-0e50-5b21fb080ace", - "typeFullFqn": "system.home_page_widgets.getting_started" - }, "b35fa1b0-7f95-0b23-fade-d066653a40e1": { "type": "latest", "sizeX": 5, @@ -2640,6 +2608,152 @@ "row": 0, "col": 0, "id": "b9174a99-2a00-3dbe-0eae-927fd8506345" + }, + "c02d59a0-c979-5ec6-7f58-a5d89ff66236": { + "type": "static", + "sizeX": 7.5, + "sizeY": 6.5, + "config": { + "datasources": [], + "timewindow": { + "realtime": { + "timewindowMs": 60000 + } + }, + "showTitle": false, + "backgroundColor": "rgb(255, 255, 255)", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "16px", + "settings": { + "columns": 3 + }, + "title": "Getting started", + "dropShadow": false, + "enableFullscreen": false, + "widgetStyle": {}, + "widgetCss": "", + "pageSize": 1024, + "noDataDisplayMessage": "", + "showLegend": false + }, + "row": 0, + "col": 0, + "id": "c02d59a0-c979-5ec6-7f58-a5d89ff66236", + "typeFullFqn": "system.home_page_widgets.getting_started" + }, + "53d5f2dd-d432-5362-745f-63bf4487dc96": { + "typeFullFqn": "system.home_page_widgets.home_page_mobile_app_qr_code", + "type": "static", + "sizeX": 6, + "sizeY": 3, + "config": { + "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": {}, + "title": "Mobile app QR code", + "dropShadow": false, + "enableFullscreen": false, + "widgetStyle": {}, + "widgetCss": "", + "pageSize": 1024, + "noDataDisplayMessage": "" + }, + "row": 0, + "col": 0, + "id": "53d5f2dd-d432-5362-745f-63bf4487dc96" + }, + "0d34fa85-914f-30de-e3dd-7b7879f8d593": { + "typeFullFqn": "system.cards.markdown_card", + "type": "latest", + "sizeX": 5, + "sizeY": 3.5, + "config": { + "datasources": [], + "timewindow": { + "displayValue": "", + "selectedTab": 0, + "realtime": { + "realtimeType": 1, + "interval": 1000, + "timewindowMs": 60000, + "quickInterval": "CURRENT_DAY" + }, + "history": { + "historyType": 0, + "interval": 1000, + "timewindowMs": 60000, + "fixedTimewindow": { + "startTimeMs": 1715012840937, + "endTimeMs": 1715099240937 + }, + "quickInterval": "CURRENT_DAY" + }, + "aggregation": { + "type": "AVG", + "limit": 25000 + } + }, + "showTitle": false, + "backgroundColor": "#fff", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "0px", + "settings": { + "useMarkdownTextFunction": true, + "markdownTextPattern": "### Markdown/HTML card\n - **Current entity**: ${entityName}.\n - **Current value**: ${Random}.", + "markdownTextFunction": "let html = '
';\nlet supportMobile = '${mobileQrEnabled}' || false;\nif (supportMobile && typeof supportMobile === 'boolean') {\n ctx.dashboardWidget.widgetLayout.mobileHide = false;\n html += '';\n} else {\n ctx.dashboardWidget.widgetLayout.mobileHide = true;\n html += '';\n}\nhtml += '
';\nreturn html;", + "applyDefaultMarkdownStyle": false, + "markdownCss": "" + }, + "title": "Select show mobile QR code", + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "dropShadow": true, + "enableFullscreen": false, + "widgetStyle": { + "border": "none", + "boxShadow": "none", + "padding": 0 + }, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "showLegend": false, + "useDashboardTimewindow": true, + "displayTimewindow": true, + "widgetCss": "", + "pageSize": 1024, + "noDataDisplayMessage": "" + }, + "row": 0, + "col": 0, + "id": "0d34fa85-914f-30de-e3dd-7b7879f8d593" } }, "states": { @@ -2695,13 +2809,6 @@ "col": 0, "mobileHide": true }, - "a23185ad-dc46-806c-0e50-5b21fb080ace": { - "sizeX": 35, - "sizeY": 58, - "row": 0, - "col": 85, - "mobileHide": true - }, "163025d8-3ca4-dd1e-c17b-e40d8e03e8a8": { "sizeX": 44, "sizeY": 15, @@ -2709,6 +2816,14 @@ "mobileHeight": 7, "row": 0, "col": 0 + }, + "0d34fa85-914f-30de-e3dd-7b7879f8d593": { + "sizeX": 35, + "sizeY": 58, + "row": 0, + "col": 85, + "mobileHide": false, + "mobileHeight": 7 } }, "gridSettings": { @@ -2936,6 +3051,40 @@ } } } + }, + "mobile_app_qr_code": { + "name": "Mobile App QR code", + "root": false, + "layouts": { + "main": { + "widgets": { + "c02d59a0-c979-5ec6-7f58-a5d89ff66236": { + "sizeX": 120, + "sizeY": 45, + "row": 0, + "col": 0, + "mobileHide": true + }, + "53d5f2dd-d432-5362-745f-63bf4487dc96": { + "sizeX": 120, + "sizeY": 17, + "row": 45, + "col": 0 + } + }, + "gridSettings": { + "backgroundColor": "#eeeeee", + "columns": 120, + "margin": 12, + "outerMargin": false, + "backgroundSizeMode": "100%", + "autoFillHeight": true, + "backgroundImageUrl": null, + "mobileAutoFillHeight": false, + "mobileRowHeight": 20 + } + } + } } }, "entityAliases": { @@ -3051,4 +3200,4 @@ } }, "name": "System Administrator Home Page" -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json b/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json index b9cba6eb08..cb42df7839 100644 --- a/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json +++ b/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json @@ -1150,7 +1150,7 @@ "settings": { "useMarkdownTextFunction": true, "markdownTextPattern": "### Markdown/HTML card\n - **Current entity**: ${entityName}.\n - **Current value**: ${Random}.", - "markdownTextFunction": "let html = '
';\nlet supportMobile = '${mobileQrEnabled}' || false;\nif (supportMobile && typeof supportMobile === 'boolean') {\n html += '';\n} else {\n html += '';\n}\nhtml += '
';\nreturn html;", + "markdownTextFunction": "let html = '
';\nlet supportMobile = '${mobileQrEnabled}' || false;\nif (supportMobile && typeof supportMobile === 'boolean') {\n ctx.dashboardWidget.widgetLayout.mobileHide = false;\n html += '';\n} else {\n ctx.dashboardWidget.widgetLayout.mobileHide = true;\n html += '';\n}\nhtml += '
';\nreturn html;", "applyDefaultMarkdownStyle": false, "markdownCss": "" }, @@ -1214,7 +1214,7 @@ "typeFullFqn": "system.home_page_widgets.getting_started" }, "8c5207d8-7103-4dc2-db48-d61c0c8c61fd": { - "typeFullFqn": "system.home_page_widgets.home_mobile_app_qr_code", + "typeFullFqn": "system.home_page_widgets.home_page_mobile_app_qr_code", "type": "static", "sizeX": 6, "sizeY": 3, @@ -1320,7 +1320,9 @@ "sizeX": 35, "sizeY": 58, "row": 0, - "col": 85 + "col": 85, + "mobileHide": false, + "mobileHeight": 10 } }, "gridSettings": { @@ -1401,7 +1403,8 @@ "sizeX": 120, "sizeY": 45, "row": 0, - "col": 0 + "col": 0, + "mobileHide": true }, "8c5207d8-7103-4dc2-db48-d61c0c8c61fd": { "sizeX": 120, @@ -1558,4 +1561,4 @@ } }, "name": "Tenant Administrator Home Page" -} \ No newline at end of file +} From 14ebfe7975e5e8211240d3c9b08ee82fa862a905 Mon Sep 17 00:00:00 2001 From: rusikv Date: Thu, 9 May 2024 18:25:24 +0300 Subject: [PATCH 40/84] UI: mobile app qrcode widgets update --- .../system/widget_bundles/home_page_widgets.json | 2 +- .../widget_types/home_mobile_app_qr_code.json | 16 +++++++++++----- .../system/widget_types/mobile_app_qr_code.json | 9 +++++++-- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/application/src/main/data/json/system/widget_bundles/home_page_widgets.json b/application/src/main/data/json/system/widget_bundles/home_page_widgets.json index 0af30e4df2..cb8d0ed915 100644 --- a/application/src/main/data/json/system/widget_bundles/home_page_widgets.json +++ b/application/src/main/data/json/system/widget_bundles/home_page_widgets.json @@ -14,6 +14,6 @@ "home_page_widgets.documentation_links", "home_page_widgets.dashboards", "home_page_widgets.usage_info", - "home_page_widgets.home_mobile_app_qr_code" + "home_page_widgets.home_page_mobile_app_qr_code" ] } \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_types/home_mobile_app_qr_code.json b/application/src/main/data/json/system/widget_types/home_mobile_app_qr_code.json index 88109fc902..0e88188e55 100644 --- a/application/src/main/data/json/system/widget_types/home_mobile_app_qr_code.json +++ b/application/src/main/data/json/system/widget_types/home_mobile_app_qr_code.json @@ -1,9 +1,9 @@ { - "fqn": "home_page_widgets.home_mobile_app_qr_code", - "name": "Mobile app QR code", + "fqn": "home_page_widgets.home_page_mobile_app_qr_code", + "name": "Home page mobile app QR code", "deprecated": false, "image": null, - "description": null, + "description": "Enables users to open the ThingsBoard mobile app by scanning a QR code from Home page.", "descriptor": { "type": "static", "sizeX": 6, @@ -15,7 +15,13 @@ "settingsSchema": "", "dataKeySettingsSchema": "", "settingsDirective": "", - "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\":\"Mobile app QR code\",\"dropShadow\":true}" + "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\":\"Home page mobile app QR code\",\"dropShadow\":false,\"enableFullscreen\":false,\"widgetStyle\":{},\"widgetCss\":\"\",\"pageSize\":1024,\"noDataDisplayMessage\":\"\",\"showTitleIcon\":false,\"titleTooltip\":\"\",\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}" }, - "tags": null + "tags": [ + "ios", + "android", + "qr code", + "mobile application", + "home page" + ] } \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_types/mobile_app_qr_code.json b/application/src/main/data/json/system/widget_types/mobile_app_qr_code.json index 82ce9025ed..d84c83bfb6 100644 --- a/application/src/main/data/json/system/widget_types/mobile_app_qr_code.json +++ b/application/src/main/data/json/system/widget_types/mobile_app_qr_code.json @@ -3,7 +3,7 @@ "name": "Mobile app QR code", "deprecated": false, "image": null, - "description": null, + "description": "Enables users to open the ThingsBoard mobile app by scanning the QR code from dashboard.", "descriptor": { "type": "static", "sizeX": 7.5, @@ -17,5 +17,10 @@ "settingsDirective": "tb-mobile-app-qr-code-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\":{\"useDefaultApp\":true,\"androidConfig\":{\"enabled\":true,\"appPackage\":\"\",\"sha256CertFingerprints\":\"\"},\"iosConfig\":{\"enabled\":true,\"appId\":\"\"},\"qrCodeConfig\":{\"badgeEnabled\":true,\"badgeStyle\":\"ORIGINAL\",\"badgePosition\":\"RIGHT\",\"qrCodeLabelEnabled\":true,\"qrCodeLabel\":\"Scan to connect or download mobile app\"}},\"title\":\"Mobile app QR code\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"widgetCss\":\"\",\"pageSize\":1024,\"noDataDisplayMessage\":\"\",\"showTitleIcon\":false,\"titleTooltip\":\"\",\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}" }, - "tags": null + "tags": [ + "ios", + "android", + "qr code", + "mobile application" + ] } \ No newline at end of file From 21872468e2548674f99f43f276f291fccaf1e871 Mon Sep 17 00:00:00 2001 From: rusikv Date: Fri, 10 May 2024 11:08:57 +0300 Subject: [PATCH 41/84] UI: added preview images for mobile app qr code widgets --- .../widget_types/home_mobile_app_qr_code.json | 2 +- .../widget_types/mobile_app_qr_code.json | 2 +- .../lib/mobile-app-qrcode-widget.component.ts | 19 +++++++++---------- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/application/src/main/data/json/system/widget_types/home_mobile_app_qr_code.json b/application/src/main/data/json/system/widget_types/home_mobile_app_qr_code.json index 0e88188e55..e8e21f5d89 100644 --- a/application/src/main/data/json/system/widget_types/home_mobile_app_qr_code.json +++ b/application/src/main/data/json/system/widget_types/home_mobile_app_qr_code.json @@ -2,7 +2,7 @@ "fqn": "home_page_widgets.home_page_mobile_app_qr_code", "name": "Home page mobile app QR code", "deprecated": false, - "image": null, + "image": "tb-image:bW9iaWxlX2FwcF9xcl9jb2RlX3dpZGdldC5zdmc=:bW9iaWxlX2FwcF9xcl9jb2RlX3dpZGdldC5zdmc=;data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMDAiIGhlaWdodD0iMTYwIiBmaWxsPSJub25lIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE2MCIgZmlsbD0iI2ZmZiIgcng9IjQiLz48cmVjdCB3aWR0aD0iMTk5IiBoZWlnaHQ9IjE1OSIgeD0iLjUiIHk9Ii41IiBzdHJva2U9IiMwMDAiIHN0cm9rZS1vcGFjaXR5PSIuMSIgcng9IjMuNSIvPjxnIGNsaXAtcGF0aD0idXJsKCNhKSI+PHJlY3Qgd2lkdGg9IjEzNyIgaGVpZ2h0PSIxMzciIHg9IjMxLjUiIHk9IjExLjUiIGZpbGw9IiNmZmYiIHJ4PSI4LjIiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTY4LjUgMTEuNWgtMTM3djEzN2gxMzd2LTEzN1oiLz48bWFzayBpZD0iYiIgd2lkdGg9IjEyNCIgaGVpZ2h0PSIxMjQiIHg9IjM4IiB5PSIxOCIgbWFza1VuaXRzPSJ1c2VyU3BhY2VPblVzZSIgc3R5bGU9Im1hc2stdHlwZTpsdW1pbmFuY2UiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik00Mi43IDUyLjRoLTQuM3Y0LjJoNC4zdi00LjJaTTQyLjcgNjAuOWgtNC4zVjY1aDQuM1Y2MVpNNDIuNyA2NS4xaC00LjN2NC4zaDQuM1Y2NVpNNDIuNyA5NC45aC00LjNWOTloNC4zVjk1Wk00NyA1Ni42aC00LjNWNjFoNC4ydi00LjNaTTQ3IDgyLjFoLTQuM3Y0LjNoNC4yVjgyWk00NyA4Ni40aC00LjN2NC4yaDQuMnYtNC4yWk00NyA5NC45aC00LjNWOTloNC4yVjk1Wk00NyAxMDMuNGgtNC4zdjQuMmg0LjJ2LTQuMlpNNTEuMiA1Mi40aC00LjN2NC4yaDQuM3YtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik01MS4yIDYwLjloLTQuM1Y2NWg0LjNWNjFaTTUxLjIgNzMuNmgtNC4zVjc4aDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik01MS4yIDc3LjloLTQuM1Y4Mmg0LjNWNzhaTTUxLjIgODIuMWgtNC4zdjQuM2g0LjNWODJaTTUxLjIgOTAuNmgtNC4zVjk1aDQuM3YtNC4zWk01MS4yIDk5LjFoLTQuM3Y0LjNoNC4zVjk5Wk01NS40IDUyLjRoLTQuMnY0LjJoNC4ydi00LjJaTTU1LjQgNjkuNGgtNC4ydjQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNNTUuNCA4Ni40aC00LjJ2NC4yaDQuMnYtNC4yWk01NS40IDk5LjFoLTQuMnY0LjNoNC4yVjk5Wk01NS40IDEwMy40aC00LjJ2NC4yaDQuMnYtNC4yWk01OS43IDY1LjFoLTQuM3Y0LjNoNC4zVjY1Wk01OS43IDY5LjRoLTQuM3Y0LjJoNC4zdi00LjJaTTU5LjcgODYuNGgtNC4zdjQuMmg0LjN2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNNTkuNyA5MC42aC00LjNWOTVoNC4zdi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTU5LjcgOTQuOWgtNC4zVjk5aDQuM1Y5NVpNNjMuOSA1Mi40aC00LjJ2NC4yaDQuMnYtNC4yWk02My45IDY1LjFoLTQuMnY0LjNoNC4yVjY1Wk02My45IDY5LjRoLTQuMnY0LjJoNC4ydi00LjJaTTYzLjkgODYuNGgtNC4ydjQuMmg0LjJ2LTQuMlpNNjMuOSA5OS4xaC00LjJ2NC4zaDQuMlY5OVpNNjguMSA1Mi40SDY0djQuMmg0LjJ2LTQuMlpNNjguMSA2MC45SDY0VjY1aDQuMlY2MVpNNjguMSA2OS40SDY0djQuMmg0LjJ2LTQuMlpNNjguMSA3Ny45SDY0VjgyaDQuMlY3OFpNNjguMSA4Ni40SDY0djQuMmg0LjJ2LTQuMlpNNjguMSA5NC45SDY0Vjk5aDQuMlY5NVpNNjguMSAxMDMuNEg2NHY0LjJoNC4ydi00LjJaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTcyLjQgNTIuNEg2OHY0LjJoNC4zdi00LjJaTTcyLjQgNjkuNEg2OHY0LjJoNC4zdi00LjJaTTcyLjQgNzcuOUg2OFY4Mmg0LjNWNzhaTTcyLjQgODIuMUg2OHY0LjNoNC4zVjgyWk03Mi40IDg2LjRINjh2NC4yaDQuM3YtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik03Mi40IDkwLjZINjhWOTVoNC4zdi00LjNaTTcyLjQgOTkuMUg2OHY0LjNoNC4zVjk5Wk03Mi40IDEwMy40SDY4djQuMmg0LjN2LTQuMlpNNzYuNiAyMi43aC00LjJ2NC4yaDQuMnYtNC4yWk03Ni42IDMxLjJoLTQuMnY0LjJoNC4ydi00LjJaTTc2LjYgMzkuN2gtNC4ydjQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNNzYuNiA0My45aC00LjJ2NC4yaDQuMlY0NFpNNzYuNiA0OC4xaC00LjJ2NC4zaDQuMnYtNC4zWk03Ni42IDUyLjRoLTQuMnY0LjJoNC4ydi00LjJaTTc2LjYgNTYuNmgtNC4yVjYxaDQuMnYtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik03Ni42IDYwLjloLTQuMlY2NWg0LjJWNjFaTTc2LjYgNjUuMWgtNC4ydjQuM2g0LjJWNjVaTTc2LjYgNjkuNGgtNC4ydjQuMmg0LjJ2LTQuMlpNNzYuNiA5NC45aC00LjJWOTloNC4yVjk1Wk03Ni42IDEwMy40aC00LjJ2NC4yaDQuMnYtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik03Ni42IDEwNy42aC00LjJ2NC4zaDQuMnYtNC4zWk03Ni42IDExMS45aC00LjJ2NC4yaDQuMnYtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik03Ni42IDExNi4xaC00LjJ2NC4yaDQuMnYtNC4yWk03Ni42IDEyNC42aC00LjJ2NC4yaDQuMnYtNC4yWk03Ni42IDEyOC44aC00LjJ2NC4zaDQuMnYtNC4zWk03Ni42IDEzNy4zaC00LjJ2NC4zaDQuMnYtNC4zWk04MC45IDE4LjRoLTQuM3Y0LjNIODF2LTQuM1pNODAuOSAyN2gtNC4zdjQuMkg4MXYtNC4zWk04MC45IDMxLjJoLTQuM3Y0LjJIODF2LTQuMlpNODAuOSAzNS40aC00LjN2NC4zSDgxdi00LjNaTTgwLjkgMzkuN2gtNC4zdjQuMkg4MXYtNC4yWk04MC45IDQ4LjFoLTQuM3Y0LjNIODF2LTQuM1pNODAuOSA2MC45aC00LjNWNjVIODFWNjFaTTgwLjkgNjUuMWgtNC4zdjQuM0g4MVY2NVpNODAuOSA3My42aC00LjNWNzhIODF2LTQuM1pNODAuOSA4Mi4xaC00LjN2NC4zSDgxVjgyWk04MC45IDk0LjloLTQuM1Y5OUg4MVY5NVpNODAuOSA5OS4xaC00LjN2NC4zSDgxVjk5Wk04MC45IDEwMy40aC00LjN2NC4ySDgxdi00LjJaTTgwLjkgMTExLjloLTQuM3Y0LjJIODF2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNODAuOSAxMTYuMWgtNC4zdjQuMkg4MXYtNC4yWk04MC45IDEyNC42aC00LjN2NC4ySDgxdi00LjJaTTgwLjkgMTM3LjNoLTQuM3Y0LjNIODF2LTQuM1pNODUuMSAxOC40SDgxdjQuM0g4NXYtNC4zWk04NS4xIDMxLjJIODF2NC4ySDg1di00LjJaTTg1LjEgMzUuNEg4MXY0LjNIODV2LTQuM1pNODUuMSA0My45SDgxdjQuMkg4NVY0NFpNODUuMSA1Ni42SDgxVjYxSDg1di00LjNaTTg1LjEgNzMuNkg4MVY3OEg4NXYtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik04NS4xIDc3LjlIODFWODJIODVWNzhaTTg1LjEgODIuMUg4MXY0LjNIODVWODJaTTg1LjEgODYuNEg4MXY0LjJIODV2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNODUuMSA5MC42SDgxVjk1SDg1di00LjNaTTg1LjEgOTkuMUg4MXY0LjNIODVWOTlaTTg1LjEgMTExLjlIODF2NC4ySDg1di00LjJaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTg1LjEgMTE2LjFIODF2NC4ySDg1di00LjJaTTg1LjEgMTMzSDgxdjQuM0g4NXYtNC4yWk04OS40IDIyLjdIODV2NC4yaDQuM3YtNC4yWk04OS40IDMxLjJIODV2NC4yaDQuM3YtNC4yWk04OS40IDM1LjRIODV2NC4zaDQuM3YtNC4zWk04OS40IDQ4LjFIODV2NC4zaDQuM3YtNC4zWk04OS40IDUyLjRIODV2NC4yaDQuM3YtNC4yWk04OS40IDU2LjZIODVWNjFoNC4zdi00LjNaTTg5LjQgNjUuMUg4NXY0LjNoNC4zVjY1Wk04OS40IDEwMy40SDg1djQuMmg0LjN2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNODkuNCAxMDcuNkg4NXY0LjNoNC4zdi00LjNaTTg5LjQgMTExLjlIODV2NC4yaDQuM3YtNC4yWk04OS40IDEyOC44SDg1djQuM2g0LjN2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNODkuNCAxMzNIODV2NC4zaDQuM3YtNC4yWk05My42IDE4LjRoLTQuMnY0LjNoNC4ydi00LjNaTTkzLjYgMjIuN2gtNC4ydjQuMmg0LjJ2LTQuMlpNOTMuNiAyN2gtNC4ydjQuMmg0LjJ2LTQuM1pNOTMuNiAzMS4yaC00LjJ2NC4yaDQuMnYtNC4yWk05My42IDQzLjloLTQuMnY0LjJoNC4yVjQ0Wk05My42IDU2LjZoLTQuMlY2MWg0LjJ2LTQuM1pNOTMuNiA2NS4xaC00LjJ2NC4zaDQuMlY2NVpNOTMuNiA2OS40aC00LjJ2NC4yaDQuMnYtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik05My42IDczLjZoLTQuMlY3OGg0LjJ2LTQuM1pNOTMuNiA4Mi4xaC00LjJ2NC4zaDQuMlY4MlpNOTMuNiA4Ni40aC00LjJ2NC4yaDQuMnYtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik05My42IDkwLjZoLTQuMlY5NWg0LjJ2LTQuM1pNOTMuNiAxMTEuOWgtNC4ydjQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNOTMuNiAxMTYuMWgtNC4ydjQuMmg0LjJ2LTQuMlpNOTcuOSAxOC40aC00LjN2NC4zSDk4di00LjNaTTk3LjkgMjIuN2gtNC4zdjQuMkg5OHYtNC4yWk05Ny45IDM1LjRoLTQuM3Y0LjNIOTh2LTQuM1pNOTcuOSA0OC4xaC00LjN2NC4zSDk4di00LjNaTTk3LjkgNTIuNGgtNC4zdjQuMkg5OHYtNC4yWk05Ny45IDYwLjloLTQuM1Y2NUg5OFY2MVpNOTcuOSA2NS4xaC00LjN2NC4zSDk4VjY1Wk05Ny45IDczLjZoLTQuM1Y3OEg5OHYtNC4zWk05Ny45IDgyLjFoLTQuM3Y0LjNIOThWODJaTTk3LjkgODYuNGgtNC4zdjQuMkg5OHYtNC4yWk05Ny45IDk0LjloLTQuM1Y5OUg5OFY5NVpNOTcuOSA5OS4xaC00LjN2NC4zSDk4Vjk5Wk05Ny45IDEwMy40aC00LjN2NC4ySDk4di00LjJaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTk3LjkgMTA3LjZoLTQuM3Y0LjNIOTh2LTQuM1pNOTcuOSAxMTYuMWgtNC4zdjQuMkg5OHYtNC4yWk05Ny45IDEyNC42aC00LjN2NC4ySDk4di00LjJaTTk3LjkgMTMzaC00LjN2NC4zSDk4di00LjJaTTEwMi4xIDIyLjdIOTh2NC4yaDQuMnYtNC4yWk0xMDIuMSA0My45SDk4djQuMmg0LjJWNDRaTTEwMi4xIDUyLjRIOTh2NC4yaDQuMnYtNC4yWk0xMDIuMSA2OS40SDk4djQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTAyLjEgNzMuNkg5OFY3OGg0LjJ2LTQuM1pNMTAyLjEgOTAuNkg5OFY5NWg0LjJ2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTAyLjEgOTQuOUg5OFY5OWg0LjJWOTVaTTEwMi4xIDEwMy40SDk4djQuMmg0LjJ2LTQuMlpNMTAyLjEgMTMzSDk4djQuM2g0LjJ2LTQuMlpNMTAyLjEgMTM3LjNIOTh2NC4zaDQuMnYtNC4zWk0xMDYuNCAxOC40SDEwMnY0LjNoNC4zdi00LjNaTTEwNi40IDIyLjdIMTAydjQuMmg0LjN2LTQuMlpNMTA2LjQgNDguMUgxMDJ2NC4zaDQuM3YtNC4zWk0xMDYuNCA2MC45SDEwMlY2NWg0LjNWNjFaTTEwNi40IDczLjZIMTAyVjc4aDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMDYuNCA3Ny45SDEwMlY4Mmg0LjNWNzhaTTEwNi40IDgyLjFIMTAydjQuM2g0LjNWODJaTTEwNi40IDg2LjRIMTAydjQuMmg0LjN2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTA2LjQgOTAuNkgxMDJWOTVoNC4zdi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTEwNi40IDk0LjlIMTAyVjk5aDQuM1Y5NVpNMTA2LjQgMTAzLjRIMTAydjQuMmg0LjN2LTQuMlpNMTA2LjQgMTE2LjFIMTAydjQuMmg0LjN2LTQuMlpNMTA2LjQgMTMzSDEwMnY0LjNoNC4zdi00LjJaTTEwNi40IDEzNy4zSDEwMnY0LjNoNC4zdi00LjNaTTExMC42IDI3aC00LjJ2NC4yaDQuMnYtNC4zWk0xMTAuNiAzOS43aC00LjJ2NC4yaDQuMnYtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMTAuNiA0My45aC00LjJ2NC4yaDQuMlY0NFpNMTEwLjYgNDguMWgtNC4ydjQuM2g0LjJ2LTQuM1pNMTEwLjYgNTIuNGgtNC4ydjQuMmg0LjJ2LTQuMlpNMTEwLjYgNTYuNmgtNC4yVjYxaDQuMnYtNC4zWk0xMTAuNiA5MC42aC00LjJWOTVoNC4ydi00LjNaTTExMC42IDk5LjFoLTQuMnY0LjNoNC4yVjk5Wk0xMTAuNiAxMDcuNmgtNC4ydjQuM2g0LjJ2LTQuM1pNMTEwLjYgMTE2LjFoLTQuMnY0LjJoNC4ydi00LjJaTTExMC42IDEyMC4zaC00LjJ2NC4zaDQuMnYtNC4zWk0xMTAuNiAxMzNoLTQuMnY0LjNoNC4ydi00LjJaTTExMC42IDEzNy4zaC00LjJ2NC4zaDQuMnYtNC4zWk0xMTQuOSAzMS4yaC00LjN2NC4yaDQuM3YtNC4yWk0xMTQuOSA0OC4xaC00LjN2NC4zaDQuM3YtNC4zWk0xMTQuOSA1Mi40aC00LjN2NC4yaDQuM3YtNC4yWk0xMTQuOSA2NS4xaC00LjN2NC4zaDQuM1Y2NVpNMTE0LjkgNzMuNmgtNC4zVjc4aDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMTQuOSA3Ny45aC00LjNWODJoNC4zVjc4Wk0xMTQuOSA4Mi4xaC00LjN2NC4zaDQuM1Y4MlpNMTE0LjkgODYuNGgtNC4zdjQuMmg0LjN2LTQuMlpNMTE0LjkgOTkuMWgtNC4zdjQuM2g0LjNWOTlaTTExNC45IDExMS45aC00LjN2NC4yaDQuM3YtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMTQuOSAxMTYuMWgtNC4zdjQuMmg0LjN2LTQuMlpNMTE0LjkgMTMzaC00LjN2NC4zaDQuM3YtNC4yWk0xMTQuOSAxMzcuM2gtNC4zdjQuM2g0LjN2LTQuM1pNMTE5LjEgMTguNEgxMTV2NC4zaDQuMnYtNC4zWk0xMTkuMSAzNS40SDExNXY0LjNoNC4ydi00LjNaTTExOS4xIDM5LjdIMTE1djQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTE5LjEgNDMuOUgxMTV2NC4yaDQuMlY0NFpNMTE5LjEgNjUuMUgxMTV2NC4zaDQuMlY2NVpNMTE5LjEgNjkuNEgxMTV2NC4yaDQuMnYtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMTkuMSA3My42SDExNVY3OGg0LjJ2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTE5LjEgNzcuOUgxMTVWODJoNC4yVjc4Wk0xMTkuMSA4Mi4xSDExNXY0LjNoNC4yVjgyWk0xMTkuMSA4Ni40SDExNXY0LjJoNC4ydi00LjJaTTExOS4xIDk0LjlIMTE1Vjk5aDQuMlY5NVpNMTE5LjEgMTAzLjRIMTE1djQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTE5LjEgMTA3LjZIMTE1djQuM2g0LjJ2LTQuM1pNMTE5LjEgMTE2LjFIMTE1djQuMmg0LjJ2LTQuMlpNMTE5LjEgMTI0LjZIMTE1djQuMmg0LjJ2LTQuMlpNMTE5LjEgMTMzSDExNXY0LjNoNC4ydi00LjJaTTExOS4xIDEzNy4zSDExNXY0LjNoNC4ydi00LjNaTTEyMy40IDMxLjJIMTE5djQuMmg0LjN2LTQuMlpNMTIzLjQgMzUuNEgxMTl2NC4zaDQuM3YtNC4zWk0xMjMuNCA1Ni42SDExOVY2MWg0LjN2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTIzLjQgNjAuOUgxMTlWNjVoNC4zVjYxWk0xMjMuNCA5MC42SDExOVY5NWg0LjN2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTIzLjQgOTQuOUgxMTlWOTloNC4zVjk1Wk0xMjMuNCA5OS4xSDExOXY0LjNoNC4zVjk5Wk0xMjMuNCAxMjAuM0gxMTl2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMjMuNCAxMjQuNkgxMTl2NC4yaDQuM3YtNC4yWk0xMjcuNiAxOC40aC00LjJ2NC4zaDQuMnYtNC4zWk0xMjcuNiAyN2gtNC4ydjQuMmg0LjJ2LTQuM1pNMTI3LjYgMzEuMmgtNC4ydjQuMmg0LjJ2LTQuMlpNMTI3LjYgMzkuN2gtNC4ydjQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTI3LjYgNDMuOWgtNC4ydjQuMmg0LjJWNDRaTTEyNy42IDQ4LjFoLTQuMnY0LjNoNC4ydi00LjNaTTEyNy42IDU2LjZoLTQuMlY2MWg0LjJ2LTQuM1pNMTI3LjYgNjUuMWgtNC4ydjQuM2g0LjJWNjVaTTEyNy42IDY5LjRoLTQuMnY0LjJoNC4ydi00LjJaTTEyNy42IDc3LjloLTQuMlY4Mmg0LjJWNzhaTTEyNy42IDgyLjFoLTQuMnY0LjNoNC4yVjgyWk0xMjcuNiA5OS4xaC00LjJ2NC4zaDQuMlY5OVpNMTI3LjYgMTAzLjRoLTQuMnY0LjJoNC4ydi00LjJaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTEyNy42IDEwNy42aC00LjJ2NC4zaDQuMnYtNC4zWk0xMjcuNiAxMTEuOWgtNC4ydjQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTI3LjYgMTE2LjFoLTQuMnY0LjJoNC4ydi00LjJaTTEyNy42IDEyMC4zaC00LjJ2NC4zaDQuMnYtNC4zWk0xMjcuNiAxMjguOGgtNC4ydjQuM2g0LjJ2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTI3LjYgMTMzaC00LjJ2NC4zaDQuMnYtNC4yWk0xMzEuOSA1Mi40aC00LjN2NC4yaDQuM3YtNC4yWk0xMzEuOSA2NS4xaC00LjN2NC4zaDQuM1Y2NVpNMTMxLjkgNzcuOWgtNC4zVjgyaDQuM1Y3OFpNMTMxLjkgODYuNGgtNC4zdjQuMmg0LjN2LTQuMlpNMTMxLjkgOTQuOWgtNC4zVjk5aDQuM1Y5NVpNMTMxLjkgMTAzLjRoLTQuM3Y0LjJoNC4zdi00LjJaTTEzMS45IDEyMC4zaC00LjN2NC4zaDQuM3YtNC4zWk0xMzEuOSAxMzNoLTQuM3Y0LjNoNC4zdi00LjJaTTEzMS45IDEzNy4zaC00LjN2NC4zaDQuM3YtNC4zWk0xMzYuMSA1Mi40aC00LjJ2NC4yaDQuMnYtNC4yWk0xMzYuMSA1Ni42aC00LjJWNjFoNC4ydi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTEzNi4xIDYwLjloLTQuMlY2NWg0LjJWNjFaTTEzNi4xIDY1LjFoLTQuMnY0LjNoNC4yVjY1Wk0xMzYuMSA2OS40aC00LjJ2NC4yaDQuMnYtNC4yWk0xMzYuMSA5NC45aC00LjJWOTloNC4yVjk1Wk0xMzYuMSAxMDMuNGgtNC4ydjQuMmg0LjJ2LTQuMlpNMTM2LjEgMTExLjloLTQuMnY0LjJoNC4ydi00LjJaTTEzNi4xIDEyMC4zaC00LjJ2NC4zaDQuMnYtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMzYuMSAxMjQuNmgtNC4ydjQuMmg0LjJ2LTQuMlpNMTM2LjEgMTI4LjhoLTQuMnY0LjNoNC4ydi00LjNaTTE0MC4zIDUyLjRoLTQuMnY0LjJoNC4ydi00LjJaTTE0MC4zIDU2LjZoLTQuMlY2MWg0LjJ2LTQuM1pNMTQwLjMgNjUuMWgtNC4ydjQuM2g0LjJWNjVaTTE0MC4zIDczLjZoLTQuMlY3OGg0LjJ2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTQwLjMgNzcuOWgtNC4yVjgyaDQuMlY3OFpNMTQwLjMgODYuNGgtNC4ydjQuMmg0LjJ2LTQuMlpNMTQwLjMgOTQuOWgtNC4yVjk5aDQuMlY5NVpNMTQwLjMgMTAzLjRoLTQuMnY0LjJoNC4ydi00LjJaTTE0MC4zIDEyMC4zaC00LjJ2NC4zaDQuMnYtNC4zWk0xNDAuMyAxMjguOGgtNC4ydjQuM2g0LjJ2LTQuM1pNMTQ0LjYgNjAuOWgtNC4zVjY1aDQuM1Y2MVoiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTQ0LjYgODYuNGgtNC4zdjQuMmg0LjN2LTQuMlpNMTQ0LjYgMTAzLjRoLTQuM3Y0LjJoNC4zdi00LjJaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE0NC42IDEwNy42aC00LjN2NC4zaDQuM3YtNC4zWk0xNDQuNiAxMTEuOWgtNC4zdjQuMmg0LjN2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTQ0LjYgMTE2LjFoLTQuM3Y0LjJoNC4zdi00LjJaTTE0NC42IDEyMC4zaC00LjN2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xNDQuNiAxMjQuNmgtNC4zdjQuMmg0LjN2LTQuMlpNMTQ4LjggNTIuNGgtNC4ydjQuMmg0LjJ2LTQuMlpNMTQ4LjggNjAuOWgtNC4yVjY1aDQuMlY2MVpNMTQ4LjggNzMuNmgtNC4yVjc4aDQuMnYtNC4zWk0xNDguOCA4Mi4xaC00LjJ2NC4zaDQuMlY4MlpNMTQ4LjggODYuNGgtNC4ydjQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTQ4LjggOTAuNmgtNC4yVjk1aDQuMnYtNC4zWk0xNDguOCA5OS4xaC00LjJ2NC4zaDQuMlY5OVpNMTQ4LjggMTAzLjRoLTQuMnY0LjJoNC4ydi00LjJaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE0OC44IDEwNy42aC00LjJ2NC4zaDQuMnYtNC4zWk0xNDguOCAxMTEuOWgtNC4ydjQuMmg0LjJ2LTQuMlpNMTQ4LjggMTIwLjNoLTQuMnY0LjNoNC4ydi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE0OC44IDEyNC42aC00LjJ2NC4yaDQuMnYtNC4yWk0xNDguOCAxMjguOGgtNC4ydjQuM2g0LjJ2LTQuM1pNMTQ4LjggMTM3LjNoLTQuMnY0LjNoNC4ydi00LjNaTTE1MyA1Ni42aC00LjJWNjFoNC4zdi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE1MyA2MC45aC00LjJWNjVoNC4zVjYxWk0xNTMgNjUuMWgtNC4ydjQuM2g0LjNWNjVaTTE1MyA5NC45aC00LjJWOTloNC4zVjk1Wk0xNTMgOTkuMWgtNC4ydjQuM2g0LjNWOTlaTTE1MyAxMDMuNGgtNC4ydjQuMmg0LjN2LTQuMlpNMTUzIDEyMC4zaC00LjJ2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xNTMgMTI0LjZoLTQuMnY0LjJoNC4zdi00LjJaTTE1MyAxMzcuM2gtNC4ydjQuM2g0LjN2LTQuM1pNMTU3LjMgNTIuNGgtNC4ydjQuMmg0LjJ2LTQuMlpNMTU3LjMgNTYuNmgtNC4yVjYxaDQuMnYtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xNTcuMyA2MC45aC00LjJWNjVoNC4yVjYxWk0xNTcuMyA2NS4xaC00LjJ2NC4zaDQuMlY2NVpNMTU3LjMgOTAuNmgtNC4yVjk1aDQuMnYtNC4zWk0xNTcuMyAxMTYuMWgtNC4ydjQuMmg0LjJ2LTQuMlpNMTU3LjMgMTI0LjZoLTQuMnY0LjJoNC4ydi00LjJaTTE1Ny4zIDEzM2gtNC4ydjQuM2g0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTYxLjYgNTYuNmgtNC4zVjYxaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xNjEuNiA2MC45aC00LjNWNjVoNC4zVjYxWk0xNjEuNiA2NS4xaC00LjN2NC4zaDQuM1Y2NVpNMTYxLjYgNzMuNmgtNC4zVjc4aDQuM3YtNC4zWk0xNjEuNiA5MC42aC00LjNWOTVoNC4zdi00LjNaTTE2MS42IDEwMy40aC00LjN2NC4yaDQuM3YtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xNjEuNiAxMDcuNmgtNC4zdjQuM2g0LjN2LTQuM1pNMTYxLjYgMTExLjloLTQuM3Y0LjJoNC4zdi00LjJaTTE2MS42IDEyMC4zaC00LjN2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xNjEuNiAxMjQuNmgtNC4zdjQuMmg0LjN2LTQuMlpNMTYxLjYgMTMzaC00LjN2NC4zaDQuM3YtNC4yWiIvPjwvbWFzaz48ZyBtYXNrPSJ1cmwoI2IpIj48cGF0aCBmaWxsPSIjMDAwIiBkPSJNMTYxLjYgMTguNEgzOC40djEyMy4yaDEyMy4yVjE4LjRaIi8+PC9nPjxtYXNrIGlkPSJjIiB3aWR0aD0iMzEiIGhlaWdodD0iMzEiIHg9IjM4IiB5PSIxOCIgbWFza1VuaXRzPSJ1c2VyU3BhY2VPblVzZSIgc3R5bGU9Im1hc2stdHlwZTpsdW1pbmFuY2UiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik00Mi43IDE4LjRoLTQuM3Y0LjNoNC4zdi00LjNaTTQyLjcgMjIuN2gtNC4zdjQuMmg0LjN2LTQuMlpNNDIuNyAyN2gtNC4zdjQuMmg0LjN2LTQuM1pNNDIuNyAzMS4yaC00LjN2NC4yaDQuM3YtNC4yWk00Mi43IDM1LjRoLTQuM3Y0LjNoNC4zdi00LjNaTTQyLjcgMzkuN2gtNC4zdjQuMmg0LjN2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNNDIuNyA0My45aC00LjN2NC4yaDQuM1Y0NFpNNDcgMTguNGgtNC4zdjQuM2g0LjJ2LTQuM1pNNDcgNDMuOWgtNC4zdjQuMmg0LjJWNDRaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTUxLjIgMTguNGgtNC4zdjQuM2g0LjN2LTQuM1pNNTEuMiA0My45aC00LjN2NC4yaDQuM1Y0NFoiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNNTUuNCAxOC40aC00LjJ2NC4zaDQuMnYtNC4zWk01NS40IDQzLjloLTQuMnY0LjJoNC4yVjQ0Wk01OS43IDE4LjRoLTQuM3Y0LjNoNC4zdi00LjNaTTU5LjcgNDMuOWgtNC4zdjQuMmg0LjNWNDRaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTYzLjkgMTguNGgtNC4ydjQuM2g0LjJ2LTQuM1pNNjMuOSA0My45aC00LjJ2NC4yaDQuMlY0NFpNNjguMSAxOC40SDY0djQuM2g0LjJ2LTQuM1pNNjguMSAyMi43SDY0djQuMmg0LjJ2LTQuMlpNNjguMSAyN0g2NHY0LjJoNC4ydi00LjNaTTY4LjEgMzEuMkg2NHY0LjJoNC4ydi00LjJaTTY4LjEgMzUuNEg2NHY0LjNoNC4ydi00LjNaTTY4LjEgMzkuN0g2NHY0LjJoNC4ydi00LjJaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTY4LjEgNDMuOUg2NHY0LjJoNC4yVjQ0WiIvPjwvbWFzaz48ZyBtYXNrPSJ1cmwoI2MpIj48cGF0aCBmaWxsPSIjMDAwIiBkPSJNNjguMSAxOC40SDM4LjR2MjkuN2gyOS43VjE4LjRaIi8+PC9nPjxtYXNrIGlkPSJkIiB3aWR0aD0iMTQiIGhlaWdodD0iMTQiIHg9IjQ2IiB5PSIyNiIgbWFza1VuaXRzPSJ1c2VyU3BhY2VPblVzZSIgc3R5bGU9Im1hc2stdHlwZTpsdW1pbmFuY2UiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik01MS4yIDI3aC00LjN2NC4yaDQuM3YtNC4zWk01MS4yIDMxLjJoLTQuM3Y0LjJoNC4zdi00LjJaTTUxLjIgMzUuNGgtNC4zdjQuM2g0LjN2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNNTUuNCAyN2gtNC4ydjQuMmg0LjJ2LTQuM1pNNTUuNCAzMS4yaC00LjJ2NC4yaDQuMnYtNC4yWk01NS40IDM1LjRoLTQuMnY0LjNoNC4ydi00LjNaTTU5LjcgMjdoLTQuM3Y0LjJoNC4zdi00LjNaTTU5LjcgMzEuMmgtNC4zdjQuMmg0LjN2LTQuMlpNNTkuNyAzNS40aC00LjN2NC4zaDQuM3YtNC4zWiIvPjwvbWFzaz48ZyBtYXNrPSJ1cmwoI2QpIj48cGF0aCBmaWxsPSIjMDAwIiBkPSJNNTkuNyAyN0g0Ni45djEyLjdoMTIuOFYyNi45WiIvPjwvZz48bWFzayBpZD0iZSIgd2lkdGg9IjMxIiBoZWlnaHQ9IjMxIiB4PSIxMzEiIHk9IjE4IiBtYXNrVW5pdHM9InVzZXJTcGFjZU9uVXNlIiBzdHlsZT0ibWFzay10eXBlOmx1bWluYW5jZSI+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTEzNi4xIDE4LjRoLTQuMnY0LjNoNC4ydi00LjNaTTEzNi4xIDIyLjdoLTQuMnY0LjJoNC4ydi00LjJaTTEzNi4xIDI3aC00LjJ2NC4yaDQuMnYtNC4zWk0xMzYuMSAzMS4yaC00LjJ2NC4yaDQuMnYtNC4yWk0xMzYuMSAzNS40aC00LjJ2NC4zaDQuMnYtNC4zWk0xMzYuMSAzOS43aC00LjJ2NC4yaDQuMnYtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMzYuMSA0My45aC00LjJ2NC4yaDQuMlY0NFpNMTQwLjMgMTguNGgtNC4ydjQuM2g0LjJ2LTQuM1pNMTQwLjMgNDMuOWgtNC4ydjQuMmg0LjJWNDRaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE0NC42IDE4LjRoLTQuM3Y0LjNoNC4zdi00LjNaTTE0NC42IDQzLjloLTQuM3Y0LjJoNC4zVjQ0Wk0xNDguOCAxOC40aC00LjJ2NC4zaDQuMnYtNC4zWk0xNDguOCA0My45aC00LjJ2NC4yaDQuMlY0NFpNMTUzIDE4LjRoLTQuMnY0LjNoNC4zdi00LjNaTTE1MyA0My45aC00LjJ2NC4yaDQuM1Y0NFpNMTU3LjMgMTguNGgtNC4ydjQuM2g0LjJ2LTQuM1pNMTU3LjMgNDMuOWgtNC4ydjQuMmg0LjJWNDRaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE2MS42IDE4LjRoLTQuM3Y0LjNoNC4zdi00LjNaTTE2MS42IDIyLjdoLTQuM3Y0LjJoNC4zdi00LjJaTTE2MS42IDI3aC00LjN2NC4yaDQuM3YtNC4zWk0xNjEuNiAzMS4yaC00LjN2NC4yaDQuM3YtNC4yWk0xNjEuNiAzNS40aC00LjN2NC4zaDQuM3YtNC4zWk0xNjEuNiAzOS43aC00LjN2NC4yaDQuM3YtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xNjEuNiA0My45aC00LjN2NC4yaDQuM1Y0NFoiLz48L21hc2s+PGcgbWFzaz0idXJsKCNlKSI+PHBhdGggZmlsbD0iIzAwMCIgZD0iTTE2MS42IDE4LjRoLTI5Ljd2MjkuN2gyOS43VjE4LjRaIi8+PC9nPjxtYXNrIGlkPSJmIiB3aWR0aD0iMTQiIGhlaWdodD0iMTQiIHg9IjE0MCIgeT0iMjYiIG1hc2tVbml0cz0idXNlclNwYWNlT25Vc2UiIHN0eWxlPSJtYXNrLXR5cGU6bHVtaW5hbmNlIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTQ0LjYgMjdoLTQuM3Y0LjJoNC4zdi00LjNaTTE0NC42IDMxLjJoLTQuM3Y0LjJoNC4zdi00LjJaTTE0NC42IDM1LjRoLTQuM3Y0LjNoNC4zdi00LjNaTTE0OC44IDI3aC00LjJ2NC4yaDQuMnYtNC4zWk0xNDguOCAzMS4yaC00LjJ2NC4yaDQuMnYtNC4yWk0xNDguOCAzNS40aC00LjJ2NC4zaDQuMnYtNC4zWk0xNTMgMjdoLTQuMnY0LjJoNC4zdi00LjNaTTE1MyAzMS4yaC00LjJ2NC4yaDQuM3YtNC4yWk0xNTMgMzUuNGgtNC4ydjQuM2g0LjN2LTQuM1oiLz48L21hc2s+PGcgbWFzaz0idXJsKCNmKSI+PHBhdGggZmlsbD0iIzAwMCIgZD0iTTE1MyAyN2gtMTIuN3YxMi43aDEyLjhWMjYuOVoiLz48L2c+PG1hc2sgaWQ9ImciIHdpZHRoPSIzMSIgaGVpZ2h0PSIzMSIgeD0iMzgiIHk9IjExMSIgbWFza1VuaXRzPSJ1c2VyU3BhY2VPblVzZSIgc3R5bGU9Im1hc2stdHlwZTpsdW1pbmFuY2UiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik00Mi43IDExMS45aC00LjN2NC4yaDQuM3YtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik00Mi43IDExNi4xaC00LjN2NC4yaDQuM3YtNC4yWk00Mi43IDEyMC4zaC00LjN2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik00Mi43IDEyNC42aC00LjN2NC4yaDQuM3YtNC4yWk00Mi43IDEyOC44aC00LjN2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik00Mi43IDEzM2gtNC4zdjQuM2g0LjN2LTQuMlpNNDIuNyAxMzcuM2gtNC4zdjQuM2g0LjN2LTQuM1pNNDcgMTExLjloLTQuM3Y0LjJoNC4ydi00LjJaTTQ3IDEzNy4zaC00LjN2NC4zaDQuMnYtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik01MS4yIDExMS45aC00LjN2NC4yaDQuM3YtNC4yWk01MS4yIDEzNy4zaC00LjN2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik01NS40IDExMS45aC00LjJ2NC4yaDQuMnYtNC4yWk01NS40IDEzNy4zaC00LjJ2NC4zaDQuMnYtNC4zWk01OS43IDExMS45aC00LjN2NC4yaDQuM3YtNC4yWk01OS43IDEzNy4zaC00LjN2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik02My45IDExMS45aC00LjJ2NC4yaDQuMnYtNC4yWk02My45IDEzNy4zaC00LjJ2NC4zaDQuMnYtNC4zWk02OC4xIDExMS45SDY0djQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNNjguMSAxMTYuMUg2NHY0LjJoNC4ydi00LjJaTTY4LjEgMTIwLjNINjR2NC4zaDQuMnYtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik02OC4xIDEyNC42SDY0djQuMmg0LjJ2LTQuMlpNNjguMSAxMjguOEg2NHY0LjNoNC4ydi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTY4LjEgMTMzSDY0djQuM2g0LjJ2LTQuMlpNNjguMSAxMzcuM0g2NHY0LjNoNC4ydi00LjNaIi8+PC9tYXNrPjxnIG1hc2s9InVybCgjZykiPjxwYXRoIGZpbGw9IiMwMDAiIGQ9Ik02OC4xIDExMS45SDM4LjR2MjkuN2gyOS43di0yOS43WiIvPjwvZz48bWFzayBpZD0iaCIgd2lkdGg9IjE0IiBoZWlnaHQ9IjE0IiB4PSI0NiIgeT0iMTIwIiBtYXNrVW5pdHM9InVzZXJTcGFjZU9uVXNlIiBzdHlsZT0ibWFzay10eXBlOmx1bWluYW5jZSI+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTUxLjIgMTIwLjNoLTQuM3Y0LjNoNC4zdi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTUxLjIgMTI0LjZoLTQuM3Y0LjJoNC4zdi00LjJaTTUxLjIgMTI4LjhoLTQuM3Y0LjNoNC4zdi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTU1LjQgMTIwLjNoLTQuMnY0LjNoNC4ydi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTU1LjQgMTI0LjZoLTQuMnY0LjJoNC4ydi00LjJaTTU1LjQgMTI4LjhoLTQuMnY0LjNoNC4ydi00LjNaTTU5LjcgMTIwLjNoLTQuM3Y0LjNoNC4zdi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTU5LjcgMTI0LjZoLTQuM3Y0LjJoNC4zdi00LjJaTTU5LjcgMTI4LjhoLTQuM3Y0LjNoNC4zdi00LjNaIi8+PC9tYXNrPjxnIG1hc2s9InVybCgjaCkiPjxwYXRoIGZpbGw9IiMwMDAiIGQ9Ik01OS43IDEyMC4zSDQ2Ljl2MTIuOGgxMi44di0xMi44WiIvPjwvZz48L2c+PGRlZnM+PGNsaXBQYXRoIGlkPSJhIj48cmVjdCB3aWR0aD0iMTM3IiBoZWlnaHQ9IjEzNyIgeD0iMzEuNSIgeT0iMTEuNSIgZmlsbD0iI2ZmZiIgcng9IjguMiIvPjwvY2xpcFBhdGg+PC9kZWZzPjwvc3ZnPg==", "description": "Enables users to open the ThingsBoard mobile app by scanning a QR code from Home page.", "descriptor": { "type": "static", diff --git a/application/src/main/data/json/system/widget_types/mobile_app_qr_code.json b/application/src/main/data/json/system/widget_types/mobile_app_qr_code.json index d84c83bfb6..223678bbdd 100644 --- a/application/src/main/data/json/system/widget_types/mobile_app_qr_code.json +++ b/application/src/main/data/json/system/widget_types/mobile_app_qr_code.json @@ -2,7 +2,7 @@ "fqn": "cards.mobile_app_qr_code", "name": "Mobile app QR code", "deprecated": false, - "image": null, + "image": "tb-image:bW9iaWxlX2FwcF9xcl9jb2RlX3dpZGdldC5zdmc=:bW9iaWxlX2FwcF9xcl9jb2RlX3dpZGdldC5zdmc=;data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMDAiIGhlaWdodD0iMTYwIiBmaWxsPSJub25lIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE2MCIgZmlsbD0iI2ZmZiIgcng9IjQiLz48cmVjdCB3aWR0aD0iMTk5IiBoZWlnaHQ9IjE1OSIgeD0iLjUiIHk9Ii41IiBzdHJva2U9IiMwMDAiIHN0cm9rZS1vcGFjaXR5PSIuMSIgcng9IjMuNSIvPjxnIGNsaXAtcGF0aD0idXJsKCNhKSI+PHJlY3Qgd2lkdGg9IjEzNyIgaGVpZ2h0PSIxMzciIHg9IjMxLjUiIHk9IjExLjUiIGZpbGw9IiNmZmYiIHJ4PSI4LjIiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTY4LjUgMTEuNWgtMTM3djEzN2gxMzd2LTEzN1oiLz48bWFzayBpZD0iYiIgd2lkdGg9IjEyNCIgaGVpZ2h0PSIxMjQiIHg9IjM4IiB5PSIxOCIgbWFza1VuaXRzPSJ1c2VyU3BhY2VPblVzZSIgc3R5bGU9Im1hc2stdHlwZTpsdW1pbmFuY2UiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik00Mi43IDUyLjRoLTQuM3Y0LjJoNC4zdi00LjJaTTQyLjcgNjAuOWgtNC4zVjY1aDQuM1Y2MVpNNDIuNyA2NS4xaC00LjN2NC4zaDQuM1Y2NVpNNDIuNyA5NC45aC00LjNWOTloNC4zVjk1Wk00NyA1Ni42aC00LjNWNjFoNC4ydi00LjNaTTQ3IDgyLjFoLTQuM3Y0LjNoNC4yVjgyWk00NyA4Ni40aC00LjN2NC4yaDQuMnYtNC4yWk00NyA5NC45aC00LjNWOTloNC4yVjk1Wk00NyAxMDMuNGgtNC4zdjQuMmg0LjJ2LTQuMlpNNTEuMiA1Mi40aC00LjN2NC4yaDQuM3YtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik01MS4yIDYwLjloLTQuM1Y2NWg0LjNWNjFaTTUxLjIgNzMuNmgtNC4zVjc4aDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik01MS4yIDc3LjloLTQuM1Y4Mmg0LjNWNzhaTTUxLjIgODIuMWgtNC4zdjQuM2g0LjNWODJaTTUxLjIgOTAuNmgtNC4zVjk1aDQuM3YtNC4zWk01MS4yIDk5LjFoLTQuM3Y0LjNoNC4zVjk5Wk01NS40IDUyLjRoLTQuMnY0LjJoNC4ydi00LjJaTTU1LjQgNjkuNGgtNC4ydjQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNNTUuNCA4Ni40aC00LjJ2NC4yaDQuMnYtNC4yWk01NS40IDk5LjFoLTQuMnY0LjNoNC4yVjk5Wk01NS40IDEwMy40aC00LjJ2NC4yaDQuMnYtNC4yWk01OS43IDY1LjFoLTQuM3Y0LjNoNC4zVjY1Wk01OS43IDY5LjRoLTQuM3Y0LjJoNC4zdi00LjJaTTU5LjcgODYuNGgtNC4zdjQuMmg0LjN2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNNTkuNyA5MC42aC00LjNWOTVoNC4zdi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTU5LjcgOTQuOWgtNC4zVjk5aDQuM1Y5NVpNNjMuOSA1Mi40aC00LjJ2NC4yaDQuMnYtNC4yWk02My45IDY1LjFoLTQuMnY0LjNoNC4yVjY1Wk02My45IDY5LjRoLTQuMnY0LjJoNC4ydi00LjJaTTYzLjkgODYuNGgtNC4ydjQuMmg0LjJ2LTQuMlpNNjMuOSA5OS4xaC00LjJ2NC4zaDQuMlY5OVpNNjguMSA1Mi40SDY0djQuMmg0LjJ2LTQuMlpNNjguMSA2MC45SDY0VjY1aDQuMlY2MVpNNjguMSA2OS40SDY0djQuMmg0LjJ2LTQuMlpNNjguMSA3Ny45SDY0VjgyaDQuMlY3OFpNNjguMSA4Ni40SDY0djQuMmg0LjJ2LTQuMlpNNjguMSA5NC45SDY0Vjk5aDQuMlY5NVpNNjguMSAxMDMuNEg2NHY0LjJoNC4ydi00LjJaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTcyLjQgNTIuNEg2OHY0LjJoNC4zdi00LjJaTTcyLjQgNjkuNEg2OHY0LjJoNC4zdi00LjJaTTcyLjQgNzcuOUg2OFY4Mmg0LjNWNzhaTTcyLjQgODIuMUg2OHY0LjNoNC4zVjgyWk03Mi40IDg2LjRINjh2NC4yaDQuM3YtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik03Mi40IDkwLjZINjhWOTVoNC4zdi00LjNaTTcyLjQgOTkuMUg2OHY0LjNoNC4zVjk5Wk03Mi40IDEwMy40SDY4djQuMmg0LjN2LTQuMlpNNzYuNiAyMi43aC00LjJ2NC4yaDQuMnYtNC4yWk03Ni42IDMxLjJoLTQuMnY0LjJoNC4ydi00LjJaTTc2LjYgMzkuN2gtNC4ydjQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNNzYuNiA0My45aC00LjJ2NC4yaDQuMlY0NFpNNzYuNiA0OC4xaC00LjJ2NC4zaDQuMnYtNC4zWk03Ni42IDUyLjRoLTQuMnY0LjJoNC4ydi00LjJaTTc2LjYgNTYuNmgtNC4yVjYxaDQuMnYtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik03Ni42IDYwLjloLTQuMlY2NWg0LjJWNjFaTTc2LjYgNjUuMWgtNC4ydjQuM2g0LjJWNjVaTTc2LjYgNjkuNGgtNC4ydjQuMmg0LjJ2LTQuMlpNNzYuNiA5NC45aC00LjJWOTloNC4yVjk1Wk03Ni42IDEwMy40aC00LjJ2NC4yaDQuMnYtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik03Ni42IDEwNy42aC00LjJ2NC4zaDQuMnYtNC4zWk03Ni42IDExMS45aC00LjJ2NC4yaDQuMnYtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik03Ni42IDExNi4xaC00LjJ2NC4yaDQuMnYtNC4yWk03Ni42IDEyNC42aC00LjJ2NC4yaDQuMnYtNC4yWk03Ni42IDEyOC44aC00LjJ2NC4zaDQuMnYtNC4zWk03Ni42IDEzNy4zaC00LjJ2NC4zaDQuMnYtNC4zWk04MC45IDE4LjRoLTQuM3Y0LjNIODF2LTQuM1pNODAuOSAyN2gtNC4zdjQuMkg4MXYtNC4zWk04MC45IDMxLjJoLTQuM3Y0LjJIODF2LTQuMlpNODAuOSAzNS40aC00LjN2NC4zSDgxdi00LjNaTTgwLjkgMzkuN2gtNC4zdjQuMkg4MXYtNC4yWk04MC45IDQ4LjFoLTQuM3Y0LjNIODF2LTQuM1pNODAuOSA2MC45aC00LjNWNjVIODFWNjFaTTgwLjkgNjUuMWgtNC4zdjQuM0g4MVY2NVpNODAuOSA3My42aC00LjNWNzhIODF2LTQuM1pNODAuOSA4Mi4xaC00LjN2NC4zSDgxVjgyWk04MC45IDk0LjloLTQuM1Y5OUg4MVY5NVpNODAuOSA5OS4xaC00LjN2NC4zSDgxVjk5Wk04MC45IDEwMy40aC00LjN2NC4ySDgxdi00LjJaTTgwLjkgMTExLjloLTQuM3Y0LjJIODF2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNODAuOSAxMTYuMWgtNC4zdjQuMkg4MXYtNC4yWk04MC45IDEyNC42aC00LjN2NC4ySDgxdi00LjJaTTgwLjkgMTM3LjNoLTQuM3Y0LjNIODF2LTQuM1pNODUuMSAxOC40SDgxdjQuM0g4NXYtNC4zWk04NS4xIDMxLjJIODF2NC4ySDg1di00LjJaTTg1LjEgMzUuNEg4MXY0LjNIODV2LTQuM1pNODUuMSA0My45SDgxdjQuMkg4NVY0NFpNODUuMSA1Ni42SDgxVjYxSDg1di00LjNaTTg1LjEgNzMuNkg4MVY3OEg4NXYtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik04NS4xIDc3LjlIODFWODJIODVWNzhaTTg1LjEgODIuMUg4MXY0LjNIODVWODJaTTg1LjEgODYuNEg4MXY0LjJIODV2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNODUuMSA5MC42SDgxVjk1SDg1di00LjNaTTg1LjEgOTkuMUg4MXY0LjNIODVWOTlaTTg1LjEgMTExLjlIODF2NC4ySDg1di00LjJaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTg1LjEgMTE2LjFIODF2NC4ySDg1di00LjJaTTg1LjEgMTMzSDgxdjQuM0g4NXYtNC4yWk04OS40IDIyLjdIODV2NC4yaDQuM3YtNC4yWk04OS40IDMxLjJIODV2NC4yaDQuM3YtNC4yWk04OS40IDM1LjRIODV2NC4zaDQuM3YtNC4zWk04OS40IDQ4LjFIODV2NC4zaDQuM3YtNC4zWk04OS40IDUyLjRIODV2NC4yaDQuM3YtNC4yWk04OS40IDU2LjZIODVWNjFoNC4zdi00LjNaTTg5LjQgNjUuMUg4NXY0LjNoNC4zVjY1Wk04OS40IDEwMy40SDg1djQuMmg0LjN2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNODkuNCAxMDcuNkg4NXY0LjNoNC4zdi00LjNaTTg5LjQgMTExLjlIODV2NC4yaDQuM3YtNC4yWk04OS40IDEyOC44SDg1djQuM2g0LjN2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNODkuNCAxMzNIODV2NC4zaDQuM3YtNC4yWk05My42IDE4LjRoLTQuMnY0LjNoNC4ydi00LjNaTTkzLjYgMjIuN2gtNC4ydjQuMmg0LjJ2LTQuMlpNOTMuNiAyN2gtNC4ydjQuMmg0LjJ2LTQuM1pNOTMuNiAzMS4yaC00LjJ2NC4yaDQuMnYtNC4yWk05My42IDQzLjloLTQuMnY0LjJoNC4yVjQ0Wk05My42IDU2LjZoLTQuMlY2MWg0LjJ2LTQuM1pNOTMuNiA2NS4xaC00LjJ2NC4zaDQuMlY2NVpNOTMuNiA2OS40aC00LjJ2NC4yaDQuMnYtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik05My42IDczLjZoLTQuMlY3OGg0LjJ2LTQuM1pNOTMuNiA4Mi4xaC00LjJ2NC4zaDQuMlY4MlpNOTMuNiA4Ni40aC00LjJ2NC4yaDQuMnYtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik05My42IDkwLjZoLTQuMlY5NWg0LjJ2LTQuM1pNOTMuNiAxMTEuOWgtNC4ydjQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNOTMuNiAxMTYuMWgtNC4ydjQuMmg0LjJ2LTQuMlpNOTcuOSAxOC40aC00LjN2NC4zSDk4di00LjNaTTk3LjkgMjIuN2gtNC4zdjQuMkg5OHYtNC4yWk05Ny45IDM1LjRoLTQuM3Y0LjNIOTh2LTQuM1pNOTcuOSA0OC4xaC00LjN2NC4zSDk4di00LjNaTTk3LjkgNTIuNGgtNC4zdjQuMkg5OHYtNC4yWk05Ny45IDYwLjloLTQuM1Y2NUg5OFY2MVpNOTcuOSA2NS4xaC00LjN2NC4zSDk4VjY1Wk05Ny45IDczLjZoLTQuM1Y3OEg5OHYtNC4zWk05Ny45IDgyLjFoLTQuM3Y0LjNIOThWODJaTTk3LjkgODYuNGgtNC4zdjQuMkg5OHYtNC4yWk05Ny45IDk0LjloLTQuM1Y5OUg5OFY5NVpNOTcuOSA5OS4xaC00LjN2NC4zSDk4Vjk5Wk05Ny45IDEwMy40aC00LjN2NC4ySDk4di00LjJaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTk3LjkgMTA3LjZoLTQuM3Y0LjNIOTh2LTQuM1pNOTcuOSAxMTYuMWgtNC4zdjQuMkg5OHYtNC4yWk05Ny45IDEyNC42aC00LjN2NC4ySDk4di00LjJaTTk3LjkgMTMzaC00LjN2NC4zSDk4di00LjJaTTEwMi4xIDIyLjdIOTh2NC4yaDQuMnYtNC4yWk0xMDIuMSA0My45SDk4djQuMmg0LjJWNDRaTTEwMi4xIDUyLjRIOTh2NC4yaDQuMnYtNC4yWk0xMDIuMSA2OS40SDk4djQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTAyLjEgNzMuNkg5OFY3OGg0LjJ2LTQuM1pNMTAyLjEgOTAuNkg5OFY5NWg0LjJ2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTAyLjEgOTQuOUg5OFY5OWg0LjJWOTVaTTEwMi4xIDEwMy40SDk4djQuMmg0LjJ2LTQuMlpNMTAyLjEgMTMzSDk4djQuM2g0LjJ2LTQuMlpNMTAyLjEgMTM3LjNIOTh2NC4zaDQuMnYtNC4zWk0xMDYuNCAxOC40SDEwMnY0LjNoNC4zdi00LjNaTTEwNi40IDIyLjdIMTAydjQuMmg0LjN2LTQuMlpNMTA2LjQgNDguMUgxMDJ2NC4zaDQuM3YtNC4zWk0xMDYuNCA2MC45SDEwMlY2NWg0LjNWNjFaTTEwNi40IDczLjZIMTAyVjc4aDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMDYuNCA3Ny45SDEwMlY4Mmg0LjNWNzhaTTEwNi40IDgyLjFIMTAydjQuM2g0LjNWODJaTTEwNi40IDg2LjRIMTAydjQuMmg0LjN2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTA2LjQgOTAuNkgxMDJWOTVoNC4zdi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTEwNi40IDk0LjlIMTAyVjk5aDQuM1Y5NVpNMTA2LjQgMTAzLjRIMTAydjQuMmg0LjN2LTQuMlpNMTA2LjQgMTE2LjFIMTAydjQuMmg0LjN2LTQuMlpNMTA2LjQgMTMzSDEwMnY0LjNoNC4zdi00LjJaTTEwNi40IDEzNy4zSDEwMnY0LjNoNC4zdi00LjNaTTExMC42IDI3aC00LjJ2NC4yaDQuMnYtNC4zWk0xMTAuNiAzOS43aC00LjJ2NC4yaDQuMnYtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMTAuNiA0My45aC00LjJ2NC4yaDQuMlY0NFpNMTEwLjYgNDguMWgtNC4ydjQuM2g0LjJ2LTQuM1pNMTEwLjYgNTIuNGgtNC4ydjQuMmg0LjJ2LTQuMlpNMTEwLjYgNTYuNmgtNC4yVjYxaDQuMnYtNC4zWk0xMTAuNiA5MC42aC00LjJWOTVoNC4ydi00LjNaTTExMC42IDk5LjFoLTQuMnY0LjNoNC4yVjk5Wk0xMTAuNiAxMDcuNmgtNC4ydjQuM2g0LjJ2LTQuM1pNMTEwLjYgMTE2LjFoLTQuMnY0LjJoNC4ydi00LjJaTTExMC42IDEyMC4zaC00LjJ2NC4zaDQuMnYtNC4zWk0xMTAuNiAxMzNoLTQuMnY0LjNoNC4ydi00LjJaTTExMC42IDEzNy4zaC00LjJ2NC4zaDQuMnYtNC4zWk0xMTQuOSAzMS4yaC00LjN2NC4yaDQuM3YtNC4yWk0xMTQuOSA0OC4xaC00LjN2NC4zaDQuM3YtNC4zWk0xMTQuOSA1Mi40aC00LjN2NC4yaDQuM3YtNC4yWk0xMTQuOSA2NS4xaC00LjN2NC4zaDQuM1Y2NVpNMTE0LjkgNzMuNmgtNC4zVjc4aDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMTQuOSA3Ny45aC00LjNWODJoNC4zVjc4Wk0xMTQuOSA4Mi4xaC00LjN2NC4zaDQuM1Y4MlpNMTE0LjkgODYuNGgtNC4zdjQuMmg0LjN2LTQuMlpNMTE0LjkgOTkuMWgtNC4zdjQuM2g0LjNWOTlaTTExNC45IDExMS45aC00LjN2NC4yaDQuM3YtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMTQuOSAxMTYuMWgtNC4zdjQuMmg0LjN2LTQuMlpNMTE0LjkgMTMzaC00LjN2NC4zaDQuM3YtNC4yWk0xMTQuOSAxMzcuM2gtNC4zdjQuM2g0LjN2LTQuM1pNMTE5LjEgMTguNEgxMTV2NC4zaDQuMnYtNC4zWk0xMTkuMSAzNS40SDExNXY0LjNoNC4ydi00LjNaTTExOS4xIDM5LjdIMTE1djQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTE5LjEgNDMuOUgxMTV2NC4yaDQuMlY0NFpNMTE5LjEgNjUuMUgxMTV2NC4zaDQuMlY2NVpNMTE5LjEgNjkuNEgxMTV2NC4yaDQuMnYtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMTkuMSA3My42SDExNVY3OGg0LjJ2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTE5LjEgNzcuOUgxMTVWODJoNC4yVjc4Wk0xMTkuMSA4Mi4xSDExNXY0LjNoNC4yVjgyWk0xMTkuMSA4Ni40SDExNXY0LjJoNC4ydi00LjJaTTExOS4xIDk0LjlIMTE1Vjk5aDQuMlY5NVpNMTE5LjEgMTAzLjRIMTE1djQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTE5LjEgMTA3LjZIMTE1djQuM2g0LjJ2LTQuM1pNMTE5LjEgMTE2LjFIMTE1djQuMmg0LjJ2LTQuMlpNMTE5LjEgMTI0LjZIMTE1djQuMmg0LjJ2LTQuMlpNMTE5LjEgMTMzSDExNXY0LjNoNC4ydi00LjJaTTExOS4xIDEzNy4zSDExNXY0LjNoNC4ydi00LjNaTTEyMy40IDMxLjJIMTE5djQuMmg0LjN2LTQuMlpNMTIzLjQgMzUuNEgxMTl2NC4zaDQuM3YtNC4zWk0xMjMuNCA1Ni42SDExOVY2MWg0LjN2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTIzLjQgNjAuOUgxMTlWNjVoNC4zVjYxWk0xMjMuNCA5MC42SDExOVY5NWg0LjN2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTIzLjQgOTQuOUgxMTlWOTloNC4zVjk1Wk0xMjMuNCA5OS4xSDExOXY0LjNoNC4zVjk5Wk0xMjMuNCAxMjAuM0gxMTl2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMjMuNCAxMjQuNkgxMTl2NC4yaDQuM3YtNC4yWk0xMjcuNiAxOC40aC00LjJ2NC4zaDQuMnYtNC4zWk0xMjcuNiAyN2gtNC4ydjQuMmg0LjJ2LTQuM1pNMTI3LjYgMzEuMmgtNC4ydjQuMmg0LjJ2LTQuMlpNMTI3LjYgMzkuN2gtNC4ydjQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTI3LjYgNDMuOWgtNC4ydjQuMmg0LjJWNDRaTTEyNy42IDQ4LjFoLTQuMnY0LjNoNC4ydi00LjNaTTEyNy42IDU2LjZoLTQuMlY2MWg0LjJ2LTQuM1pNMTI3LjYgNjUuMWgtNC4ydjQuM2g0LjJWNjVaTTEyNy42IDY5LjRoLTQuMnY0LjJoNC4ydi00LjJaTTEyNy42IDc3LjloLTQuMlY4Mmg0LjJWNzhaTTEyNy42IDgyLjFoLTQuMnY0LjNoNC4yVjgyWk0xMjcuNiA5OS4xaC00LjJ2NC4zaDQuMlY5OVpNMTI3LjYgMTAzLjRoLTQuMnY0LjJoNC4ydi00LjJaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTEyNy42IDEwNy42aC00LjJ2NC4zaDQuMnYtNC4zWk0xMjcuNiAxMTEuOWgtNC4ydjQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTI3LjYgMTE2LjFoLTQuMnY0LjJoNC4ydi00LjJaTTEyNy42IDEyMC4zaC00LjJ2NC4zaDQuMnYtNC4zWk0xMjcuNiAxMjguOGgtNC4ydjQuM2g0LjJ2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTI3LjYgMTMzaC00LjJ2NC4zaDQuMnYtNC4yWk0xMzEuOSA1Mi40aC00LjN2NC4yaDQuM3YtNC4yWk0xMzEuOSA2NS4xaC00LjN2NC4zaDQuM1Y2NVpNMTMxLjkgNzcuOWgtNC4zVjgyaDQuM1Y3OFpNMTMxLjkgODYuNGgtNC4zdjQuMmg0LjN2LTQuMlpNMTMxLjkgOTQuOWgtNC4zVjk5aDQuM1Y5NVpNMTMxLjkgMTAzLjRoLTQuM3Y0LjJoNC4zdi00LjJaTTEzMS45IDEyMC4zaC00LjN2NC4zaDQuM3YtNC4zWk0xMzEuOSAxMzNoLTQuM3Y0LjNoNC4zdi00LjJaTTEzMS45IDEzNy4zaC00LjN2NC4zaDQuM3YtNC4zWk0xMzYuMSA1Mi40aC00LjJ2NC4yaDQuMnYtNC4yWk0xMzYuMSA1Ni42aC00LjJWNjFoNC4ydi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTEzNi4xIDYwLjloLTQuMlY2NWg0LjJWNjFaTTEzNi4xIDY1LjFoLTQuMnY0LjNoNC4yVjY1Wk0xMzYuMSA2OS40aC00LjJ2NC4yaDQuMnYtNC4yWk0xMzYuMSA5NC45aC00LjJWOTloNC4yVjk1Wk0xMzYuMSAxMDMuNGgtNC4ydjQuMmg0LjJ2LTQuMlpNMTM2LjEgMTExLjloLTQuMnY0LjJoNC4ydi00LjJaTTEzNi4xIDEyMC4zaC00LjJ2NC4zaDQuMnYtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMzYuMSAxMjQuNmgtNC4ydjQuMmg0LjJ2LTQuMlpNMTM2LjEgMTI4LjhoLTQuMnY0LjNoNC4ydi00LjNaTTE0MC4zIDUyLjRoLTQuMnY0LjJoNC4ydi00LjJaTTE0MC4zIDU2LjZoLTQuMlY2MWg0LjJ2LTQuM1pNMTQwLjMgNjUuMWgtNC4ydjQuM2g0LjJWNjVaTTE0MC4zIDczLjZoLTQuMlY3OGg0LjJ2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTQwLjMgNzcuOWgtNC4yVjgyaDQuMlY3OFpNMTQwLjMgODYuNGgtNC4ydjQuMmg0LjJ2LTQuMlpNMTQwLjMgOTQuOWgtNC4yVjk5aDQuMlY5NVpNMTQwLjMgMTAzLjRoLTQuMnY0LjJoNC4ydi00LjJaTTE0MC4zIDEyMC4zaC00LjJ2NC4zaDQuMnYtNC4zWk0xNDAuMyAxMjguOGgtNC4ydjQuM2g0LjJ2LTQuM1pNMTQ0LjYgNjAuOWgtNC4zVjY1aDQuM1Y2MVoiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTQ0LjYgODYuNGgtNC4zdjQuMmg0LjN2LTQuMlpNMTQ0LjYgMTAzLjRoLTQuM3Y0LjJoNC4zdi00LjJaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE0NC42IDEwNy42aC00LjN2NC4zaDQuM3YtNC4zWk0xNDQuNiAxMTEuOWgtNC4zdjQuMmg0LjN2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTQ0LjYgMTE2LjFoLTQuM3Y0LjJoNC4zdi00LjJaTTE0NC42IDEyMC4zaC00LjN2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xNDQuNiAxMjQuNmgtNC4zdjQuMmg0LjN2LTQuMlpNMTQ4LjggNTIuNGgtNC4ydjQuMmg0LjJ2LTQuMlpNMTQ4LjggNjAuOWgtNC4yVjY1aDQuMlY2MVpNMTQ4LjggNzMuNmgtNC4yVjc4aDQuMnYtNC4zWk0xNDguOCA4Mi4xaC00LjJ2NC4zaDQuMlY4MlpNMTQ4LjggODYuNGgtNC4ydjQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTQ4LjggOTAuNmgtNC4yVjk1aDQuMnYtNC4zWk0xNDguOCA5OS4xaC00LjJ2NC4zaDQuMlY5OVpNMTQ4LjggMTAzLjRoLTQuMnY0LjJoNC4ydi00LjJaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE0OC44IDEwNy42aC00LjJ2NC4zaDQuMnYtNC4zWk0xNDguOCAxMTEuOWgtNC4ydjQuMmg0LjJ2LTQuMlpNMTQ4LjggMTIwLjNoLTQuMnY0LjNoNC4ydi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE0OC44IDEyNC42aC00LjJ2NC4yaDQuMnYtNC4yWk0xNDguOCAxMjguOGgtNC4ydjQuM2g0LjJ2LTQuM1pNMTQ4LjggMTM3LjNoLTQuMnY0LjNoNC4ydi00LjNaTTE1MyA1Ni42aC00LjJWNjFoNC4zdi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE1MyA2MC45aC00LjJWNjVoNC4zVjYxWk0xNTMgNjUuMWgtNC4ydjQuM2g0LjNWNjVaTTE1MyA5NC45aC00LjJWOTloNC4zVjk1Wk0xNTMgOTkuMWgtNC4ydjQuM2g0LjNWOTlaTTE1MyAxMDMuNGgtNC4ydjQuMmg0LjN2LTQuMlpNMTUzIDEyMC4zaC00LjJ2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xNTMgMTI0LjZoLTQuMnY0LjJoNC4zdi00LjJaTTE1MyAxMzcuM2gtNC4ydjQuM2g0LjN2LTQuM1pNMTU3LjMgNTIuNGgtNC4ydjQuMmg0LjJ2LTQuMlpNMTU3LjMgNTYuNmgtNC4yVjYxaDQuMnYtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xNTcuMyA2MC45aC00LjJWNjVoNC4yVjYxWk0xNTcuMyA2NS4xaC00LjJ2NC4zaDQuMlY2NVpNMTU3LjMgOTAuNmgtNC4yVjk1aDQuMnYtNC4zWk0xNTcuMyAxMTYuMWgtNC4ydjQuMmg0LjJ2LTQuMlpNMTU3LjMgMTI0LjZoLTQuMnY0LjJoNC4ydi00LjJaTTE1Ny4zIDEzM2gtNC4ydjQuM2g0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTYxLjYgNTYuNmgtNC4zVjYxaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xNjEuNiA2MC45aC00LjNWNjVoNC4zVjYxWk0xNjEuNiA2NS4xaC00LjN2NC4zaDQuM1Y2NVpNMTYxLjYgNzMuNmgtNC4zVjc4aDQuM3YtNC4zWk0xNjEuNiA5MC42aC00LjNWOTVoNC4zdi00LjNaTTE2MS42IDEwMy40aC00LjN2NC4yaDQuM3YtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xNjEuNiAxMDcuNmgtNC4zdjQuM2g0LjN2LTQuM1pNMTYxLjYgMTExLjloLTQuM3Y0LjJoNC4zdi00LjJaTTE2MS42IDEyMC4zaC00LjN2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xNjEuNiAxMjQuNmgtNC4zdjQuMmg0LjN2LTQuMlpNMTYxLjYgMTMzaC00LjN2NC4zaDQuM3YtNC4yWiIvPjwvbWFzaz48ZyBtYXNrPSJ1cmwoI2IpIj48cGF0aCBmaWxsPSIjMDAwIiBkPSJNMTYxLjYgMTguNEgzOC40djEyMy4yaDEyMy4yVjE4LjRaIi8+PC9nPjxtYXNrIGlkPSJjIiB3aWR0aD0iMzEiIGhlaWdodD0iMzEiIHg9IjM4IiB5PSIxOCIgbWFza1VuaXRzPSJ1c2VyU3BhY2VPblVzZSIgc3R5bGU9Im1hc2stdHlwZTpsdW1pbmFuY2UiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik00Mi43IDE4LjRoLTQuM3Y0LjNoNC4zdi00LjNaTTQyLjcgMjIuN2gtNC4zdjQuMmg0LjN2LTQuMlpNNDIuNyAyN2gtNC4zdjQuMmg0LjN2LTQuM1pNNDIuNyAzMS4yaC00LjN2NC4yaDQuM3YtNC4yWk00Mi43IDM1LjRoLTQuM3Y0LjNoNC4zdi00LjNaTTQyLjcgMzkuN2gtNC4zdjQuMmg0LjN2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNNDIuNyA0My45aC00LjN2NC4yaDQuM1Y0NFpNNDcgMTguNGgtNC4zdjQuM2g0LjJ2LTQuM1pNNDcgNDMuOWgtNC4zdjQuMmg0LjJWNDRaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTUxLjIgMTguNGgtNC4zdjQuM2g0LjN2LTQuM1pNNTEuMiA0My45aC00LjN2NC4yaDQuM1Y0NFoiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNNTUuNCAxOC40aC00LjJ2NC4zaDQuMnYtNC4zWk01NS40IDQzLjloLTQuMnY0LjJoNC4yVjQ0Wk01OS43IDE4LjRoLTQuM3Y0LjNoNC4zdi00LjNaTTU5LjcgNDMuOWgtNC4zdjQuMmg0LjNWNDRaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTYzLjkgMTguNGgtNC4ydjQuM2g0LjJ2LTQuM1pNNjMuOSA0My45aC00LjJ2NC4yaDQuMlY0NFpNNjguMSAxOC40SDY0djQuM2g0LjJ2LTQuM1pNNjguMSAyMi43SDY0djQuMmg0LjJ2LTQuMlpNNjguMSAyN0g2NHY0LjJoNC4ydi00LjNaTTY4LjEgMzEuMkg2NHY0LjJoNC4ydi00LjJaTTY4LjEgMzUuNEg2NHY0LjNoNC4ydi00LjNaTTY4LjEgMzkuN0g2NHY0LjJoNC4ydi00LjJaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTY4LjEgNDMuOUg2NHY0LjJoNC4yVjQ0WiIvPjwvbWFzaz48ZyBtYXNrPSJ1cmwoI2MpIj48cGF0aCBmaWxsPSIjMDAwIiBkPSJNNjguMSAxOC40SDM4LjR2MjkuN2gyOS43VjE4LjRaIi8+PC9nPjxtYXNrIGlkPSJkIiB3aWR0aD0iMTQiIGhlaWdodD0iMTQiIHg9IjQ2IiB5PSIyNiIgbWFza1VuaXRzPSJ1c2VyU3BhY2VPblVzZSIgc3R5bGU9Im1hc2stdHlwZTpsdW1pbmFuY2UiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik01MS4yIDI3aC00LjN2NC4yaDQuM3YtNC4zWk01MS4yIDMxLjJoLTQuM3Y0LjJoNC4zdi00LjJaTTUxLjIgMzUuNGgtNC4zdjQuM2g0LjN2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNNTUuNCAyN2gtNC4ydjQuMmg0LjJ2LTQuM1pNNTUuNCAzMS4yaC00LjJ2NC4yaDQuMnYtNC4yWk01NS40IDM1LjRoLTQuMnY0LjNoNC4ydi00LjNaTTU5LjcgMjdoLTQuM3Y0LjJoNC4zdi00LjNaTTU5LjcgMzEuMmgtNC4zdjQuMmg0LjN2LTQuMlpNNTkuNyAzNS40aC00LjN2NC4zaDQuM3YtNC4zWiIvPjwvbWFzaz48ZyBtYXNrPSJ1cmwoI2QpIj48cGF0aCBmaWxsPSIjMDAwIiBkPSJNNTkuNyAyN0g0Ni45djEyLjdoMTIuOFYyNi45WiIvPjwvZz48bWFzayBpZD0iZSIgd2lkdGg9IjMxIiBoZWlnaHQ9IjMxIiB4PSIxMzEiIHk9IjE4IiBtYXNrVW5pdHM9InVzZXJTcGFjZU9uVXNlIiBzdHlsZT0ibWFzay10eXBlOmx1bWluYW5jZSI+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTEzNi4xIDE4LjRoLTQuMnY0LjNoNC4ydi00LjNaTTEzNi4xIDIyLjdoLTQuMnY0LjJoNC4ydi00LjJaTTEzNi4xIDI3aC00LjJ2NC4yaDQuMnYtNC4zWk0xMzYuMSAzMS4yaC00LjJ2NC4yaDQuMnYtNC4yWk0xMzYuMSAzNS40aC00LjJ2NC4zaDQuMnYtNC4zWk0xMzYuMSAzOS43aC00LjJ2NC4yaDQuMnYtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMzYuMSA0My45aC00LjJ2NC4yaDQuMlY0NFpNMTQwLjMgMTguNGgtNC4ydjQuM2g0LjJ2LTQuM1pNMTQwLjMgNDMuOWgtNC4ydjQuMmg0LjJWNDRaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE0NC42IDE4LjRoLTQuM3Y0LjNoNC4zdi00LjNaTTE0NC42IDQzLjloLTQuM3Y0LjJoNC4zVjQ0Wk0xNDguOCAxOC40aC00LjJ2NC4zaDQuMnYtNC4zWk0xNDguOCA0My45aC00LjJ2NC4yaDQuMlY0NFpNMTUzIDE4LjRoLTQuMnY0LjNoNC4zdi00LjNaTTE1MyA0My45aC00LjJ2NC4yaDQuM1Y0NFpNMTU3LjMgMTguNGgtNC4ydjQuM2g0LjJ2LTQuM1pNMTU3LjMgNDMuOWgtNC4ydjQuMmg0LjJWNDRaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE2MS42IDE4LjRoLTQuM3Y0LjNoNC4zdi00LjNaTTE2MS42IDIyLjdoLTQuM3Y0LjJoNC4zdi00LjJaTTE2MS42IDI3aC00LjN2NC4yaDQuM3YtNC4zWk0xNjEuNiAzMS4yaC00LjN2NC4yaDQuM3YtNC4yWk0xNjEuNiAzNS40aC00LjN2NC4zaDQuM3YtNC4zWk0xNjEuNiAzOS43aC00LjN2NC4yaDQuM3YtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xNjEuNiA0My45aC00LjN2NC4yaDQuM1Y0NFoiLz48L21hc2s+PGcgbWFzaz0idXJsKCNlKSI+PHBhdGggZmlsbD0iIzAwMCIgZD0iTTE2MS42IDE4LjRoLTI5Ljd2MjkuN2gyOS43VjE4LjRaIi8+PC9nPjxtYXNrIGlkPSJmIiB3aWR0aD0iMTQiIGhlaWdodD0iMTQiIHg9IjE0MCIgeT0iMjYiIG1hc2tVbml0cz0idXNlclNwYWNlT25Vc2UiIHN0eWxlPSJtYXNrLXR5cGU6bHVtaW5hbmNlIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTQ0LjYgMjdoLTQuM3Y0LjJoNC4zdi00LjNaTTE0NC42IDMxLjJoLTQuM3Y0LjJoNC4zdi00LjJaTTE0NC42IDM1LjRoLTQuM3Y0LjNoNC4zdi00LjNaTTE0OC44IDI3aC00LjJ2NC4yaDQuMnYtNC4zWk0xNDguOCAzMS4yaC00LjJ2NC4yaDQuMnYtNC4yWk0xNDguOCAzNS40aC00LjJ2NC4zaDQuMnYtNC4zWk0xNTMgMjdoLTQuMnY0LjJoNC4zdi00LjNaTTE1MyAzMS4yaC00LjJ2NC4yaDQuM3YtNC4yWk0xNTMgMzUuNGgtNC4ydjQuM2g0LjN2LTQuM1oiLz48L21hc2s+PGcgbWFzaz0idXJsKCNmKSI+PHBhdGggZmlsbD0iIzAwMCIgZD0iTTE1MyAyN2gtMTIuN3YxMi43aDEyLjhWMjYuOVoiLz48L2c+PG1hc2sgaWQ9ImciIHdpZHRoPSIzMSIgaGVpZ2h0PSIzMSIgeD0iMzgiIHk9IjExMSIgbWFza1VuaXRzPSJ1c2VyU3BhY2VPblVzZSIgc3R5bGU9Im1hc2stdHlwZTpsdW1pbmFuY2UiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik00Mi43IDExMS45aC00LjN2NC4yaDQuM3YtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik00Mi43IDExNi4xaC00LjN2NC4yaDQuM3YtNC4yWk00Mi43IDEyMC4zaC00LjN2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik00Mi43IDEyNC42aC00LjN2NC4yaDQuM3YtNC4yWk00Mi43IDEyOC44aC00LjN2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik00Mi43IDEzM2gtNC4zdjQuM2g0LjN2LTQuMlpNNDIuNyAxMzcuM2gtNC4zdjQuM2g0LjN2LTQuM1pNNDcgMTExLjloLTQuM3Y0LjJoNC4ydi00LjJaTTQ3IDEzNy4zaC00LjN2NC4zaDQuMnYtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik01MS4yIDExMS45aC00LjN2NC4yaDQuM3YtNC4yWk01MS4yIDEzNy4zaC00LjN2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik01NS40IDExMS45aC00LjJ2NC4yaDQuMnYtNC4yWk01NS40IDEzNy4zaC00LjJ2NC4zaDQuMnYtNC4zWk01OS43IDExMS45aC00LjN2NC4yaDQuM3YtNC4yWk01OS43IDEzNy4zaC00LjN2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik02My45IDExMS45aC00LjJ2NC4yaDQuMnYtNC4yWk02My45IDEzNy4zaC00LjJ2NC4zaDQuMnYtNC4zWk02OC4xIDExMS45SDY0djQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNNjguMSAxMTYuMUg2NHY0LjJoNC4ydi00LjJaTTY4LjEgMTIwLjNINjR2NC4zaDQuMnYtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik02OC4xIDEyNC42SDY0djQuMmg0LjJ2LTQuMlpNNjguMSAxMjguOEg2NHY0LjNoNC4ydi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTY4LjEgMTMzSDY0djQuM2g0LjJ2LTQuMlpNNjguMSAxMzcuM0g2NHY0LjNoNC4ydi00LjNaIi8+PC9tYXNrPjxnIG1hc2s9InVybCgjZykiPjxwYXRoIGZpbGw9IiMwMDAiIGQ9Ik02OC4xIDExMS45SDM4LjR2MjkuN2gyOS43di0yOS43WiIvPjwvZz48bWFzayBpZD0iaCIgd2lkdGg9IjE0IiBoZWlnaHQ9IjE0IiB4PSI0NiIgeT0iMTIwIiBtYXNrVW5pdHM9InVzZXJTcGFjZU9uVXNlIiBzdHlsZT0ibWFzay10eXBlOmx1bWluYW5jZSI+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTUxLjIgMTIwLjNoLTQuM3Y0LjNoNC4zdi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTUxLjIgMTI0LjZoLTQuM3Y0LjJoNC4zdi00LjJaTTUxLjIgMTI4LjhoLTQuM3Y0LjNoNC4zdi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTU1LjQgMTIwLjNoLTQuMnY0LjNoNC4ydi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTU1LjQgMTI0LjZoLTQuMnY0LjJoNC4ydi00LjJaTTU1LjQgMTI4LjhoLTQuMnY0LjNoNC4ydi00LjNaTTU5LjcgMTIwLjNoLTQuM3Y0LjNoNC4zdi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTU5LjcgMTI0LjZoLTQuM3Y0LjJoNC4zdi00LjJaTTU5LjcgMTI4LjhoLTQuM3Y0LjNoNC4zdi00LjNaIi8+PC9tYXNrPjxnIG1hc2s9InVybCgjaCkiPjxwYXRoIGZpbGw9IiMwMDAiIGQ9Ik01OS43IDEyMC4zSDQ2Ljl2MTIuOGgxMi44di0xMi44WiIvPjwvZz48L2c+PGRlZnM+PGNsaXBQYXRoIGlkPSJhIj48cmVjdCB3aWR0aD0iMTM3IiBoZWlnaHQ9IjEzNyIgeD0iMzEuNSIgeT0iMTEuNSIgZmlsbD0iI2ZmZiIgcng9IjguMiIvPjwvY2xpcFBhdGg+PC9kZWZzPjwvc3ZnPg==", "description": "Enables users to open the ThingsBoard mobile app by scanning the QR code from dashboard.", "descriptor": { "type": "static", diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.ts index c7557c75c2..5085d43aec 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.ts @@ -71,13 +71,15 @@ export class MobileAppQrcodeWidgetComponent extends PageComponent implements OnI } ngOnInit(): void { - if (this.ctx) { - this.mobileAppSettings = this.ctx.settings; - } else { - this.mobileAppService.getMobileAppSettings().subscribe((settings => { - this.mobileAppSettings = settings; - this.cd.detectChanges(); - })); + if (!this.previewMode) { + if (this.ctx) { + this.mobileAppSettings = this.ctx.settings; + } else { + this.mobileAppService.getMobileAppSettings().subscribe((settings => { + this.mobileAppSettings = settings; + this.cd.detectChanges(); + })); + } } } @@ -105,9 +107,6 @@ export class MobileAppQrcodeWidgetComponent extends PageComponent implements OnI updateQRCode(link: string) { import('qrcode').then((QRCode) => { QRCode.toCanvas(this.canvasRef.nativeElement, link, { width: 100 }); - // QRCode.toCanvas(this.canvasRef.nativeElement, link); - // this.canvasRef.nativeElement.style.width = '4.6vw'; - // this.canvasRef.nativeElement.style.height = '4.6vw'; }); } From e2a4ac67395e4b868e0c7023462835dd42ead214 Mon Sep 17 00:00:00 2001 From: Artem Dzhereleiko Date: Fri, 10 May 2024 12:25:12 +0300 Subject: [PATCH 42/84] UI: Added hint for Time series chart for comparison series --- .../basic/chart/time-series-chart-basic-config.component.html | 4 +++- ui-ngx/src/assets/locale/locale.constant-en_US.json | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html index 3d0fe04e4c..34177676aa 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html @@ -27,7 +27,9 @@
-
{{ 'widgets.chart.series' | translate }}
+
+ {{ 'widgets.chart.series' | translate }} +
{{ 'widgets.chart.series' | translate }} diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 6de2d81900..d109f3b08e 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -5783,6 +5783,7 @@ "display-legend": "Display legend", "labels-font-color": "Labels font color", "series": "Series", + "series-hint": "Comparison works only with historical data!", "add-series": "Add series", "series-settings": "Series settings", "remove-series": "Remove series", From d89b14e359fbe71663dbb622848130fd39fcb593 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Fri, 10 May 2024 11:45:52 +0200 Subject: [PATCH 43/84] Kafka Confluent compatibility out of the box: segment.bytes:26214400 increased segment.bytes:52428800 --- application/src/main/resources/thingsboard.yml | 18 +++++++++--------- .../server/queue/util/PropertyUtilsTest.java | 4 ++-- msa/js-executor/config/default.yml | 2 +- .../src/main/resources/tb-vc-executor.yml | 8 ++++---- .../src/main/resources/tb-coap-transport.yml | 10 +++++----- .../src/main/resources/tb-http-transport.yml | 10 +++++----- .../src/main/resources/tb-lwm2m-transport.yml | 10 +++++----- .../src/main/resources/tb-mqtt-transport.yml | 10 +++++----- .../src/main/resources/tb-snmp-transport.yml | 10 +++++----- 9 files changed, 41 insertions(+), 41 deletions(-) diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 290d57a468..3b3be2b708 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -1471,23 +1471,23 @@ queue: # value: "${TB_QUEUE_KAFKA_SESSION_TIMEOUT_MS:10000}" # (10 seconds) topic-properties: # Kafka properties for Rule Engine - rule-engine: "${TB_QUEUE_KAFKA_RE_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" + rule-engine: "${TB_QUEUE_KAFKA_RE_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" # Kafka properties for Core topics - core: "${TB_QUEUE_KAFKA_CORE_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:52428800;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" # Kafka properties for Transport Api topics - transport-api: "${TB_QUEUE_KAFKA_TA_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:10;min.insync.replicas:1}" + transport-api: "${TB_QUEUE_KAFKA_TA_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:10;min.insync.replicas:1}" # Kafka properties for Notifications topics - notifications: "${TB_QUEUE_KAFKA_NOTIFICATIONS_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" + notifications: "${TB_QUEUE_KAFKA_NOTIFICATIONS_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" # Kafka properties for JS Executor topics - js-executor: "${TB_QUEUE_KAFKA_JE_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:104857600;partitions:100;min.insync.replicas:1}" + js-executor: "${TB_QUEUE_KAFKA_JE_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:104857600;partitions:100;min.insync.replicas:1}" # Kafka properties for OTA updates topic - ota-updates: "${TB_QUEUE_KAFKA_OTA_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:10;min.insync.replicas:1}" + ota-updates: "${TB_QUEUE_KAFKA_OTA_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:10;min.insync.replicas:1}" # Kafka properties for Version Control topic - version-control: "${TB_QUEUE_KAFKA_VC_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" + version-control: "${TB_QUEUE_KAFKA_VC_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" # Kafka properties for Housekeeper tasks topic - housekeeper: "${TB_QUEUE_KAFKA_HOUSEKEEPER_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:10;min.insync.replicas:1}" + housekeeper: "${TB_QUEUE_KAFKA_HOUSEKEEPER_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:10;min.insync.replicas:1}" # Kafka properties for Housekeeper reprocessing topic; retention.ms is set to 90 days; partitions is set to 1 since only one reprocessing service is running at a time - housekeeper-reprocessing: "${TB_QUEUE_KAFKA_HOUSEKEEPER_REPROCESSING_TOPIC_PROPERTIES:retention.ms:7776000000;segment.bytes:26214400;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" + housekeeper-reprocessing: "${TB_QUEUE_KAFKA_HOUSEKEEPER_REPROCESSING_TOPIC_PROPERTIES:retention.ms:7776000000;segment.bytes:52428800;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" consumer-stats: # Prints lag between consumer group offset and last messages offset in Kafka topics enabled: "${TB_QUEUE_KAFKA_CONSUMER_STATS_ENABLED:true}" diff --git a/common/queue/src/test/java/org/thingsboard/server/queue/util/PropertyUtilsTest.java b/common/queue/src/test/java/org/thingsboard/server/queue/util/PropertyUtilsTest.java index ae5bbc9192..79e41aa986 100644 --- a/common/queue/src/test/java/org/thingsboard/server/queue/util/PropertyUtilsTest.java +++ b/common/queue/src/test/java/org/thingsboard/server/queue/util/PropertyUtilsTest.java @@ -49,10 +49,10 @@ class PropertyUtilsTest { @Test void givenKafkaTopicProperties_whenGetConfig_thenReturnMappedValues() { - assertThat(PropertyUtils.getProps("retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:1;min.insync.replicas:1")) + assertThat(PropertyUtils.getProps("retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:1;min.insync.replicas:1")) .isEqualTo(Map.of( "retention.ms", "604800000", - "segment.bytes", "26214400", + "segment.bytes", "52428800", "retention.bytes", "1048576000", "partitions", "1", "min.insync.replicas", "1" diff --git a/msa/js-executor/config/default.yml b/msa/js-executor/config/default.yml index b340425a31..6b24d23570 100644 --- a/msa/js-executor/config/default.yml +++ b/msa/js-executor/config/default.yml @@ -35,7 +35,7 @@ kafka: requestTimeout: "30000" # The default value in kafkajs is: 30000 connectionTimeout: "1000" # The default value in kafkajs is: 1000 compression: "gzip" # gzip or uncompressed - topic_properties: "retention.ms:604800000;segment.bytes:26214400;retention.bytes:104857600;partitions:100;min.insync.replicas:1" + topic_properties: "retention.ms:604800000;segment.bytes:52428800;retention.bytes:104857600;partitions:100;min.insync.replicas:1" use_confluent_cloud: false client_id: "kafkajs" #inject pod name to easy identify the client using /opt/kafka/bin/kafka-consumer-groups.sh confluent: diff --git a/msa/vc-executor/src/main/resources/tb-vc-executor.yml b/msa/vc-executor/src/main/resources/tb-vc-executor.yml index a92a7eea2b..a55a0e73dc 100644 --- a/msa/vc-executor/src/main/resources/tb-vc-executor.yml +++ b/msa/vc-executor/src/main/resources/tb-vc-executor.yml @@ -132,13 +132,13 @@ queue: # value: "${TB_QUEUE_KAFKA_SESSION_TIMEOUT_MS:10000}" # (10 seconds) topic-properties: # Kafka properties for Core topics - core: "${TB_QUEUE_KAFKA_CORE_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:52428800;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" # Kafka properties for Notifications topics - notifications: "${TB_QUEUE_KAFKA_NOTIFICATIONS_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" + notifications: "${TB_QUEUE_KAFKA_NOTIFICATIONS_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" # Kafka properties for Core topics - version-control: "${TB_QUEUE_KAFKA_VC_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" + version-control: "${TB_QUEUE_KAFKA_VC_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" # Kafka properties for Housekeeper tasks topic - housekeeper: "${TB_QUEUE_KAFKA_HOUSEKEEPER_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:10;min.insync.replicas:1}" + housekeeper: "${TB_QUEUE_KAFKA_HOUSEKEEPER_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:10;min.insync.replicas:1}" consumer-stats: # Prints lag between consumer group offset and last messages offset in Kafka topics enabled: "${TB_QUEUE_KAFKA_CONSUMER_STATS_ENABLED:true}" diff --git a/transport/coap/src/main/resources/tb-coap-transport.yml b/transport/coap/src/main/resources/tb-coap-transport.yml index 17a6a53aca..fbd64b8d59 100644 --- a/transport/coap/src/main/resources/tb-coap-transport.yml +++ b/transport/coap/src/main/resources/tb-coap-transport.yml @@ -292,15 +292,15 @@ queue: # value: "${TB_QUEUE_KAFKA_SESSION_TIMEOUT_MS:10000}" # (10 seconds) topic-properties: # Kafka properties for Rule Engine - rule-engine: "${TB_QUEUE_KAFKA_RE_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" + rule-engine: "${TB_QUEUE_KAFKA_RE_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" # Kafka properties for Core topics - core: "${TB_QUEUE_KAFKA_CORE_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:52428800;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" # Kafka properties for Transport Api topics - transport-api: "${TB_QUEUE_KAFKA_TA_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:10;min.insync.replicas:1}" + transport-api: "${TB_QUEUE_KAFKA_TA_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:10;min.insync.replicas:1}" # Kafka properties for Notifications topics - notifications: "${TB_QUEUE_KAFKA_NOTIFICATIONS_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" + notifications: "${TB_QUEUE_KAFKA_NOTIFICATIONS_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" # Kafka properties for Housekeeper tasks topic - housekeeper: "${TB_QUEUE_KAFKA_HOUSEKEEPER_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:10;min.insync.replicas:1}" + housekeeper: "${TB_QUEUE_KAFKA_HOUSEKEEPER_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:10;min.insync.replicas:1}" aws_sqs: # Use the default credentials provider for AWS SQS use_default_credential_provider_chain: "${TB_QUEUE_AWS_SQS_USE_DEFAULT_CREDENTIAL_PROVIDER_CHAIN:false}" diff --git a/transport/http/src/main/resources/tb-http-transport.yml b/transport/http/src/main/resources/tb-http-transport.yml index 4252dfac06..9a5615478d 100644 --- a/transport/http/src/main/resources/tb-http-transport.yml +++ b/transport/http/src/main/resources/tb-http-transport.yml @@ -265,15 +265,15 @@ queue: # value: "${TB_QUEUE_KAFKA_SESSION_TIMEOUT_MS:10000}" # (10 seconds) topic-properties: # Kafka properties for Rule Engine - rule-engine: "${TB_QUEUE_KAFKA_RE_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" + rule-engine: "${TB_QUEUE_KAFKA_RE_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" # Kafka properties for Core topics - core: "${TB_QUEUE_KAFKA_CORE_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:52428800;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" # Kafka properties for Transport Api topics - transport-api: "${TB_QUEUE_KAFKA_TA_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:10;min.insync.replicas:1}" + transport-api: "${TB_QUEUE_KAFKA_TA_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:10;min.insync.replicas:1}" # Kafka properties for Notifications topics - notifications: "${TB_QUEUE_KAFKA_NOTIFICATIONS_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" + notifications: "${TB_QUEUE_KAFKA_NOTIFICATIONS_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" # Kafka properties for Housekeeper tasks topic - housekeeper: "${TB_QUEUE_KAFKA_HOUSEKEEPER_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:10;min.insync.replicas:1}" + housekeeper: "${TB_QUEUE_KAFKA_HOUSEKEEPER_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:10;min.insync.replicas:1}" aws_sqs: # Use default credentials provider for AWS SQS use_default_credential_provider_chain: "${TB_QUEUE_AWS_SQS_USE_DEFAULT_CREDENTIAL_PROVIDER_CHAIN:false}" diff --git a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml index cf1ba51240..6f6d173aa6 100644 --- a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml +++ b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml @@ -370,15 +370,15 @@ queue: # value: "${TB_QUEUE_KAFKA_SESSION_TIMEOUT_MS:10000}" # (10 seconds) topic-properties: # Kafka properties for Rule Engine - rule-engine: "${TB_QUEUE_KAFKA_RE_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" + rule-engine: "${TB_QUEUE_KAFKA_RE_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" # Kafka properties for Core topics - core: "${TB_QUEUE_KAFKA_CORE_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:52428800;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" # Kafka properties for Transport Api topics - transport-api: "${TB_QUEUE_KAFKA_TA_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:10;min.insync.replicas:1}" + transport-api: "${TB_QUEUE_KAFKA_TA_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:10;min.insync.replicas:1}" # Kafka properties for Notifications topics - notifications: "${TB_QUEUE_KAFKA_NOTIFICATIONS_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" + notifications: "${TB_QUEUE_KAFKA_NOTIFICATIONS_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" # Kafka properties for Housekeeper tasks topic - housekeeper: "${TB_QUEUE_KAFKA_HOUSEKEEPER_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:10;min.insync.replicas:1}" + housekeeper: "${TB_QUEUE_KAFKA_HOUSEKEEPER_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:10;min.insync.replicas:1}" aws_sqs: # Use the default credentials provider for AWS SQS use_default_credential_provider_chain: "${TB_QUEUE_AWS_SQS_USE_DEFAULT_CREDENTIAL_PROVIDER_CHAIN:false}" diff --git a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml index d210138852..3cef42b5d0 100644 --- a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml +++ b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml @@ -297,15 +297,15 @@ queue: # value: "${TB_QUEUE_KAFKA_SESSION_TIMEOUT_MS:10000}" # (10 seconds) topic-properties: # Kafka properties for Rule Engine - rule-engine: "${TB_QUEUE_KAFKA_RE_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" + rule-engine: "${TB_QUEUE_KAFKA_RE_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" # Kafka properties for Core topics - core: "${TB_QUEUE_KAFKA_CORE_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:52428800;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" # Kafka properties for Transport Api topics - transport-api: "${TB_QUEUE_KAFKA_TA_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:10;min.insync.replicas:1}" + transport-api: "${TB_QUEUE_KAFKA_TA_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:10;min.insync.replicas:1}" # Kafka properties for Notifications topics - notifications: "${TB_QUEUE_KAFKA_NOTIFICATIONS_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" + notifications: "${TB_QUEUE_KAFKA_NOTIFICATIONS_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" # Kafka properties for Housekeeper tasks topic - housekeeper: "${TB_QUEUE_KAFKA_HOUSEKEEPER_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:10;min.insync.replicas:1}" + housekeeper: "${TB_QUEUE_KAFKA_HOUSEKEEPER_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:10;min.insync.replicas:1}" aws_sqs: # Use the default credentials provider for AWS SQS use_default_credential_provider_chain: "${TB_QUEUE_AWS_SQS_USE_DEFAULT_CREDENTIAL_PROVIDER_CHAIN:false}" diff --git a/transport/snmp/src/main/resources/tb-snmp-transport.yml b/transport/snmp/src/main/resources/tb-snmp-transport.yml index 76f6abdb61..85a125b351 100644 --- a/transport/snmp/src/main/resources/tb-snmp-transport.yml +++ b/transport/snmp/src/main/resources/tb-snmp-transport.yml @@ -247,15 +247,15 @@ queue: # value: "${TB_QUEUE_KAFKA_SESSION_TIMEOUT_MS:10000}" # (10 seconds) topic-properties: # Kafka properties for Rule Engine - rule-engine: "${TB_QUEUE_KAFKA_RE_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" + rule-engine: "${TB_QUEUE_KAFKA_RE_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" # Kafka properties for Core topics - core: "${TB_QUEUE_KAFKA_CORE_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:52428800;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" # Kafka properties for Transport Api topics - transport-api: "${TB_QUEUE_KAFKA_TA_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:10;min.insync.replicas:1}" + transport-api: "${TB_QUEUE_KAFKA_TA_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:10;min.insync.replicas:1}" # Kafka properties for Notifications topics - notifications: "${TB_QUEUE_KAFKA_NOTIFICATIONS_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" + notifications: "${TB_QUEUE_KAFKA_NOTIFICATIONS_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" # Kafka properties for Housekeeper tasks topic - housekeeper: "${TB_QUEUE_KAFKA_HOUSEKEEPER_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:26214400;retention.bytes:1048576000;partitions:10;min.insync.replicas:1}" + housekeeper: "${TB_QUEUE_KAFKA_HOUSEKEEPER_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:10;min.insync.replicas:1}" consumer-stats: # Prints lag between consumer group offset and last messages offset in Kafka topics enabled: "${TB_QUEUE_KAFKA_CONSUMER_STATS_ENABLED:true}" From ffbb2c7c73e9443acb5a90f44a678f626c5b927c Mon Sep 17 00:00:00 2001 From: rusikv Date: Fri, 10 May 2024 16:46:32 +0300 Subject: [PATCH 44/84] UI: refactor mobile app qr code widgets validationm, cleanup widgets json --- .../widget_types/home_mobile_app_qr_code.json | 2 +- .../widget_types/mobile_app_qr_code.json | 2 +- ...app-qr-code-widget-settings.component.html | 9 ++ ...e-app-qr-code-widget-settings.component.ts | 106 +++++-------- .../admin/mobile-app-settings.component.html | 9 ++ .../admin/mobile-app-settings.component.ts | 149 +++++++++--------- .../assets/locale/locale.constant-en_US.json | 1 + 7 files changed, 131 insertions(+), 147 deletions(-) diff --git a/application/src/main/data/json/system/widget_types/home_mobile_app_qr_code.json b/application/src/main/data/json/system/widget_types/home_mobile_app_qr_code.json index e8e21f5d89..908533b279 100644 --- a/application/src/main/data/json/system/widget_types/home_mobile_app_qr_code.json +++ b/application/src/main/data/json/system/widget_types/home_mobile_app_qr_code.json @@ -15,7 +15,7 @@ "settingsSchema": "", "dataKeySettingsSchema": "", "settingsDirective": "", - "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\":\"Home page mobile app QR code\",\"dropShadow\":false,\"enableFullscreen\":false,\"widgetStyle\":{},\"widgetCss\":\"\",\"pageSize\":1024,\"noDataDisplayMessage\":\"\",\"showTitleIcon\":false,\"titleTooltip\":\"\",\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}" + "defaultConfig": "{\"showTitle\":false,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Home page mobile app QR code\",\"dropShadow\":true}" }, "tags": [ "ios", diff --git a/application/src/main/data/json/system/widget_types/mobile_app_qr_code.json b/application/src/main/data/json/system/widget_types/mobile_app_qr_code.json index 223678bbdd..ccb9bea73f 100644 --- a/application/src/main/data/json/system/widget_types/mobile_app_qr_code.json +++ b/application/src/main/data/json/system/widget_types/mobile_app_qr_code.json @@ -15,7 +15,7 @@ "settingsSchema": "", "dataKeySettingsSchema": "", "settingsDirective": "tb-mobile-app-qr-code-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\":{\"useDefaultApp\":true,\"androidConfig\":{\"enabled\":true,\"appPackage\":\"\",\"sha256CertFingerprints\":\"\"},\"iosConfig\":{\"enabled\":true,\"appId\":\"\"},\"qrCodeConfig\":{\"badgeEnabled\":true,\"badgeStyle\":\"ORIGINAL\",\"badgePosition\":\"RIGHT\",\"qrCodeLabelEnabled\":true,\"qrCodeLabel\":\"Scan to connect or download mobile app\"}},\"title\":\"Mobile app QR code\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"widgetCss\":\"\",\"pageSize\":1024,\"noDataDisplayMessage\":\"\",\"showTitleIcon\":false,\"titleTooltip\":\"\",\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}" + "defaultConfig": "{\n \"showTitle\": true,\n \"backgroundColor\": \"rgb(255, 255, 255)\",\n \"color\": \"rgba(0, 0, 0, 0.87)\",\n \"padding\": \"8px\",\n \"settings\": {\n \"useDefaultApp\": true,\n \"androidConfig\": {\n \"enabled\": true,\n \"appPackage\": \"\",\n \"sha256CertFingerprints\": \"\"\n },\n \"iosConfig\": {\n \"enabled\": true,\n \"appId\": \"\"\n },\n \"qrCodeConfig\": {\n \"badgeEnabled\": true,\n \"badgeStyle\": \"ORIGINAL\",\n \"badgePosition\": \"RIGHT\",\n \"qrCodeLabelEnabled\": true,\n \"qrCodeLabel\": \"Scan to connect or download mobile app\"\n }\n },\n \"title\": \"Mobile app QR code\",\n \"dropShadow\": true,\n \"enableFullscreen\": false,\n \"widgetStyle\": {},\n \"widgetCss\": \"\",\n \"pageSize\": 1024,\n \"noDataDisplayMessage\": \"\",\n \"showTitleIcon\": false,\n \"titleTooltip\": \"\",\n \"titleStyle\": {\n \"fontSize\": \"16px\",\n \"fontWeight\": 400\n }\n}" }, "tags": [ "ios", diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.html index cdef956141..d474714216 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.html @@ -123,6 +123,15 @@ + + warning +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.ts index 662a9d3dd3..664dbe3327 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.ts @@ -49,22 +49,22 @@ export class MobileAppQrCodeWidgetSettingsComponent extends WidgetSettingsCompon protected onSettingsSet(settings: WidgetSettings) { this.mobileAppQRCodeWidgetSettingsForm = this.fb.group({ - useDefaultApp: [settings.useDefaultApp, []], + useDefaultApp: [settings.useDefaultApp], androidConfig: this.fb.group({ - enabled: [settings.androidConfig.enabled, []], - appPackage: [settings.androidConfig.appPackage, []], - sha256CertFingerprints: [settings.androidConfig.sha256CertFingerprints, []] + enabled: [settings.androidConfig.enabled], + appPackage: [settings.androidConfig.appPackage, [Validators.required]], + sha256CertFingerprints: [settings.androidConfig.sha256CertFingerprints, [Validators.required]] }), iosConfig: this.fb.group({ - enabled: [settings.iosConfig.enabled, []], - appId: [settings.iosConfig.appId, []] + enabled: [settings.iosConfig.enabled], + appId: [settings.iosConfig.appId, [Validators.required]] }), qrCodeConfig: this.fb.group({ - badgeEnabled: [settings.qrCodeConfig.badgeEnabled, []], - badgeStyle: [{value: settings.qrCodeConfig.badgeStyle, disabled: true}, []], - badgePosition: [{value: settings.qrCodeConfig.badgePosition, disabled: true}, []], - qrCodeLabelEnabled: [settings.qrCodeConfig.qrCodeLabelEnabled, []], - qrCodeLabel: [settings.qrCodeConfig.qrCodeLabel, []] + badgeEnabled: [settings.qrCodeConfig.badgeEnabled], + badgeStyle: [{value: settings.qrCodeConfig.badgeStyle, disabled: true}], + badgePosition: [{value: settings.qrCodeConfig.badgePosition, disabled: true}], + qrCodeLabelEnabled: [settings.qrCodeConfig.qrCodeLabelEnabled], + qrCodeLabel: [settings.qrCodeConfig.qrCodeLabel, [Validators.required]] }) }); } @@ -81,76 +81,46 @@ export class MobileAppQrCodeWidgetSettingsComponent extends WidgetSettingsCompon const qrCodeLabelEnabled = this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.qrCodeLabelEnabled').value; if (useDefaultApp) { - this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.appPackage').clearValidators(); - this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.sha256CertFingerprints').clearValidators(); - this.mobileAppQRCodeWidgetSettingsForm.get('iosConfig.appId').clearValidators(); + this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.appPackage').disable({emitEvent: false}); + this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.sha256CertFingerprints').disable({emitEvent: false}); + this.mobileAppQRCodeWidgetSettingsForm.get('iosConfig.appId').disable({emitEvent: false}); } else { - this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.appPackage').setValidators([Validators.required]); - this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.sha256CertFingerprints').setValidators([Validators.required]); - this.mobileAppQRCodeWidgetSettingsForm.get('iosConfig.appId').setValidators([Validators.required]); - } - - if (androidEnabled) { - if (!useDefaultApp) { - this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.appPackage').setValidators([Validators.required]); - this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.sha256CertFingerprints').setValidators([Validators.required]); + if (androidEnabled) { + this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.appPackage').enable({emitEvent: false}); + this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.sha256CertFingerprints').enable({emitEvent: false}); + } else { + this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.appPackage').disable({emitEvent: false}); + this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.sha256CertFingerprints').disable({emitEvent: false}); } - this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeEnabled').enable({emitEvent: false}); - if (badgeEnabled) { - this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeStyle').enable(); - this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgePosition').enable(); - } - } else { - if (!useDefaultApp) { - this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.appPackage').clearValidators(); - this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.sha256CertFingerprints').clearValidators(); - } - if (!iosEnabled) { - this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeEnabled').disable({emitEvent: false}); - this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeStyle').disable(); - this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgePosition').disable(); + if (iosEnabled) { + this.mobileAppQRCodeWidgetSettingsForm.get('iosConfig.appId').enable({emitEvent: false}); + } else { + this.mobileAppQRCodeWidgetSettingsForm.get('iosConfig.appId').disable({emitEvent: false}); } } - if (iosEnabled) { - if (!useDefaultApp) { - this.mobileAppQRCodeWidgetSettingsForm.get('iosConfig.appId').setValidators([Validators.required]); - } - this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeEnabled').enable({emitEvent: false}); - if (badgeEnabled) { - this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeStyle').enable(); - this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgePosition').enable(); - } - } else { - if (!useDefaultApp) { - this.mobileAppQRCodeWidgetSettingsForm.get('iosConfig.appId').clearValidators(); - } - if (!androidEnabled) { - this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeEnabled').disable({emitEvent: false}); - this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeStyle').disable(); - this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgePosition').disable(); - } + if (!androidEnabled && !iosEnabled) { + this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeEnabled').disable({emitEvent: false}); + this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeStyle').disable({emitEvent: false}); + this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgePosition').disable({emitEvent: false}); } - if (badgeEnabled) { - if (androidEnabled || iosEnabled) { - this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeStyle').enable(); - this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgePosition').enable(); + if (androidEnabled || iosEnabled) { + this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeEnabled').enable({emitEvent: false}); + if (badgeEnabled) { + this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeStyle').enable({emitEvent: false}); + this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgePosition').enable({emitEvent: false}); + } else { + this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeStyle').disable({emitEvent: false}); + this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgePosition').disable({emitEvent: false}); } - } else { - this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeStyle').disable(); - this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgePosition').disable(); } if (qrCodeLabelEnabled) { - this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.qrCodeLabel').enable(); + this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.qrCodeLabel').enable({emitEvent: false}); } else { - this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.qrCodeLabel').disable(); + this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.qrCodeLabel').disable({emitEvent: false}); } - - this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.appPackage').updateValueAndValidity({emitEvent}); - this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.sha256CertFingerprints').updateValueAndValidity({emitEvent}); - this.mobileAppQRCodeWidgetSettingsForm.get('iosConfig.appId').updateValueAndValidity({emitEvent}); } } diff --git a/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.html b/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.html index 5b1cb710e0..88c6c287d5 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.html @@ -140,6 +140,15 @@ + + warning +
diff --git a/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.ts b/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.ts index 7959b1b800..96e8e89e6a 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.ts @@ -29,7 +29,6 @@ import { badgeStyleTranslationsMap, MobileAppQRCodeSettings } from '@shared/models/mobile-app.models'; -import { AuthService } from '@core/auth/auth.service'; @Component({ selector: 'tb-mobile-app-settings', @@ -58,95 +57,49 @@ export class MobileAppSettingsComponent extends PageComponent implements HasConf takeUntil(this.destroy$) ).subscribe(value => { if (value) { - this.mobileAppSettingsForm.get('androidConfig.appPackage').clearValidators(); - this.mobileAppSettingsForm.get('androidConfig.sha256CertFingerprints').clearValidators(); - this.mobileAppSettingsForm.get('iosConfig.appId').clearValidators(); + this.mobileAppSettingsForm.get('androidConfig.appPackage').disable({emitEvent: false}); + this.mobileAppSettingsForm.get('androidConfig.sha256CertFingerprints').disable({emitEvent: false}); + this.mobileAppSettingsForm.get('iosConfig.appId').disable({emitEvent: false}); } else { - this.mobileAppSettingsForm.get('androidConfig.appPackage').setValidators([Validators.required]); - this.mobileAppSettingsForm.get('androidConfig.sha256CertFingerprints').setValidators([Validators.required]); - this.mobileAppSettingsForm.get('iosConfig.appId').setValidators([Validators.required]); + if (this.mobileAppSettingsForm.get('androidConfig.enabled').value) { + this.mobileAppSettingsForm.get('androidConfig.appPackage').enable({emitEvent: false}); + this.mobileAppSettingsForm.get('androidConfig.sha256CertFingerprints').enable({emitEvent: false}); + } + if (this.mobileAppSettingsForm.get('iosConfig.enabled').value) { + this.mobileAppSettingsForm.get('iosConfig.appId').enable({emitEvent: false}); + } } - this.mobileAppSettingsForm.get('androidConfig.appPackage').updateValueAndValidity(); - this.mobileAppSettingsForm.get('androidConfig.sha256CertFingerprints').updateValueAndValidity(); - this.mobileAppSettingsForm.get('iosConfig.appId').updateValueAndValidity(); }); this.mobileAppSettingsForm.get('androidConfig.enabled').valueChanges.pipe( takeUntil(this.destroy$) ).subscribe(value => { - if (!this.mobileAppSettingsForm.get('useDefaultApp').value) { - if (value) { - this.mobileAppSettingsForm.get('androidConfig.appPackage').setValidators([Validators.required]); - this.mobileAppSettingsForm.get('androidConfig.sha256CertFingerprints').setValidators([Validators.required]); - } else { - this.mobileAppSettingsForm.get('androidConfig.appPackage').clearValidators(); - this.mobileAppSettingsForm.get('androidConfig.sha256CertFingerprints').clearValidators(); - } - this.mobileAppSettingsForm.get('androidConfig.appPackage').updateValueAndValidity(); - this.mobileAppSettingsForm.get('androidConfig.sha256CertFingerprints').updateValueAndValidity(); - } - if (this.mobileAppSettingsForm.get('qrCodeConfig.showOnHomePage').value) { - if (value) { - this.mobileAppSettingsForm.get('qrCodeConfig.badgeEnabled').enable(); - if (this.mobileAppSettingsForm.get('qrCodeConfig.badgeEnabled').value) { - this.mobileAppSettingsForm.get('qrCodeConfig.badgeStyle').enable(); - this.mobileAppSettingsForm.get('qrCodeConfig.badgePosition').enable(); - } - } else { - if (!this.mobileAppSettingsForm.get('iosConfig.enabled').value) { - this.mobileAppSettingsForm.get('qrCodeConfig.badgeEnabled').disable(); - this.mobileAppSettingsForm.get('qrCodeConfig.badgeStyle').disable(); - this.mobileAppSettingsForm.get('qrCodeConfig.badgePosition').disable(); - } - } - } + this.androidEnableChanged(value); }); this.mobileAppSettingsForm.get('iosConfig.enabled').valueChanges.pipe( takeUntil(this.destroy$) ).subscribe(value => { - if (!this.mobileAppSettingsForm.get('useDefaultApp').value) { - if (value) { - this.mobileAppSettingsForm.get('iosConfig.appId').setValidators([Validators.required]); - } else { - this.mobileAppSettingsForm.get('iosConfig.appId').clearValidators(); - } - this.mobileAppSettingsForm.get('iosConfig.appId').updateValueAndValidity(); - } - if (this.mobileAppSettingsForm.get('qrCodeConfig.showOnHomePage').value) { - if (value) { - this.mobileAppSettingsForm.get('qrCodeConfig.badgeEnabled').enable(); - if (this.mobileAppSettingsForm.get('qrCodeConfig.badgeEnabled').value) { - this.mobileAppSettingsForm.get('qrCodeConfig.badgeStyle').enable(); - this.mobileAppSettingsForm.get('qrCodeConfig.badgePosition').enable(); - } - } else { - if (!this.mobileAppSettingsForm.get('androidConfig.enabled').value) { - this.mobileAppSettingsForm.get('qrCodeConfig.badgeEnabled').disable(); - this.mobileAppSettingsForm.get('qrCodeConfig.badgeStyle').disable(); - this.mobileAppSettingsForm.get('qrCodeConfig.badgePosition').disable(); - } - } - } + this.iosEnableChanged(value); }); this.mobileAppSettingsForm.get('qrCodeConfig.badgeEnabled').valueChanges.pipe( takeUntil(this.destroy$) ).subscribe(value => { if (value) { if (this.mobileAppSettingsForm.get('androidConfig.enabled').value || this.mobileAppSettingsForm.get('iosConfig.enabled').value) { - this.mobileAppSettingsForm.get('qrCodeConfig.badgeStyle').enable(); - this.mobileAppSettingsForm.get('qrCodeConfig.badgePosition').enable(); + this.mobileAppSettingsForm.get('qrCodeConfig.badgeStyle').enable({emitEvent: false}); + this.mobileAppSettingsForm.get('qrCodeConfig.badgePosition').enable({emitEvent: false}); } } else { - this.mobileAppSettingsForm.get('qrCodeConfig.badgeStyle').disable(); - this.mobileAppSettingsForm.get('qrCodeConfig.badgePosition').disable(); + this.mobileAppSettingsForm.get('qrCodeConfig.badgeStyle').disable({emitEvent: false}); + this.mobileAppSettingsForm.get('qrCodeConfig.badgePosition').disable({emitEvent: false}); } }); this.mobileAppSettingsForm.get('qrCodeConfig.qrCodeLabelEnabled').valueChanges.pipe( takeUntil(this.destroy$) ).subscribe(value => { if (value) { - this.mobileAppSettingsForm.get('qrCodeConfig.qrCodeLabel').enable(); + this.mobileAppSettingsForm.get('qrCodeConfig.qrCodeLabel').enable({emitEvent: false}); } else { - this.mobileAppSettingsForm.get('qrCodeConfig.qrCodeLabel').disable(); + this.mobileAppSettingsForm.get('qrCodeConfig.qrCodeLabel').disable({emitEvent: false}); } }); } @@ -159,23 +112,23 @@ export class MobileAppSettingsComponent extends PageComponent implements HasConf private buildMobileAppSettingsForm() { this.mobileAppSettingsForm = this.fb.group({ - useDefaultApp: [true, []], + useDefaultApp: [true], androidConfig: this.fb.group({ - enabled: [true, []], - appPackage: ['', []], - sha256CertFingerprints: ['', []] + enabled: [true], + appPackage: [{value: '', disabled: true}, [Validators.required]], + sha256CertFingerprints: [{value: '', disabled: true}, [Validators.required]] }), iosConfig: this.fb.group({ - enabled: [true, []], - appId: ['', []] + enabled: [true], + appId: [{value: '', disabled: true}, [Validators.required]] }), qrCodeConfig: this.fb.group({ - showOnHomePage: [true, []], - badgeEnabled: [true, []], - badgeStyle: [{value: BadgeStyle.ORIGINAL, disabled: true}, []], - badgePosition: [{value: BadgePosition.RIGHT, disabled: true}, []], - qrCodeLabelEnabled: [true, []], - qrCodeLabel: ['', []] + showOnHomePage: [true], + badgeEnabled: [true], + badgeStyle: [{value: BadgeStyle.ORIGINAL, disabled: true}], + badgePosition: [{value: BadgePosition.RIGHT, disabled: true}], + qrCodeLabelEnabled: [true], + qrCodeLabel: ['', [Validators.required]] }) }); } @@ -185,6 +138,48 @@ export class MobileAppSettingsComponent extends PageComponent implements HasConf this.mobileAppSettingsForm.reset(this.mobileAppSettings); } + private androidEnableChanged(value: boolean): void { + if (value) { + if (!this.mobileAppSettingsForm.get('useDefaultApp').value) { + this.mobileAppSettingsForm.get('androidConfig.appPackage').enable({emitEvent: false}); + this.mobileAppSettingsForm.get('androidConfig.sha256CertFingerprints').enable({emitEvent: false}); + } + } else { + this.mobileAppSettingsForm.get('androidConfig.appPackage').disable({emitEvent: false}); + this.mobileAppSettingsForm.get('androidConfig.sha256CertFingerprints').disable({emitEvent: false}); + } + this.updateBadgeControls(value); + } + + private iosEnableChanged(value: boolean): void { + if (value) { + if (!this.mobileAppSettingsForm.get('useDefaultApp').value) { + this.mobileAppSettingsForm.get('iosConfig.appId').enable({emitEvent: false}); + } + } else { + this.mobileAppSettingsForm.get('iosConfig.appId').disable({emitEvent: false}); + } + this.updateBadgeControls(value); + } + + private updateBadgeControls(value: boolean) { + if (this.mobileAppSettingsForm.get('qrCodeConfig.showOnHomePage').value) { + if (value) { + this.mobileAppSettingsForm.get('qrCodeConfig.badgeEnabled').enable({emitEvent: false}); + if (this.mobileAppSettingsForm.get('qrCodeConfig.badgeEnabled').value) { + this.mobileAppSettingsForm.get('qrCodeConfig.badgeStyle').enable({emitEvent: false}); + this.mobileAppSettingsForm.get('qrCodeConfig.badgePosition').enable({emitEvent: false}); + } + } else { + if (!this.mobileAppSettingsForm.get('iosConfig.enabled').value && !this.mobileAppSettingsForm.get('androidConfig.enabled').value) { + this.mobileAppSettingsForm.get('qrCodeConfig.badgeEnabled').disable({emitEvent: false}); + this.mobileAppSettingsForm.get('qrCodeConfig.badgeStyle').disable({emitEvent: false}); + this.mobileAppSettingsForm.get('qrCodeConfig.badgePosition').disable({emitEvent: false}); + } + } + } + } + save(): void { this.mobileAppSettings = {...this.mobileAppSettings, ...this.mobileAppSettingsForm.getRawValue()}; this.mobileAppService.saveMobileAppSettings(this.mobileAppSettings) diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index a78067e830..d79b468a8e 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -440,6 +440,7 @@ "original": "Original", "white": "White", "label": "Label", + "label-required": "Label is required", "right": "Right", "left": "Left", "set": "Set", From 9d71bb77ea396839c4e4685871d1c5a6a670de23 Mon Sep 17 00:00:00 2001 From: rusikv Date: Fri, 10 May 2024 18:06:11 +0300 Subject: [PATCH 45/84] UI: clean up sysadmin, tenant home pages jsons --- .../assets/dashboard/sys_admin_home_page.json | 62 +------------------ .../dashboard/tenant_admin_home_page.json | 61 +----------------- 2 files changed, 2 insertions(+), 121 deletions(-) diff --git a/ui-ngx/src/assets/dashboard/sys_admin_home_page.json b/ui-ngx/src/assets/dashboard/sys_admin_home_page.json index 43998afbcb..4984de6e29 100644 --- a/ui-ngx/src/assets/dashboard/sys_admin_home_page.json +++ b/ui-ngx/src/assets/dashboard/sys_admin_home_page.json @@ -2614,12 +2614,6 @@ "sizeX": 7.5, "sizeY": 6.5, "config": { - "datasources": [], - "timewindow": { - "realtime": { - "timewindowMs": 60000 - } - }, "showTitle": false, "backgroundColor": "rgb(255, 255, 255)", "color": "rgba(0, 0, 0, 0.87)", @@ -2632,9 +2626,7 @@ "enableFullscreen": false, "widgetStyle": {}, "widgetCss": "", - "pageSize": 1024, - "noDataDisplayMessage": "", - "showLegend": false + "noDataDisplayMessage": "" }, "row": 0, "col": 0, @@ -2647,28 +2639,6 @@ "sizeX": 6, "sizeY": 3, "config": { - "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)", @@ -2679,7 +2649,6 @@ "enableFullscreen": false, "widgetStyle": {}, "widgetCss": "", - "pageSize": 1024, "noDataDisplayMessage": "" }, "row": 0, @@ -2692,31 +2661,6 @@ "sizeX": 5, "sizeY": 3.5, "config": { - "datasources": [], - "timewindow": { - "displayValue": "", - "selectedTab": 0, - "realtime": { - "realtimeType": 1, - "interval": 1000, - "timewindowMs": 60000, - "quickInterval": "CURRENT_DAY" - }, - "history": { - "historyType": 0, - "interval": 1000, - "timewindowMs": 60000, - "fixedTimewindow": { - "startTimeMs": 1715012840937, - "endTimeMs": 1715099240937 - }, - "quickInterval": "CURRENT_DAY" - }, - "aggregation": { - "type": "AVG", - "limit": 25000 - } - }, "showTitle": false, "backgroundColor": "#fff", "color": "rgba(0, 0, 0, 0.87)", @@ -2744,11 +2688,7 @@ "fontSize": "16px", "fontWeight": 400 }, - "showLegend": false, - "useDashboardTimewindow": true, - "displayTimewindow": true, "widgetCss": "", - "pageSize": 1024, "noDataDisplayMessage": "" }, "row": 0, diff --git a/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json b/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json index cb42df7839..6c1bed741e 100644 --- a/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json +++ b/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json @@ -1118,31 +1118,6 @@ "sizeX": 5, "sizeY": 3.5, "config": { - "datasources": [], - "timewindow": { - "displayValue": "", - "selectedTab": 0, - "realtime": { - "realtimeType": 1, - "interval": 1000, - "timewindowMs": 60000, - "quickInterval": "CURRENT_DAY" - }, - "history": { - "historyType": 0, - "interval": 1000, - "timewindowMs": 60000, - "fixedTimewindow": { - "startTimeMs": 1715005524783, - "endTimeMs": 1715091924783 - }, - "quickInterval": "CURRENT_DAY" - }, - "aggregation": { - "type": "AVG", - "limit": 25000 - } - }, "showTitle": false, "backgroundColor": "#fff", "color": "rgba(0, 0, 0, 0.87)", @@ -1170,11 +1145,7 @@ "fontSize": "16px", "fontWeight": 400 }, - "showLegend": false, - "useDashboardTimewindow": true, - "displayTimewindow": true, "widgetCss": "", - "pageSize": 1024, "noDataDisplayMessage": "" }, "row": 0, @@ -1186,12 +1157,6 @@ "sizeX": 7.5, "sizeY": 6.5, "config": { - "datasources": [], - "timewindow": { - "realtime": { - "timewindowMs": 60000 - } - }, "showTitle": false, "backgroundColor": "rgb(255, 255, 255)", "color": "rgba(0, 0, 0, 0.87)", @@ -1204,9 +1169,7 @@ "enableFullscreen": false, "widgetStyle": {}, "widgetCss": "", - "pageSize": 1024, - "noDataDisplayMessage": "", - "showLegend": false + "noDataDisplayMessage": "" }, "row": 0, "col": 0, @@ -1219,28 +1182,6 @@ "sizeX": 6, "sizeY": 3, "config": { - "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)", From 87ed660fc922734880d29311158c9e7e53a3d91a Mon Sep 17 00:00:00 2001 From: YevhenBondarenko Date: Sun, 12 May 2024 22:56:45 +0200 Subject: [PATCH 46/84] fixed swagger allowableValues --- .../server/controller/ComponentDescriptorController.java | 6 +++--- .../thingsboard/server/controller/OtaPackageController.java | 2 +- .../data/sms/config/SmppSmsProviderConfiguration.java | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/ComponentDescriptorController.java b/application/src/main/java/org/thingsboard/server/controller/ComponentDescriptorController.java index 5271de2329..6967b3c13b 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ComponentDescriptorController.java +++ b/application/src/main/java/org/thingsboard/server/controller/ComponentDescriptorController.java @@ -68,9 +68,9 @@ public class ComponentDescriptorController extends BaseController { @RequestMapping(value = "/components/{componentType}", method = RequestMethod.GET) @ResponseBody public List getComponentDescriptorsByType( - @Parameter(description = "Type of the Rule Node", schema = @Schema(allowableValues = "ENRICHMENT,FILTER,TRANSFORMATION,ACTION,EXTERNAL", requiredMode = Schema.RequiredMode.REQUIRED)) + @Parameter(description = "Type of the Rule Node", schema = @Schema(allowableValues = {"ENRICHMENT", "FILTER,TRANSFORMATION", "ACTION,EXTERNAL"}, requiredMode = Schema.RequiredMode.REQUIRED)) @PathVariable("componentType") String strComponentType, - @Parameter(description = "Type of the Rule Chain", schema = @Schema(allowableValues = "CORE,EDGE")) + @Parameter(description = "Type of the Rule Chain", schema = @Schema(allowableValues = {"CORE", "EDGE"})) @RequestParam(value = "ruleChainType", required = false) String strRuleChainType) throws ThingsboardException { checkParameter("componentType", strComponentType); return checkComponentDescriptorsByType(ComponentType.valueOf(strComponentType), getRuleChainType(strRuleChainType)); @@ -85,7 +85,7 @@ public class ComponentDescriptorController extends BaseController { public List getComponentDescriptorsByTypes( @Parameter(description = "List of types of the Rule Nodes, (ENRICHMENT, FILTER, TRANSFORMATION, ACTION or EXTERNAL)", required = true) @RequestParam("componentTypes") String[] strComponentTypes, - @Parameter(description = "Type of the Rule Chain", schema = @Schema(allowableValues = "CORE,EDGE")) + @Parameter(description = "Type of the Rule Chain", schema = @Schema(allowableValues = {"CORE", "EDGE"})) @RequestParam(value = "ruleChainType", required = false) String strRuleChainType) throws ThingsboardException { checkArrayParameter("componentTypes", strComponentTypes); Set componentTypes = new HashSet<>(); diff --git a/application/src/main/java/org/thingsboard/server/controller/OtaPackageController.java b/application/src/main/java/org/thingsboard/server/controller/OtaPackageController.java index f2b77c3b7b..ee7bfcc0dc 100644 --- a/application/src/main/java/org/thingsboard/server/controller/OtaPackageController.java +++ b/application/src/main/java/org/thingsboard/server/controller/OtaPackageController.java @@ -198,7 +198,7 @@ public class OtaPackageController extends BaseController { @ResponseBody public PageData getOtaPackages(@Parameter(description = DEVICE_PROFILE_ID_PARAM_DESCRIPTION) @PathVariable("deviceProfileId") String strDeviceProfileId, - @Parameter(description = "OTA Package type.", schema = @Schema(allowableValues = "FIRMWARE, SOFTWARE")) + @Parameter(description = "OTA Package type.", schema = @Schema(allowableValues = {"FIRMWARE", "SOFTWARE"})) @PathVariable("type") String strType, @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sms/config/SmppSmsProviderConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/sms/config/SmppSmsProviderConfiguration.java index 5fc0c14d61..549770c539 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sms/config/SmppSmsProviderConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sms/config/SmppSmsProviderConfiguration.java @@ -89,7 +89,7 @@ public class SmppSmsProviderConfiguration implements SmsProviderConfiguration { @Schema(description = "Address range", requiredMode = Schema.RequiredMode.NOT_REQUIRED) private String addressRange; - @Schema(allowableValues = "0-10,13-14", + @Schema(allowableValues = {"0-10" ,"13-14"}, description = "0 - SMSC Default Alphabet (ASCII for short and long code and to GSM for toll-free, used as default)\n" + "1 - IA5 (ASCII for short and long code, Latin 9 for toll-free (ISO-8859-9))\n" + "2 - Octet Unspecified (8-bit binary)\n" + From e10ba543a0b813eb0dd784d6ba07b7621f0c9c7c Mon Sep 17 00:00:00 2001 From: YevhenBondarenko Date: Mon, 13 May 2024 11:12:20 +0200 Subject: [PATCH 47/84] fixed all allowableValues --- .../thingsboard/server/controller/CustomerController.java | 2 +- .../server/controller/EntityViewController.java | 4 ++-- .../server/controller/NotificationController.java | 8 +++----- .../thingsboard/server/common/data/audit/AuditLog.java | 2 +- .../server/common/data/event/DebugEventFilter.java | 2 +- .../server/common/data/event/LifeCycleEventFilter.java | 2 +- .../common/data/event/RuleNodeDebugEventFilter.java | 2 +- .../server/common/data/plugin/ComponentDescriptor.java | 2 +- .../server/common/data/security/DeviceCredentials.java | 2 +- 9 files changed, 12 insertions(+), 14 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/CustomerController.java b/application/src/main/java/org/thingsboard/server/controller/CustomerController.java index a46cbe7dba..9ee8ae6cf8 100644 --- a/application/src/main/java/org/thingsboard/server/controller/CustomerController.java +++ b/application/src/main/java/org/thingsboard/server/controller/CustomerController.java @@ -164,7 +164,7 @@ public class CustomerController extends BaseController { @RequestParam int page, @Parameter(description = CUSTOMER_TEXT_SEARCH_DESCRIPTION) @RequestParam(required = false) String textSearch, - @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "title", "email", "country, city"})) + @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "title", "email", "country", "city"})) @RequestParam(required = false) String sortProperty, @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) @RequestParam(required = false) String sortOrder) throws ThingsboardException { diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java index 8b33aeeb15..783b451a6d 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java @@ -229,7 +229,7 @@ public class EntityViewController extends BaseController { @RequestParam(required = false) String type, @Parameter(description = ENTITY_VIEW_TEXT_SEARCH_DESCRIPTION) @RequestParam(required = false) String textSearch, - @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "name, type"})) + @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "name", "type"})) @RequestParam(required = false) String sortProperty, @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) @RequestParam(required = false) String sortOrder) throws ThingsboardException { @@ -293,7 +293,7 @@ public class EntityViewController extends BaseController { @RequestParam(required = false) String type, @Parameter(description = ENTITY_VIEW_TEXT_SEARCH_DESCRIPTION) @RequestParam(required = false) String textSearch, - @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "name, type"})) + @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "name", "type"})) @RequestParam(required = false) String sortProperty, @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) @RequestParam(required = false) String sortOrder) throws ThingsboardException { diff --git a/application/src/main/java/org/thingsboard/server/controller/NotificationController.java b/application/src/main/java/org/thingsboard/server/controller/NotificationController.java index 96f38daf35..fb32ebd30b 100644 --- a/application/src/main/java/org/thingsboard/server/controller/NotificationController.java +++ b/application/src/main/java/org/thingsboard/server/controller/NotificationController.java @@ -106,8 +106,6 @@ public class NotificationController extends BaseController { private final NotificationCenter notificationCenter; private final NotificationSettingsService notificationSettingsService; - private static final String DELIVERY_METHOD_ALLOWABLE_VALUES = "WEB,MOBILE_APP"; - @ApiOperation(value = "Get notifications (getNotifications)", notes = "Returns the page of notifications for current user." + NEW_LINE + PAGE_DATA_PARAMETERS + @@ -175,7 +173,7 @@ public class NotificationController extends BaseController { @RequestParam(required = false) String sortOrder, @Parameter(description = "To search for unread notifications only") @RequestParam(defaultValue = "false") boolean unreadOnly, - @Parameter(description = "Delivery method", schema = @Schema(allowableValues = {DELIVERY_METHOD_ALLOWABLE_VALUES})) + @Parameter(description = "Delivery method", schema = @Schema(allowableValues = {"WEB", "MOBILE_APP"})) @RequestParam(defaultValue = "WEB") NotificationDeliveryMethod deliveryMethod, @AuthenticationPrincipal SecurityUser user) throws ThingsboardException { // no permissions @@ -188,7 +186,7 @@ public class NotificationController extends BaseController { AVAILABLE_FOR_ANY_AUTHORIZED_USER) @GetMapping("/notifications/unread/count") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - public Integer getUnreadNotificationsCount(@Parameter(description = "Delivery method", schema = @Schema(allowableValues = {DELIVERY_METHOD_ALLOWABLE_VALUES})) + public Integer getUnreadNotificationsCount(@Parameter(description = "Delivery method", schema = @Schema(allowableValues = {"WEB", "MOBILE_APP"})) @RequestParam(defaultValue = "MOBILE_APP") NotificationDeliveryMethod deliveryMethod, @AuthenticationPrincipal SecurityUser user) { return notificationService.countUnreadNotificationsByRecipientId(user.getTenantId(), deliveryMethod, user.getId()); @@ -211,7 +209,7 @@ public class NotificationController extends BaseController { AVAILABLE_FOR_ANY_AUTHORIZED_USER) @PutMapping("/notifications/read") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - public void markAllNotificationsAsRead(@Parameter(description = "Delivery method", schema = @Schema(allowableValues = {DELIVERY_METHOD_ALLOWABLE_VALUES})) + public void markAllNotificationsAsRead(@Parameter(description = "Delivery method", schema = @Schema(allowableValues = {"WEB", "MOBILE_APP"})) @RequestParam(defaultValue = "WEB") NotificationDeliveryMethod deliveryMethod, @AuthenticationPrincipal SecurityUser user) { // no permissions diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/audit/AuditLog.java b/common/data/src/main/java/org/thingsboard/server/common/data/audit/AuditLog.java index bc7ac85755..fdbbe4436c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/audit/AuditLog.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/audit/AuditLog.java @@ -49,7 +49,7 @@ public class AuditLog extends BaseData { private ActionType actionType; @Schema(description = "JsonNode represented action data", accessMode = Schema.AccessMode.READ_ONLY) private JsonNode actionData; - @Schema(description = "String represented Action status", example = "SUCCESS", allowableValues = "SUCCESS,FAILURE", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "String represented Action status", example = "SUCCESS", allowableValues = {"SUCCESS", "FAILURE"}, accessMode = Schema.AccessMode.READ_ONLY) private ActionStatus actionStatus; @Schema(description = "Failure action details info. An empty string in case of action status type 'SUCCESS', otherwise includes stack trace of the caused exception.", accessMode = Schema.AccessMode.READ_ONLY) private String actionFailureDetails; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/event/DebugEventFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/event/DebugEventFilter.java index c810dfdbb2..17c94cc4e1 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/event/DebugEventFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/event/DebugEventFilter.java @@ -25,7 +25,7 @@ public abstract class DebugEventFilter implements EventFilter { @Schema(description = "String value representing the server name, identifier or ip address where the platform is running", example = "ip-172-31-24-152") protected String server; - @Schema(description = "Boolean value to filter the errors", allowableValues = "false, true") + @Schema(description = "Boolean value to filter the errors", allowableValues = {"false", "true"}) protected boolean isError; @Schema(description = "The case insensitive 'contains' filter based on error message", example = "not present in the DB") protected String errorStr; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/event/LifeCycleEventFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/event/LifeCycleEventFilter.java index 643dd0bef2..3c07a699c0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/event/LifeCycleEventFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/event/LifeCycleEventFilter.java @@ -27,7 +27,7 @@ public class LifeCycleEventFilter implements EventFilter { protected String server; @Schema(description = "String value representing the lifecycle event type", example = "STARTED") protected String event; - @Schema(description = "String value representing status of the lifecycle event", allowableValues = "Success, Failure") + @Schema(description = "String value representing status of the lifecycle event", allowableValues = {"Success", "Failure"}) protected String status; @Schema(description = "The case insensitive 'contains' filter based on error message", example = "not present in the DB") protected String errorStr; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/event/RuleNodeDebugEventFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/event/RuleNodeDebugEventFilter.java index 6f99de5986..3d43b86a6b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/event/RuleNodeDebugEventFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/event/RuleNodeDebugEventFilter.java @@ -25,7 +25,7 @@ import org.thingsboard.server.common.data.StringUtils; @Schema public class RuleNodeDebugEventFilter extends DebugEventFilter { - @Schema(description = "String value representing msg direction type (incoming to entity or outcoming from entity)", allowableValues = "IN, OUT") + @Schema(description = "String value representing msg direction type (incoming to entity or outcoming from entity)", allowableValues = {"IN", "OUT"}) protected String msgDirectionType; @Schema(description = "String value representing the entity id in the event body (originator of the message)", example = "de9d54a0-2b7a-11ec-a3cc-23386423d98f") protected String entityId; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/plugin/ComponentDescriptor.java b/common/data/src/main/java/org/thingsboard/server/common/data/plugin/ComponentDescriptor.java index d58ca2d62d..4275180ede 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/plugin/ComponentDescriptor.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/plugin/ComponentDescriptor.java @@ -39,7 +39,7 @@ public class ComponentDescriptor extends BaseData { @Getter @Setter private ComponentType type; @Schema(description = "Scope of the Rule Node. Always set to 'TENANT', since no rule chains on the 'SYSTEM' level yet.", accessMode = Schema.AccessMode.READ_ONLY, allowableValues = "TENANT", example = "TENANT") @Getter @Setter private ComponentScope scope; - @Schema(description = "Clustering mode of the RuleNode. This mode represents the ability to start Rule Node in multiple microservices.", accessMode = Schema.AccessMode.READ_ONLY, allowableValues = "USER_PREFERENCE, ENABLED, SINGLETON", example = "ENABLED") + @Schema(description = "Clustering mode of the RuleNode. This mode represents the ability to start Rule Node in multiple microservices.", accessMode = Schema.AccessMode.READ_ONLY, allowableValues = {"USER_PREFERENCE", "ENABLED", "SINGLETON"}, example = "ENABLED") @Getter @Setter private ComponentClusteringMode clusteringMode; @Length(fieldName = "name") @Schema(description = "Name of the Rule Node. Taken from the @RuleNode annotation.", accessMode = Schema.AccessMode.READ_ONLY, example = "Custom Rule Node") diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/DeviceCredentials.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/DeviceCredentials.java index 24d8cc1175..e269f0438d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/DeviceCredentials.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/DeviceCredentials.java @@ -70,7 +70,7 @@ public class DeviceCredentials extends BaseData implements this.deviceId = deviceId; } - @Schema(description = "Type of the credentials", allowableValues ="ACCESS_TOKEN, X509_CERTIFICATE, MQTT_BASIC, LWM2M_CREDENTIALS") + @Schema(description = "Type of the credentials", allowableValues = {"ACCESS_TOKEN", "X509_CERTIFICATE", "MQTT_BASIC", "LWM2M_CREDENTIALS"}) @Override public DeviceCredentialsType getCredentialsType() { return credentialsType; From 9c30fce48db61cf82c98cf378b1f6216c9ae70e9 Mon Sep 17 00:00:00 2001 From: ViacheslavKlimov Date: Tue, 14 May 2024 11:48:11 +0300 Subject: [PATCH 48/84] Added more logs to DefaultTbApiUsageStateService --- .../service/apiusage/BaseApiUsageState.java | 10 ++++++++++ .../apiusage/DefaultTbApiUsageStateService.java | 15 ++++++++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) 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 index 2d5026429a..9eac721dfb 100644 --- a/application/src/main/java/org/thingsboard/server/service/apiusage/BaseApiUsageState.java +++ b/application/src/main/java/org/thingsboard/server/service/apiusage/BaseApiUsageState.java @@ -199,6 +199,16 @@ public abstract class BaseApiUsageState { return getApiUsageState().getEntityId(); } + @Override + public String toString() { + return "BaseApiUsageState{" + + "apiUsageState=" + apiUsageState + + ", currentCycleTs=" + currentCycleTs + + ", nextCycleTs=" + nextCycleTs + + ", currentHourTs=" + currentHourTs + + '}'; + } + @Data @Builder public static class StatsCalculationResult { 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 index aef3a4ddcf..60bf529ace 100644 --- a/application/src/main/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateService.java +++ b/application/src/main/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateService.java @@ -17,6 +17,8 @@ package org.thingsboard.server.service.apiusage; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.ListenableFuture; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.checkerframework.checker.nullness.qual.Nullable; @@ -67,8 +69,6 @@ import org.thingsboard.server.service.mail.MailExecutorService; import org.thingsboard.server.service.partition.AbstractPartitionBasedService; import org.thingsboard.server.service.telemetry.InternalTelemetryService; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -184,6 +184,9 @@ public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService if (newHourTs != hourTs) { usageState.setHour(newHourTs); } + if (log.isTraceEnabled()) { + log.trace("[{}][{}] Processing usage stats from {} (currentCycleTs={}, currentHourTs={}): {}", tenantId, ownerId, serviceId, ts, newHourTs, values); + } updatedEntries = new ArrayList<>(ApiUsageRecordKey.values().length); Set apiFeatures = new HashSet<>(); for (UsageStatsKVProto statsItem : values) { @@ -210,6 +213,7 @@ public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService } finally { updateLock.unlock(); } + log.trace("[{}][{}] Saving new stats: {}", tenantId, ownerId, updatedEntries); tsWsService.saveAndNotifyInternal(tenantId, usageState.getApiUsageState().getId(), updatedEntries, VOID_CALLBACK); if (!result.isEmpty()) { persistAndNotify(usageState, result); @@ -410,6 +414,9 @@ public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService myUsageStates.values().forEach(state -> { if ((state.getNextCycleTs() < now) && (now - state.getNextCycleTs() < TimeUnit.HOURS.toMillis(1))) { state.setCycles(state.getNextCycleTs(), SchedulerUtils.getStartOfNextNextMonth()); + if (log.isTraceEnabled()) { + log.trace("[{}][{}] Updating state cycles (currentCycleTs={},nextCycleTs={})", state.getTenantId(), state.getEntityId(), state.getCurrentCycleTs(), state.getNextCycleTs()); + } saveNewCounts(state, Arrays.asList(ApiUsageRecordKey.values())); if (state.getEntityType() == EntityType.TENANT && !state.getEntityId().equals(TenantId.SYS_TENANT_ID)) { TenantId tenantId = state.getTenantId(); @@ -417,6 +424,8 @@ public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService } } }); + } catch (Throwable e) { + log.error("Failed to check start of next cycle", e); } finally { updateLock.unlock(); } @@ -483,7 +492,7 @@ public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService } } state.setGaugeReportInterval(gaugeReportInterval); - log.debug("[{}] Initialized state: {}", ownerId, storedState); + log.debug("[{}][{}] Initialized state: {}", tenantId, ownerId, state); TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, tenantId, ownerId); if (tpi.isMyPartition()) { addEntityState(tpi, state); From 278f6aec067082719d824bd21038005f5f1fee71 Mon Sep 17 00:00:00 2001 From: kalytka Date: Wed, 15 May 2024 13:35:17 +0300 Subject: [PATCH 49/84] Added Lithuanian language --- .../assets/locale/locale.constant-en_US.json | 1 + .../assets/locale/locale.constant-lt_LT.json | 7954 +++++++++++++++++ 2 files changed, 7955 insertions(+) create mode 100644 ui-ngx/src/assets/locale/locale.constant-lt_LT.json diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index e16dcee50b..6996e37699 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -7336,6 +7336,7 @@ "ja_JP": "日本語", "ka_GE": "ქართული", "ko_KR": "한국어", + "lt_LT": "Lietuvių", "lv_LV": "Latviešu", "nl_BE": "Koninkrijk België", "pl_PL": "Polski", diff --git a/ui-ngx/src/assets/locale/locale.constant-lt_LT.json b/ui-ngx/src/assets/locale/locale.constant-lt_LT.json new file mode 100644 index 0000000000..5ca808146c --- /dev/null +++ b/ui-ngx/src/assets/locale/locale.constant-lt_LT.json @@ -0,0 +1,7954 @@ +{ + "access": { + "unauthorized": "Neautorizuotas", + "unauthorized-access": "Neautorizuota prieiga", + "unauthorized-access-text": "Prieiga prie šio funkcionalumo leidžiama tik prisijungusiam vartotojui!", + "access-forbidden": "Prieiga uždrausta", + "access-forbidden-text": "Neturite teisių prieigai!
Bandykite prisijungti kitu vartotoju.", + "refresh-token-expired": "Sesijos galiojimas baigėsi", + "refresh-token-failed": "Sesijos atnaujinti nepavyksta", + "permission-denied": "Leidimas nesuteiktas", + "permission-denied-text": "Šio veiksmo atlikti negalite!" + }, + "account": { + "account": "Account", + "notification-settings": "Notification settings" + }, + "action": { + "activate": "Aktyvuoti", + "suspend": "Sustabdyti", + "save": "Išsaugoti", + "saveAs": "Išsaugoti kaip", + "move": "Perkelti", + "cancel": "Atmesti", + "ok": "OK", + "delete": "Panaikinti", + "add": "Pridėti", + "yes": "Taip", + "no": "Ne", + "update": "Atnaujinti", + "remove": "Panaikinti", + "search": "Paieška", + "clear-search": "Išvalyti", + "assign": "Priskirti", + "unassign": "Atsieti", + "share": "Pasidalinti", + "make-private": "Riboti matomumą", + "make-public": "Neriboti matomumo", + "apply": "Taikyti", + "apply-changes": "Pritaikyti pakeitimus", + "edit-mode": "Redagavimo režimas", + "enter-edit-mode": "Redagavimo režimas", + "decline-changes": "Atšaukti pakeitimus", + "open": "Atverti", + "decline": "Atšaukti", + "close": "Uždaryti", + "back": "Atgal", + "run": "Paleisti", + "sign-in": "Prisijunkite!", + "edit": "Redaguoti", + "view": "Peržiūrėti", + "create": "Kurti", + "drag": "Vilkti", + "refresh": "Atnaujinti", + "undo": "Atšaukti", + "copy": "Kopijuoti", + "paste": "Įklijuoti", + "copy-reference": "Kopijuoti nuorodą", + "paste-reference": "Įklijuoti nuorodą", + "import": "Importuoti", + "export": "Eksportuoti", + "share-via": "Pasidalinti per {{provider}}", + "select": "Pasirinkti", + "continue": "Tęsti", + "discard-changes": "Atsisakyti pakeitimų", + "download": "Parsisiųsti", + "next": "Sekantis", + "next-with-label": "Sekantis: {{label}}", + "read-more": "Skaityti daugiau", + "hide": "Paslėpti", + "done": "Atlikta", + "print": "Spausdinti", + "restore": "Atkurti", + "confirm": "Patvirtinti", + "more": "Daugiau", + "less": "Mažiau", + "skip": "Praleisti", + "send": "Siųsti", + "reset": "Nustatyti iš naujo", + "show-more": "Rodyti daugiau", + "dont-show-again": "Daugiau neberodyti", + "see-documentation": "Žiūrėti dokumentacijoje", + "clear": "Išvalyti" + }, + "aggregation": { + "aggregation": "Agregavimas", + "function": "Duomenų aggregavimo funkcija", + "limit": "Maksimali reikšmė", + "group-interval": "Grupavimo intervalas", + "min": "Min", + "max": "Max", + "avg": "Vidurkis", + "sum": "Sum", + "count": "Kiekis", + "none": "Nėra" + }, + "admin": { + "settings": "Settings", + "general": "General", + "general-settings": "General Settings", + "home-settings": "Home Settings", + "home": "Home", + "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", + "use-system-mail-settings": "Use System Mail Server Settings", + "mail-templates": "Mail Templates", + "mail-template-settings": "Mail Templates Settings", + "use-system-mail-template-settings": "Use System Mail Templates", + "mail-template": { + "mail-template": "Mail template", + "test": "Test email message", + "activation": "Account activation message", + "account-activated": "Account activated message", + "account-lockout": "Account lockout message", + "reset-password": "Reset password message", + "password-was-reset": "Password was reset message", + "user-activated": "User activated message", + "user-registered": "User registered message", + "api-usage-state-enabled": "Api usage state enabled", + "api-usage-state-warning": "Api usage state warning", + "api-usage-state-disabled": "Api usage state disabled", + "two-fa-verification": "2FA verification message" + }, + "mail-subject": "Mail Subject", + "mail-body": "Mail body", + "sms-provider": "SMS provider", + "sms-provider-settings": "SMS provider settings", + "use-system-sms-settings": "Use System 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", + "connection-settings": "Connection settings", + "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", + "parent-customer-name-pattern": "Parent customer name pattern", + "user-groups-name-pattern": "User groups name pattern", + "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", + "smtp-provider": "SMTP provider", + "allowed-platforms": "Allowed platforms", + "authentication": "Authentication", + "basic": "Basic", + "provider": "Provider", + "redirect-url": "Redirect URI", + "domain-name": "Domain name", + "redirect-url-template": "Redirect URI template", + "microsoft-tenant-id": "Directory (tenant) Id", + "microsoft-tenant-id-required": "Directory (tenant) Id is required", + "token-uri": "Token URI", + "token-uri-required": "Token URI is required", + "redirect-uri": "Redirect URI", + "google-provider": "Google", + "microsoft-provider": "Office 365", + "sendgrid-provider": "Sendgrid", + "custom-provider": "Custom", + "generate-access-token": "Generate access token", + "update-access-token": "Update access token", + "access-token-status": "Access token status:", + "token-status-generated": "generated", + "token-status-not-generated": "not generated" + }, + "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": "Repository", + "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-username-password-hint": "GitHub users must use access tokens with write permissions to the repository.", + "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": "Auto-commit", + "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." + }, + "resources": "Resources", + "notifications": "Notifications", + "notifications-settings": "Notifications settings", + "slack-api-token": "Slack API token", + "slack": "Slack", + "slack-settings": "Slack settings" + }, + "alarm": { + "alarm": "Įspėjimas", + "alarms": "Įspėjimai", + "all-alarms": "Visi Įspėjimai", + "select-alarm": "Pasirinkti įspėjimą", + "no-alarms-matching": "Su '{{entity}}' susijusių įspėjimų nėra.", + "alarm-required": "Įspėjimas yra privalomas", + "alarm-filter": "Įspėjimų filtras", + "filter": "Filtras", + "alarm-status": "Įspėjimo statusas", + "alarm-status-list": "Įspėjimo statusų sąrašas", + "any-status": "Visi statusai", + "search-status": { + "ANY": "Visi", + "ACTIVE": "Aktyvūs", + "CLEARED": "Atmesti", + "ACK": "Pripažinti", + "UNACK": "Nepatvirtinti" + }, + "display-status": { + "ACTIVE_UNACK": "Aktyvūs nepatvirtinti", + "ACTIVE_ACK": "Aktyvūs pripažinti", + "CLEARED_UNACK": "Atmesti nepatvirtinti", + "CLEARED_ACK": "Atmesti pripažinti" + }, + "no-alarms-prompt": "Įspėjimų nėra", + "created-time": "Sukūrimo laikas", + "type": "Tipas", + "severity": "Lygis", + "originator": "Iniciatorius", + "originator-type": "Iniciatoriaus tipas", + "details": "Pastabos", + "originator-label": "Iniciatoriaus pavadinimas", + "assign": "Priskirti", + "assignments": "Priskyrimai", + "assignee": "Atsakingas", + "assignee-id": "Atsakingo asmens ID", + "assignee-first-name": "Atsakingo asmens vardas", + "assignee-last-name": "Atsakingo asmens pavardė", + "assignee-email": "Atsakingo asmens e-paštas", + "unassigned": "Panaikinti priskyrimą", + "assignee-not-set": "Visi", + "status": "Statusas", + "alarm-details": "Įspėjimo pastabos", + "start-time": "Pradžia", + "assign-time": "Priskyrimo laikas", + "end-time": "Pabaiga", + "ack-time": "Pripažinimo laikas", + "clear-time": "Atmetimo laikas", + "duration": "Trukmė", + "alarm-severity-list": "Įspėjimų lygiai", + "any-severity": "Visi lygiai", + "severity-critical": "Kritinis", + "severity-major": "Svarbus", + "severity-minor": "Antraeilis", + "severity-warning": "Įspėjimas", + "severity-indeterminate": "Nenustatytas", + "acknowledge": "Patvirtinti", + "clear": "Atmesti", + "delete": "Panaikinti", + "search": "Įspėjimo paieška", + "selected-alarms": "Pasirinkta { count, plural, =1 {1 įspėjimas} other {# įspėjimai} }", + "no-data": "Nėra duomenų", + "polling-interval": "Įspėjimų tikrinimo intervalas (sek)", + "polling-interval-required": "Reikalingas įspėjimų tikrinimo intervalas", + "min-polling-interval-message": "Mažiausias įspėjimų tikrinimo intervalas yra 1 sekundė.", + "aknowledge-alarms-title": "Pripažinti { count, plural, =1 {1 įspėjimą} other {# įspėjimus} }", + "aknowledge-alarms-text": "Pripažinti { count, plural, =1 {1 įspėjimą} other {# įspėjimus} }?", + "aknowledge-alarm-title": "Pripažinti įspėjimą", + "aknowledge-alarm-text": "Pripažinti įspėjimą?", + "selected-alarms-are-acknowledged": "Pasirinkti įspėjimai pripažinti", + "clear-alarms-title": "Atmesti { count, plural, =1 {1 įspėjimą} other {# įspėjimus} }", + "clear-alarms-text": "Atmesti { count, plural, =1 {1 įspėjimą} other {# įspėjimus} }?", + "clear-alarm-title": "Atmesti įspėjimą", + "clear-alarm-text": "Atmesti įspėjimą?", + "delete-alarms-title": "Panaikinti { count, plural, =1 {1 įspėjimą} other {# įspėjimus} }", + "delete-alarms-text": "Panaikinti { count, plural, =1 {1 įspėjimą} other {# įspėjimus} }?", + "selected-alarms-are-cleared": "Pasirinkti įspėjimai atmesti", + "alarm-status-filter": "Įspėjimų statusai", + "alarm-filter-title": "Įspėjimų filtras", + "assigned": "Priskirta", + "filter-title": "Filtras", + "max-count-load": "Didžiausias rodomų įspėjimų skaičius (0 – neribotas)", + "max-count-load-required": "Reikalingas didžiausias rodomų įspėjimų skaičius (0 – neribotas).", + "max-count-load-error-min": "Mažiausia reikšmė yra 0.", + "fetch-size": "Fetch size", + "fetch-size-required": "Fetch size is required.", + "fetch-size-error-min": "Mažiausia rekšmė yra 10.", + "alarm-type-list": "Įspėjimų tipai", + "any-type": "Visi tipai", + "assigned-to-current-user": "Priskirta prisijungusiam vartotojui", + "assigned-to-me": "Priskirta man", + "search-propagated-alarms": "Ieškoti išplatintų įspėjimų", + "comments": "Įspėjimo pastabos", + "show-more": "Rodyti daugiau", + "additional-info": "Papildoma informacija", + "alarm-type": "Įspėjimo tipas", + "enter-alarm-type": "Įveskite įspėjimo tipą", + "no-alarm-types-matching": "Nėra įspėjimų tipų, kurie atitiktų '{{entitySubtype}}'.", + "alarm-type-list-empty": "Nepasirinktas įspėjimų tipas" + }, + "alarm-activity": { + "add": "Pridėti komentarą...", + "alarm-comment": "Įspėjimo komentaras", + "comments": "Komentarai", + "delete-alarm-comment": "Ištrinti komentarą?", + "refresh": "Atnaujinti", + "oldest-first": "Seniausi viršuje", + "newest-first": "Naujausi viršuje", + "activity": "Veikla", + "export": "Eksportuoti į CSV", + "author": "Sukūrė", + "created-date": "Sukūrimo dara", + "edited-date": "Redagavimo data", + "text": "Tekstas", + "system": "Sistema" + }, + "alias": { + "add": "Pridėti pseudonimą", + "edit": "Redaguoti pseudonimą", + "name": "Pseudonimas", + "name-required": "Reikalingas pseudonimas", + "duplicate-alias": "Toks pseudonimas sistemoje jau yra.", + "filter-type-single-entity": "Vienas subjektas", + "filter-type-entity-group": "Subjektų grupė", + "filter-type-entity-list": "Subjektų sąrašas", + "filter-type-entity-name": "Subjekto pavadinimas", + "filter-type-entity-type": "Subjekto tipas", + "filter-type-entity-group-list": "Subjektų grupių sąrašas", + "filter-type-entity-group-name": "Subjektų grupės pavadinimas", + "filter-type-entities-by-group-name": "Subjektai pagal grupės pavadinimą", + "filter-type-state-entity": "Subjektas iš skydelio būsenos", + "filter-type-state-entity-description": "Subjektas iš skydelio būsenos parametrų", + "filter-type-state-entity-owner": "Skydelio būsenos subjekto savininkas", + "filter-type-state-entity-owner-description": "Subjekto, iš skydelio būsenos parametrų, savininkas", + "filter-type-asset-type": "Turto tipas", + "filter-type-asset-type-description": "Turtas, kurio tipas '{{assetTypes}}'", + "filter-type-asset-type-and-name-description": "Turtas, kurio tipas '{{assetTypes}}' ir kurio pavadinimas prasideda '{{prefix}}'", + "filter-type-device-type": "Įrenginio tipas", + "filter-type-device-type-description": "Įrenginiai, kurių tipas '{{deviceTypes}}'", + "filter-type-device-type-and-name-description": "Įrenginiai, kurių tipas '{{deviceTypes}}' ir kurių pavadinimai '{{prefix}}'", + "filter-type-entity-view-type": "Subjekto rodinio tipas", + "filter-type-entity-view-type-description": "Subjektų rodiniai, kurių tipas '{{entityViewTypes}}'", + "filter-type-entity-view-type-and-name-description": "Subjektų rodiniai, kurių tipas '{{entityViewTypes}}' ir kurių pavadinimai prasideda '{{prefix}}'", + "filter-type-edge-type": "Edge type", + "filter-type-edge-type-description": "Edges of type '{{edgeTypes}}'", + "filter-type-edge-type-and-name-description": "Edges of type '{{edgeTypes}}' and with name starting with '{{prefix}}'", + "filter-type-relations-query": "Ryšių užklausa", + "filter-type-relations-query-description": "{{entities}} kurie turi {{relationType}} ryšį {{direction}} {{rootEntity}}", + "filter-type-edge-search-query": "Edge search query", + "filter-type-edge-search-query-description": "Edges with types {{edgeTypes}} that have {{relationType}} relation {{direction}} {{rootEntity}}", + "filter-type-scheduler-event": "Scheduler events", + "filter-type-scheduler-event-type-description": "Scheduler events with type '{{eventType}}'", + "filter-type-scheduler-event-originator-description": "Scheduler events of specific originator", + "filter-type-scheduler-event-type-originator-description": "Scheduler events of specific originator with '{{eventType}}'", + "filter-type-asset-search-query": "Turto paieškos užklausa", + "filter-type-asset-search-query-description": "{{assetTypes}} tipo turtas, turintis {{relationType}} tipo ryšį {{direction}} {{rootEntity}}", + "filter-type-device-search-query": "Įrenginio paieškos užklausa", + "filter-type-device-search-query-description": "{{deviceTypes}} tipo įrenginiai, turintys {{relationType}} tipo ryšį {{direction}} {{rootEntity}}", + "filter-type-entity-view-search-query": "Subjektų rodinio paieškos užklausa", + "filter-type-entity-view-search-query-description": "{{entityViewTypes}} tipo subjektai, turintys {{relationType}} tipo ryšį {{direction}} {{rootEntity}}", + "filter-type-apiUsageState": "Api Usage State", + "entity-filter": "Subjektų filtras", + "resolve-multiple": "Traktuoti kaip kelis subjektus", + "filter-type": "Filtro tipas", + "filter-type-required": "Filtro tipas būtinas.", + "entity-filter-no-entity-matched": "Subjektų, atitinkančių filtro kriterijus, nerasta.", + "no-entity-filter-specified": "Subjektų filtras nenustatytas", + "root-state-entity": "Subjektą, iš skydelio būsenos, naudoti kaip pagrindinį", + "group-state-entity": "Skydelio būsenos subjektą naudoti kaip subjektų grupę", + "group-state-entity-owner": "Skydelio būsenos subjektą naudoti kaip subjektų grupės sąvininką", + "last-level-relation": "Gauti tik paskutinio lygio ryšį", + "root-entity": "Pagridinis subjektas", + "state-entity-parameter-name": "Būsenos subjekto parametro pavadinimas", + "default-state-entity": "Numatytasis būsenos subjektas", + "default-state-entity-group": "Numatytoji būsenos subjektų grupė", + "default-entity-parameter-name": "Pagal nutylėjimą", + "max-relation-level": "Maksimalus ryšių lygis", + "unlimited-level": "Neribojamas lygis", + "state-entity": "Skydelio būsenos subjektas", + "entities-of-group-state-entity": "Subjektai iš skydelio būsenos subjektų grupės", + "all-entities": "Visi subjektai", + "any-relation": "visi", + "originator": "Iniciatorius", + "originator-state-entity": "Skydelio būsenos subjektą naudoti kaip iniciatorių", + "scheduler-event-type": "Scheduler event type" + }, + "asset": { + "all": "Visas", + "all-assets": "Visas turtas", + "groups": "Grupės", + "shared": "Bendrinamas", + "asset": "Turtas", + "assets": "Turtas", + "management": "Turto valdymas", + "view-assets": "Peržiūrėti turtą", + "add": "Pridėti turtą", + "asset-type-max-length": "Turto tipas turi būti trumpesnis nei 256 simboliai", + "assign-to-customer": "Priskirti klientui", + "assign-asset-to-customer": "Turtą priskirti klientui", + "assign-asset-to-customer-text": "Pasirinkite turtą, kurį norite priskiri klientui", + "no-assets-text": "Turto nerasta", + "assign-to-customer-text": "Pasirinkite klientą, kuriam priskiriamas turtas", + "public": "Viešas", + "assignedToCustomer": "Priskirti klientui", + "make-public": "Neriboti turto matomumo", + "make-private": "Riboti turto matomumą", + "unassign-from-customer": "Atsieti nuo kliento", + "delete": "Panaikinti turtą", + "asset-public": "Turtas yra viešas", + "asset-type": "Turto tipas", + "asset-type-required": "Turto tipas būtinas.", + "select-asset-type": "Pasirinkite turto tipą", + "enter-asset-type": "Įveskite turto tipą", + "any-asset": "Visas turtas", + "no-asset-types-matching": "Turto tipas, atitinkantis '{{entitySubtype}}', nerastas.", + "asset-type-list-empty": "Nepasirinkti turto tipai.", + "asset-types": "Turto tipai", + "name": "Pavadinimas", + "name-required": "Pavadinimas yra privalomas.", + "name-max-length": "Pavadinimas turi būti trumpesnis nei 256 simboliai", + "label-max-length": "Vardas turi būti trumpesnis nei 256 simboliai", + "description": "Aprašymas", + "type": "Tipas", + "type-required": "Tipas yra prvalomas.", + "details": "Pastabos", + "events": "Įvykiai", + "add-asset-text": "Pridėti turtą", + "asset-details": "Pastabos apie turtą", + "assign-assets": "Priskirti turtą", + "assign-assets-text": "Priskirti { count, plural, =1 {1 turtą} other {# turtus} } 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": "Pašalinti turtą", + "unassign-assets": "Atsieti turtą", + "unassign-assets-action-title": "Atsieti { count, plural, =1 {1 turtą} other {# turtus} } nuo kliento?", + "assign-new-asset": "Prisirti naują turtą", + "delete-asset-title": "Pašalinti '{{assetName}}' turtą?", + "delete-asset-text": "Būkite dėmesingi, po patvirtinimo, turtas ir su juo susijusi informacija, bus pašalinta.", + "delete-assets-title": "Pašalinti { count, plural, =1 {1 turtą} other {# turtus} }?", + "delete-assets-action-title": "Pašalinti { count, plural, =1 {1 turtą} other {# turtus} }", + "delete-assets-text": "Būkite dėmesingi, po patvirtinimo, visi turto įrašai ir su jais susijusi informacija, bus pašalinta.", + "make-public-asset-title": "Ar tikrai norite neriboti turto '{{assetName}}' matomumo?", + "make-public-asset-text": "Po patvirtinimo turtas ir visa su juo susijusi informacija bus prieinama visiems.", + "make-private-asset-title": "Ar tikrai norite riboti turto '{{assetName}}' matomumą?", + "make-private-asset-text": "Po patvirtinimo, turtas ir visa su juo susijusi informacija nebus prieinama kitiems.", + "unassign-asset-title": "Ar tikrai norite atsieti turtą '{{assetName}}'?", + "unassign-asset-text": "Po patvirtinimo turtas bus atsietas ir klientas neteks prieigos prie jo.", + "unassign-asset": "Atsieti turtą", + "unassign-assets-title": "Ar tikrai norite atsieti { count, plural, =1 {1 turtą} other {# turtus} }?", + "unassign-assets-text": "Po patvirtinimo visas turtas bus atsietas ir klientas neteks prieigos prie jo.", + "copyId": "Kopijuoti turto Id", + "idCopiedMessage": "Turto Id nukopijuotas į iškarpinę", + "select-asset": "Pasirinkti turtą", + "no-assets-matching": "Turtas atitinkantis '{{entity}}' nerastas.", + "asset-required": "Turtas būtinas", + "name-starts-with": "Turto pavadinimas prasideda", + "help-text": "naudokite simbolį '%' pagal tai, kaip norite ieškoti: '%turto_pavadinimo_fragmentas%', '%turto_pavadinimo_pabaiga', 'turto_pavadinimo_pradžia%'.", + "search": "Turto paieška", + "select-group-to-add": "Pasirinkti grupę, į kurią turtą pridėti", + "select-group-to-move": "Pasirinkite grupę, į kurią turtą perkelti", + "remove-assets-from-group": "Ar tikrai norite { count, plural, =1 {1 turtą} other {# turtus} } pašalinti iš grupės '{{entityGroup}}'?", + "group": "Turto grupė", + "list-of-groups": "{ count, plural, =1 {Viena turto grupė} other { # Turto grupių sąrašas} }", + "group-name-starts-with": "Turto grupės, kurių pavadinimai prasideda '{{prefix}}'", + "import": "Importuoti turtą", + "asset-file": "Turto failas", + "label": "Etiketė", + "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": "Pasirinkta { count, plural, =1 {1 turtas} other {# turtai} }" + }, + "attribute": { + "attributes": "Atributai", + "latest-telemetry": "Paskutinė telemetrija", + "no-latest-telemetry": "Telemetrijos nėra", + "attributes-scope": "Subjekto atributų kontekstas", + "scope-telemetry": "Telemetrija", + "scope-latest-telemetry": "Paskutinė telemetrija", + "scope-client": "Kliento atributai", + "scope-server": "Serverio atributai", + "scope-shared": "Bendrinami atributai", + "add": "Pridėti atributą", + "add-attribute-prompt": "Pridėkite atributą", + "key": "Raktas", + "key-max-length": "Raktas negali viršyti 256 simbolių", + "last-update-time": "Atnaujinimo laikas", + "key-required": "Atributo raktas būtinas.", + "value": "Reikšmė", + "value-required": "Atributo reikšmė būtina.", + "telemetry-key-required": "Telemetrijos raktas būtinas", + "telemetry-value-required": "Telemetrijos reikšmė būtina", + "delete-attributes-title": "Ar tikrai norite pašalinti { count, plural, =1 {1 atributą} other {# atributus} }?", + "delete-attributes-text": "Būkite dėmesingi, po patvirtinimo pasirinkti atributai bus pašalinti.", + "delete-attributes": "Pašalinti atributus", + "enter-attribute-value": "Įveskite atributo reikšmę", + "show-on-widget": "Rodyti valdiklyje", + "widget-mode": "Valdiklio režimas", + "next-widget": "Sekantis valdiklis", + "prev-widget": "Ankstesnis valdiklis", + "add-to-dashboard": "Pridėti į skydelį", + "add-widget-to-dashboard": "Pridėti valdiklį į skydelį", + "selected-attributes": "Pasirinkta { count, plural, =1 {1 atributas} other {# atributai} }", + "selected-telemetry": "Pasirinkta { count, plural, =1 {1 telemetrijos blokas} other {# telemetrijos blokai} }", + "no-attributes-text": "Atributų nėra", + "no-telemetry-text": "Telemetrijos nėra", + "copy-key": "Kopijuoti raktą", + "add-telemetry": "Pridėti telemetriją", + "copy-value": "Kopijuoti reikšmę", + "delete-timeseries": { + "start-time": "Pradžia", + "ends-on": "Pabaiga", + "strategy": "Strategija", + "delete-strategy": "Šalinimo startegija", + "all-data": "Pašalinti visus duomenis", + "all-data-except-latest-value": "Pašalinti visus duomenis išskyrus naujausią reikšmę", + "latest-value": "Pašalinti naujausią reikšmę", + "all-data-for-time-period": "Pašalinti visus duomenis periodui", + "rewrite-latest-value": "Perrašyti naujausią reikšmę" + } + }, + "api-usage": { + "api-features": "API features", + "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", + "device-api": "Device API", + "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" + }, + "api-limit": { + "cassandra-queries": "Cassandra queries", + "entity-version-creation": "Entity version creation", + "entity-version-load": "Entity version load", + "integration-messages": "Integration messages", + "integration-messages-per-device": "Integration messages per device", + "notification-requests": "Notification requests", + "notification-requests-per-rule": "Notification requests per rule", + "reports-generation": "Reports generation", + "rest-api-requests": "REST API requests", + "rest-api-requests-per-customer": "REST API requests per customer", + "transport-messages": "Transport messages", + "transport-messages-per-device": "Transport messages per device", + "ws-updates-per-session": "WS updates per session" + }, + "audit-log": { + "audit": "Auditas", + "audit-logs": "Audito žurnalas", + "timestamp": "Laiko žyma", + "entity-type": "Subjektas", + "entity-name": "Pavadinimas", + "user": "Vartotojas", + "type": "Veiksmas", + "status": "Statusas", + "details": "Informacija", + "type-added": "Pridėta", + "type-deleted": "Pašalinta", + "type-updated": "Atnaujinta", + "type-attributes-updated": "Attributai atnaujinti", + "type-attributes-deleted": "Atributai pašalinti", + "type-rpc-call": "RPC iškvietimas", + "type-credentials-updated": "Įgaliojimai atnaujinti", + "type-assigned-to-customer": "Priskirta klientui", + "type-unassigned-from-customer": "Atsieta nuo kliento", + "type-assigned-to-edge": "Assigned to Edge", + "type-unassigned-from-edge": "Unassigned from Edge", + "type-activated": "Aktyvuota", + "type-suspended": "Sustabdyta", + "type-credentials-read": "Įgaliojimų skaitymas", + "type-attributes-read": "Atributų skaitymas", + "type-added-to-entity-group": "Įtraukta į grupę", + "type-removed-from-entity-group": "Pašalinta iš grupės", + "type-relation-add-or-update": "ryšys atnaujintas", + "type-relation-delete": "Ryšys panaikintas", + "type-relations-delete": "Visi ryšiai panaikinti", + "type-alarm-ack": "Pripažinta", + "type-alarm-clear": "Išvalyta", + "type-alarm-assign": "Priskitas", + "type-alarm-unassign": "Atsietas", + "type-added-comment": "Pridėtas komentaras", + "type-updated-comment": "Atnaujintas komentaras", + "type-deleted-comment": "Pašalintas komentaras", + "type-rest-api-rule-engine-call": "Rule engine REST API call", + "type-made-public": "Padaryti viešu", + "type-made-private": "Padaryti privačiu", + "type-login": "Prisijungti", + "type-logout": "Atsijungti", + "type-lockout": "Užblokuoti", + "status-success": "Sėkmė", + "status-failure": "Nesėkmė", + "audit-log-details": "Informacija apie autido žurnalą", + "no-audit-logs-prompt": "Žurnalo įrašų nėra", + "action-data": "Informacija apie veiksmus", + "failure-details": "Informacija apie nesėkmę", + "search": "Žurnalo įrašų paieška", + "clear-search": "Išvalyti paiešką", + "type-assigned-from-tenant": "Priskirta valdytojo", + "type-assigned-to-tenant": "Priskirta valdytojui", + "type-provision-success": "Device provisioned", + "type-provision-failure": "Device provisioning was failed", + "type-timeseries-updated": "Telemetrija atnaujinta", + "type-timeseries-deleted": "Telemetrija pašalinta", + "type-owner-changed": "Savininkas pakeistas", + "type-sms-sent": "SMS sent" + }, + "confirm-on-exit": { + "message": "Turite neišsaugotų pakeitimų. Ar tikrai norite palikti šį puslapį?", + "html-message": "Turite neišsaugotų pakeitimų.
Ar tikrai norite palikti šį puslapį?", + "title": "Neišsaugoti pakeitimai" + }, + "contact": { + "country": "Šalis", + "city": "Miestas", + "state": "Rajonas", + "postal-code": "Pašto kodas", + "postal-code-invalid": "Neteisingas pašto kodo formatas.", + "address": "Adresas", + "address2": "Adresas 2", + "phone": "Telefonas", + "email": "El. paštas", + "no-address": "Adresas nenurodytas", + "state-max-length": "Rajono pavadinimas negali viršyti 256 simbolių", + "phone-max-length": "Telefono numeris negali viršyti 256 simbolių", + "city-max-length": "Miesto pavadinimas negali viršyti 256 simbolių" + }, + "common": { + "username": "Vartoto vardas", + "password": "Slaptažodis", + "enter-username": "Įveskite vartotojo vardą", + "enter-password": "Įveskite slaptažodį", + "enter-search": "Įveskite paieškos kriterijus", + "created-time": "Sukūrimo laikas", + "loading": "Įkeliama...", + "proceed": "Tęsti", + "open-details-page": "Informacija", + "not-found": "Nerasta", + "documentation": "Dokumentacija" + }, + "converter": { + "converter": "Data converter", + "converters": "Data converters", + "select-converter": "Select data converter", + "no-converters-matching": "No data converters matching '{{entity}}' were found.", + "no-converters-found": "No data converters found.", + "converter-required": "Data converter is required", + "delete": "Delete converter", + "management": "Data converters management", + "add-converter-text": "Add new data converter", + "no-converters-text": "No data converters found", + "selected-converters": "{ count, plural, =1 {1 data converter} other {# data converters} } selected", + "delete-converter-title": "Are you sure you want to delete the data converter '{{converterName}}'?", + "delete-converter-text": "Be careful, after the confirmation the data converter and all related data will become unrecoverable.", + "delete-converters-title": "Are you sure you want to delete { count, plural, =1 {1 data converter} other {# data converters} }?", + "delete-converters-action-title": "Delete { count, plural, =1 {1 data converter} other {# data converters} }", + "delete-converters-text": "Be careful, after the confirmation all selected data converters will be removed and all related data will become unrecoverable.", + "events": "Events", + "add": "Add Data Converter", + "search": "Search data converters", + "converter-details": "Data converter details", + "details": "Details", + "copyId": "Copy converter Id", + "idCopiedMessage": "Converter Id has been copied to clipboard", + "debug-mode": "Debug mode", + "created-time": "Created time", + "name": "Name", + "name-required": "Name is required.", + "name-max-length": "Name should be less than 256", + "description": "Description", + "decoder": "Decoder", + "encoder": "Encoder", + "test-decoder-fuction": "Test decoder function", + "test-encoder-fuction": "Test encoder function", + "test-with-this-message": "{{test}} with this message", + "decoder-input-params": "Decoder input parameters", + "encoder-input-params": "Encoder input parameters", + "payload": "Payload", + "payload-content-type": "Payload content type", + "payload-content": "Payload content", + "message": "Message", + "message-type": "Message type", + "message-type-required": "Message type is required", + "test": "Test", + "metadata": "Metadata", + "metadata-required": "Metadata entries can't be empty.", + "integration-metadata": "Integration metadata", + "integration-metadata-required": "Integration metadata entries can't be empty.", + "output": "Output", + "import": "Import converter", + "export": "Export converter", + "export-failed-error": "Unable to export converter: {{error}}", + "create-new-converter": "Create new converter", + "converter-file": "Converter file", + "invalid-converter-file-error": "Unable to import converter: Invalid converter data structure.", + "type": "Type", + "type-required": "Type is required.", + "type-uplink": "Uplink", + "type-downlink": "Downlink", + "update-only-keys-list": "Update only keys list", + "update-only-keys-list-hint": "The values associated with the provided keys will be saved to the database only if they are different from the corresponding values in the previous converted message. This functionality applies to both attributes and telemetry in the converter output.", + "add-key": "Add key" + }, + "content-type": { + "json": "Json", + "text": "Text", + "binary": "Binary (Base64)" + }, + "color": { + "color": "Color", + "color-picker": "Color picker", + "primary-colors": "Primary colors", + "accent-colors": "Accent colors", + "no-color-selected": "No color selected" + }, + "customer": { + "all": "Visi", + "all-customers": "Visi klientai", + "groups": "Grupės", + "shared": "Bendrinami", + "hierarchy": "Hierarchija", + "customer": "Klientas", + "customers": "Klientai", + "management": "Klientų valdymas", + "dashboard": "Klientų skydelis", + "dashboards": "Kliientų skydeliai", + "devices": "Kliento įrenginiai", + "entity-views": "Kliento subjektų rodiniai", + "assets": "Kliento turtas", + "public-dashboards": "Vieši skydeliai", + "public-devices": "Vieši įrenginiai", + "public-assets": "Viešas turtas", + "public-entity-views": "Vieši subjektų rodiniai", + "add": "Pridėti klientą", + "delete": "Pašalinti klientą", + "manage-customer-user-groups": "Kliento vartotojų grupės", + "manage-customer-groups": "Klientų grupės", + "manage-customer-device-groups": "Kliento įrenginių grupės", + "manage-customer-asset-groups": "Kliento turto grupės", + "manage-customer-entity-view-groups": "Kliento subjektų rodinių grupės", + "manage-customer-edge-groups": "Manage customer edge groups", + "manage-customer-dashboard-groups": "Kliento skydelių grupės", + "manage-customer-users": "Kliento vartotojai", + "manage-customers": "Klientai", + "manage-customer-devices": "Kliento įrenginiai", + "manage-customer-entity-views": "Kliento subjektų rodiniai", + "manage-customer-dashboards": "Kliento skydeliai", + "manage-public-devices": "Vieši įrenginiai", + "manage-public-dashboards": "Vieši skydeliai", + "manage-customer-assets": "Kliento turtas", + "manage-customer-edges": "Manage customer edges", + "manage-public-assets": "Viešas turtas", + "add-customer-text": "Pridėti naują klientą", + "no-customers-text": "Klientų nėra", + "customer-details": "Informacija apie klientą", + "delete-customer-title": "Ar tikrai norite pašalinti klientą '{{customerTitle}}'?", + "delete-customer-text": "Būkite dėmesingi, po patvirtinimo, kliento ir su juo susijusios informacijos atkurti nebegalėsite.", + "delete-customers-title": "Ar tikrai norite pašalinti { count, plural, =1 {1 klientą} other {# klientus} }?", + "delete-customers-action-title": "Pašalinti { count, plural, =1 {1 klientą} other {# klientus} }", + "delete-customers-text": "Būkite dėmesingi, po patvirtinimo, klientų ir su jais susijusios informacijos atkurti nebegalėsite.", + "manage-user-groups": "Vartotjų grupės", + "manage-asset-groups": "turto grupės", + "manage-device-groups": "Įrenginių grupės", + "manage-dashboard-groups": "Skydelių grupės", + "manage-entity-view-groups": "Subjektų rodinių grupės", + "manage-edge-groups": "Manage edge groups", + "manage-users": "Vartotojai", + "manage-assets": "Turtas", + "manage-devices": "Įrenginiai", + "manage-dashboards": "Skydeliai", + "manage-entity-views": "Subjektų rodiniai", + "title": "Pavadinimas", + "title-required": "Pavadinimas būtinas.", + "title-max-length": "Pavadinimas negali viršyti 256 simbolių", + "description": "Aprašymas", + "details": "Informacija", + "events": "Įvykiai", + "copyId": "Kopijuoti kliento Id", + "idCopiedMessage": "Kliento Id nukopijuotas į iškarpinę", + "select-customer": "Pasirinkti klientą", + "no-customers-matching": "Klientų, atitinkančių '{{entity}}' nėra.", + "customer-required": "Klientas būtinas", + "select-group-to-add": "Pasirinkite grupę, į kurią pridėti pažymėtus klientus", + "select-group-to-move": "Pasirinkite grupę, į kurią perkelti pažymėtus klientus", + "remove-customers-from-group": "Ar tikrai { count, plural, =1 {1 klientą} other {# klientus} } norite pašalinti iš grupės '{{entityGroup}}'?", + "group": "Klientų grupė", + "list-of-groups": "{ count, plural, =1 {Viena kliento grupė} other {Sąrašas # klientų grupių} }", + "group-name-starts-with": "Klientų grupės, kurios prasideda '{{prefix}}'", + "select-default-customer": "Pasirinkite numatytąjį klientą", + "default-customer": "Numatytasis klientas", + "default-customer-required": "Numatytasis klientas būtinas, norint suderinti prietaisų skydelį valdytojo lygiu", + "allow-white-labeling": "Leisti tinkinti aplikaciją", + "search": "Klientų paieška", + "selected-customers": "Pasirinkta { count, plural, =1 {1 klientas} other {# kientai} }", + "edges": "Customer edge instances", + "manage-edges": "Manage edges" + }, + "customers-hierarchy": { + "customers-hierarchy": "Klientų hierarchija", + "open-nav-tree": "Atverti navigacijos medį", + "return-to-top-level": "Grįžti į aukščiausią lygį" + }, + "custom-menu": { + "custom-menu": "Vartotojo meniu", + "custom-menu-hint": "Aprašykite pasirinktinį meniu JSON formoje. Šiame JSON nurodomas vartotojo meniu elementų sąrašas." + }, + "custom-translation": { + "custom-translation": "Vartotojo vertimai", + "translation-map": "Vertimų žemėlapis", + "key": "Vertimo raktas", + "import": "Importuoti vertimą", + "export": "Eksportuoti vertimą", + "export-data": "Eksportuoti vertimo duomenis", + "import-data": "Importuoti vertimo duomenis", + "translation-file": "Vertimų failas", + "invalid-translation-file-error": "Nepavyksta importuoti vertimo failo: Neteisinga duomenų struktūra.", + "custom-translation-hint": "Aprašykite vartotojo vertimą JSON formatu žemiau. Šis JSON perrašys standartinį vertimą. Paspaudę 'Parsisiųsti lokalės failą', gausite standartinio vertimo failą, kuriame galite surasti reikalingas raktų-reikšmių poros ir jas pakeisti savo JSON.", + "download-locale-file": "Parsisųsti lokalės failą" + }, + "date": { + "last-update-n-ago": "Last update N ago", + "last-update-n-ago-text": "Last update {{ agoText }}", + "custom-date": "Custom date", + "format": "Format", + "preview": "Preview" + }, + "datetime": { + "date-from": "Data nuo", + "time-from": "Laikas nuo", + "date-to": "Data iki", + "time-to": "Laikas iki" + }, + "dashboard": { + "all": "Visi", + "all-dashboards": "Visi skydeliai", + "groups": "Grupės", + "shared": "Bendrinami", + "dashboard": "Skydelis", + "dashboards": "Skydeliai", + "management": "Skydelių valdymas", + "view-dashboards": "Paržiūrėti skydelius", + "add": "Pridėti skydelį", + "assign-dashboard-to-customer": "Skydelį (-ius) priskirti klientui", + "assign-dashboard-to-customer-text": "Pasirinkite skydelius, kuriuos norite priskirti klientui", + "assign-to-customer-text": "Pasirinkite klientą, kuriam priskirti skydelį (-ius)", + "assign-to-customer": "Priskirti kientui", + "unassign-from-customer": "Atsieti nuo kliento", + "make-public": "Skydelį padaryti viešą", + "make-private": "Skydelį padaryti privatų", + "manage-assigned-customers": "Valdyti priskirtus klientus", + "assigned-customers": "Priskirti klientai", + "assign-to-customers": "Skydelį (-ius) priskirti klientams", + "assign-to-customers-text": "Pasirinkite klientus, kuriems priskirti skydelį (-ius)", + "unassign-from-customers": "Skydelį (-ius) atsieti nuo klientų", + "unassign-from-customers-text": "Pasirinkite klientus, kuriems atsieti skydelį (-ius)", + "no-dashboards-text": "Skydelių nėra", + "no-widgets": "Valdikliai nesukonfigūruoti", + "add-widget": "Pridėti naują valdiklį", + "add-widget-button-text": "Pridėt valdiklį", + "title": "Pavadinimas", + "image": "Skydelio paveiksliukas", + "mobile-app-settings": "Mobiliosios aplikacijos nustatymai", + "mobile-order": "Skydelio eilės tvarka mobiliojoje aplikacijoje", + "mobile-hide": "Mobiliojoje aplikacijoje skydelį paslėpti", + "update-image": "Atnaujinti skydelio paveiksliuką", + "take-screenshot": "Padaryti ekrano nuotrauką", + "select-widget-title": "Pasirinkite valdiklį", + "select-widget-value": "{{title}}: pasirinkite valdiklį", + "select-widget-subtitle": "Galimų valdiklių sąrašas", + "delete": "Pašalinti skydelį", + "title-required": "Pavadinimas būtinas.", + "title-max-length": "Pavadinimas negali viršyti 256 simbolių", + "description": "Aprašymas", + "details": "Informacija", + "dashboard-details": "Informacija", + "add-dashboard-text": "Pridėti naują skydelį", + "assign-dashboards": "Priskirti skydelius", + "assign-new-dashboard": "Priskirti naują skydelį", + "assign-dashboards-text": "Priskirti { count, plural, =1 {1 skydelį} other {# skydelius} } klientams", + "unassign-dashboards-action-text": "Atsieti { count, plural, =1 {1 skydelį} other {# skydelius} } nuo klientų", + "delete-dashboards": "Panaikinti skydelius", + "unassign-dashboards": "Atsieti skydelius", + "unassign-dashboards-action-title": "Atsieti { count, plural, =1 {1 skydelį} other {# skydelius} } nuo kliento", + "delete-dashboard-title": "Ar tikrai norite pašalinti skydelį '{{dashboardTitle}}'?", + "delete-dashboard-text": "Būkite dėmesingi, po patvirtinimo, skydelio ir su juo susijusios informacijos atkurti nebegalėsite.", + "delete-dashboards-title": "Ar tikrai norite pašalinti { count, plural, =1 {1 skydelį} other {# skydelius} }?", + "delete-dashboards-action-title": "Pašalinti { count, plural, =1 {1 skydelį} other {# skydelius} }", + "delete-dashboards-text": "Būkite dėmesingi, po patvirtinimo, pasirinkti skydeliai ir su jais susijusi informacija bus pašalinti ir jų atkurti nebegalėsite.", + "unassign-dashboard-title": "Ar tikrai norite atsieti skydelį '{{dashboardTitle}}'?", + "unassign-dashboard-text": "Po patvrtinimo skydelis bus atsietas ir klientas jo nebematys.", + "unassign-dashboard": "Atsieti skydelį", + "unassign-dashboards-title": "Ar tikrai norite atsieti { count, plural, =1 {1 skydelį} other {# skydelius} }?", + "unassign-dashboards-text": "Po patvirtinimo visi skydeliai bus atsieti ir klientas jų nebematys.", + "public-dashboard-title": "Skydelis dabar yra viešas", + "public-dashboard-text": "Skydelis {{dashboardTitle}} dabar yra viešas ir pasiekiamas per šią nuorodą:", + "public-dashboard-notice": "Pastaba: Įrenginiai turi būti vieši, jei norite matyti jų duomenis.", + "public-dashboard-link": "Vieša skydelio nuoroda", + "public-dashboard-link-text": "Viešą sydelį {{dashboardTitle}} galima atidaryti per šią nuorodą:", + "public-dashboard-link-notice": "Pastaba: Įrenginiai, turtas ir subjektai turi būti vieši, jei norite matyti jų duomenis.", + "make-private-dashboard-title": "Ar tikrai norite skydelį '{{dashboardTitle}}' padaryti privačiu?", + "make-private-dashboard-text": "Po patvirtinimo, skydelis taps privačiu ir jo nematys kiti vartotojai.", + "make-private-dashboard": "Skydelį padaryti privačiu", + "socialshare-text": "'{{dashboardTitle}}' powered by ThingsBoard", + "socialshare-title": "'{{dashboardTitle}}' powered by ThingsBoard", + "select-dashboard": "Pasirinkite skydelį", + "no-dashboards-matching": "Skydelio, atitinkančio '{{entity}}' nėra.", + "dashboard-required": "Skydelis būtinas.", + "select-existing": "Pasirinkite egzistuojantį skydelį", + "create-new": "Sukurti naują skydelį", + "new-dashboard-title": "Naujas skydelio pavadinimas", + "open-dashboard": "Atidaryti skydelį", + "set-background": "Nustatyti foną", + "background-color": "Fono spalva", + "background-image": "Fono paveikslėlis", + "background-size-mode": "Fono dydis", + "no-image": "Paveikslėlis nepasirinktas", + "empty-image": "Nėra paveikslėlio", + "drop-image": "Užvilkite paveikslėlį arba spustelėkite ir pasirinkite failą.", + "maximum-upload-file-size": "Maksimalus įkeliamo failo dydis: {{ size }}", + "cannot-upload-file": "Failo įkelti nepavyko", + "settings": "Nustatymai", + "layout-settings": "Išdėstymo nustatymai", + "columns-count": "Stulpelių skaičius", + "columns-count-required": "Stulpelių skaičius būtinas.", + "min-columns-count-message": "Minimalus stulpelių skaičius - 10.", + "max-columns-count-message": "Maksimalus stulpelių skaičius - 1000.", + "widgets-margins": "Paraštės tarp valdiklių dysis", + "margin-required": "Paraštės dydžio reikšmė būtina.", + "min-margin-message": "Minimali paraštės dydžio reikšmė - 0.", + "max-margin-message": "Maksimali paraštės dydžio reikšmė - 50.", + "horizontal-margin": "Horizontalios paraštės dydis", + "horizontal-margin-required": "Horizontalios paraštės dydžio reikšmė būtina.", + "min-horizontal-margin-message": "Minimali horizontalios paraštės dydžio reikšmė - 0", + "max-horizontal-margin-message": "Maksimali horizontalios paraštės dydžio reikšmė - 50.", + "vertical-margin": "Vertikalios paraštės dydis", + "vertical-margin-required": "Vertikalios paraštės dydžio reikšmė būtina.", + "min-vertical-margin-message": "Minimali vertikalios paraštės dydžio reikšmė - 0.", + "max-vertical-margin-message": "Maksimali vertikalios paraštės dydžio reikšmė - 50.", + "apply-outer-margin": "Taikyti paraštes maketo šonuose", + "autofill-height": "Automatiškai pritaikyti aukštį", + "mobile-layout": "Mobiliojo režimo išdėstymo nustatymai", + "mobile-row-height": "Mobiliojo režimo eilutės aukštis pikseliais", + "mobile-row-height-required": "Mobiliojo režimo eilutės aukštis būtinas.", + "min-mobile-row-height-message": "Minimalus mobiliojo režimo eilutės aukštis yra 5 pikseliai.", + "max-mobile-row-height-message": "Maksimalus mobiliojo režimo eilutės aukštis yra 200 pikselių.", + "title-settings": "Pavadinimo nustatymai", + "display-title": "Rodyt skydelio pavadinimą", + "title-color": "Pavadinimo spalva", + "toolbar-settings": "Įrankių juostos nustatymai", + "hide-toolbar": "Paslėpti įrankių juostą", + "toolbar-always-open": "Įrankių juostą laikyti atdarytą", + "display-dashboards-selection": "Rodyti skydelių pasirinkimą", + "display-entities-selection": "Rodyti subjektų pasirinkimą", + "display-filters": "Rodyti filtrus", + "display-dashboard-timewindow": "Rodyti laiko langą", + "display-dashboard-export": "Rodyti eksportą", + "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": "Importuoti skydelį", + "export": "Eksportuoti skydelį", + "export-failed-error": "Skydelio importuoti nepavyko: {{error}}", + "export-pdf": "Eksportuoti kaip PDF", + "export-png": "Eksportuoti kaip PNG", + "export-jpg": "Eksportuoti kaip JPEG", + "export-json-config": "Eksportuoti JSON konfigūraciją", + "download-dashboard-progress": "Generuojamas skydelis {{reportType}} ...", + "create-new-dashboard": "Sukurti naują skydelį", + "dashboard-file": "Skydelio failas", + "invalid-dashboard-file-error": "Skydelio importuoti nepavyko: neteisinga duomenų struktūra.", + "dashboard-import-missing-aliases-title": "Konfigūruoti importuoto skydelio naudojamus pseudonimus", + "create-new-widget": "Sukurti naują valdiklį", + "import-widget": "importuoti valdiklį", + "widget-file": "valdiklio failas", + "invalid-widget-file-error": "Valdiklio importuoti nepavyko: neteisinga duomenų struktūra.", + "widget-import-missing-aliases-title": "Konfigūruoti importuoto valdiklio naudojamus pseudonimus", + "open-toolbar": "Atidaryti skydelio įrankių juostą", + "close-toolbar": "Uždaryti įrankių juostą", + "configuration-error": "Konfigūracijos klaida", + "alias-resolution-error-title": "Skydelio pseudonimų konfigūracijos klaida", + "invalid-aliases-config": "Įrenginių, atitinkančių pseudonimų filtrus, nėra.
Susisiekite su sistemos administratoriumi.", + "select-devices": "Pasirinkite įrenginius", + "assignedToCustomer": "Priskirta klientui", + "assignedToCustomers": "Priskirta klientams", + "public": "Viešas", + "copyId": "Kopijuoti skydelio Id", + "idCopiedMessage": "Skydelio Id nukopijuotas į iškarpinę", + "public-link": "Vieša nuoroda", + "copy-public-link": "Kopijuoti viešą nuorodą", + "public-link-copied-message": "Skydelio vieša nuoroda nukopijuota į iškarpinę", + "manage-states": "Valdyti skydelio būsenas", + "states": "Skydelio būsenos", + "states-short": "Būsenos", + "search-states": "Skydelio būsenų paieška", + "selected-states": "Pasirinkta skydelio { count, plural, =1 {1 būsena} other {# būsenos} }", + "edit-state": "Redaguoti skydelio būseną", + "delete-state": "Panaikinti skydelio būseną", + "add-state": "Pridėti skydelio būseną", + "no-states-text": "Būsenų nėra", + "state": "Skydelio būsena", + "state-name": "Pavadinimas", + "state-name-required": "Skydelio pavadinimas būtinas.", + "state-id": "Būsenos Id", + "state-id-required": "Būsenos Id būtinas.", + "state-id-exists": "Skydelio būsena su tokiu pačiu Id jau yra.", + "is-root-state": "Pagrindinė būsena", + "delete-state-title": "Panaikinti skydelio būseną", + "delete-state-text": "Ar tikrai norite panaikinti skydelio būseną '{{stateName}}'?", + "show-details": "Rodyti detales", + "hide-details": "Slėpti detales", + "select-state": "Pasirinkite būseną", + "state-controller": "Būsenų valdiklis", + "search": "Skydelių paieška", + "selected-dashboards": "Pasirinkta { count, plural, =1 {1 skydelis} other {# skydeliai} }", + "home-dashboard": "Pagrindinis skydelis", + "home-dashboard-hide-toolbar": "Slėpti pagrindinio skydelio įrankių juostą", + "select-group-to-add": "Pasirinkite grupę, prie kurios pridėti pažymėtus skydelius", + "select-group-to-move": "Pasirinkite grupę, į kurią perkelti pasirinktus skydelius", + "remove-dashboards-from-group": "Ar tikrai pašalinti { count, plural, =1 {1 skydelį} other {# skydelius} } iš grupės '{{entityGroup}}'?", + "group": "Skydelių grupė", + "list-of-groups": "{ count, plural, =1 {Viena skydelių grupė} other {Sąrašas # skydelių grupių} }", + "group-name-starts-with": "Skydelių grupės, kurių pavadinimai prasideda '{{prefix}}'", + "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", + "edit-mode": "Edit mode" + }, + "datakey": { + "settings": "Nustatymai", + "general": "Bendra", + "advanced": "Patyrusiam vartotojui", + "key": "Raktas", + "label": "Etiketė", + "color": "Spalva", + "units": "Specialieji simboliai, rodomi šalia reikšmės", + "decimals": "Skaičių kiekis po kablelio", + "data-generation-func": "Duomenų generavimo funkcija", + "use-data-post-processing-func": "Papildomo duomenų apdorojimo funkcija", + "configuration": "Duomenų raktų konfigūracija", + "timeseries": "Telemetrija", + "attributes": "Atributai", + "entity-field": "Subjekto laukas", + "alarm": "Įspėjimo laukai", + "timeseries-required": "Subjekto telemetrija būtina.", + "timeseries-or-attributes-required": "Subjekto telemetrija/atributai būtini.", + "alarm-fields-timeseries-or-attributes-required": "Įspėjimo laukai ar subjekto telemetrijos/atributai būtini.", + "maximum-timeseries-or-attributes": "Leidžiama daugiausiai { count, plural, =1 {1 viena telemetrija/atributas.} other {# telemetrijos/atributai} }", + "alarm-fields-required": "Įspėjimo laukai būtini.", + "function-types": "Funkcijų tipai", + "function-type": "Funkcijos tipas", + "function-types-required": "Funkcijos tipai būtini.", + "data-keys": "Duomenų raktai", + "data-key": "Duomenų raktas", + "data-keys-required": "Duomenų raktai būtini.", + "data-key-required": "Duomenų raktas būtinas.", + "alarm-keys": "Įspėjimo duomenų raktai", + "alarm-key": "Įspėjimo duomenų raktas", + "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": "Daugiausiai leidžiama { count, plural, =1 {1 funkcijos tipas.} other {# funkcijos tipai} }", + "time-description": "Dabartinės vertės laiko žyma;", + "value-description": "Dabartinė vertė;", + "prev-value-description": "Ankstesnio funkcijos kvietimo rezultatas;", + "time-prev-description": "Ankstesnės vertės laiko žyma;", + "prev-orig-value-description": "Pradinė ankstesnė vertė;", + "aggregation": "Agregavimas", + "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)", + "source": "Source", + "latest": "Latest", + "latest-value": "Latest value", + "delta": "delta", + "percent": "percent", + "absolute": "absolute" + }, + "datasource": { + "type": "Duomenų šaltinio tipas", + "name": "Pavadinimas", + "label": "Etiketė", + "add-datasource-prompt": "Pridėkite duomenų šaltinį" + }, + "details": { + "details": "Informacija", + "edit-mode": "Redagavimo režimas", + "edit-json": "Redaguoti JSON", + "toggle-edit-mode": "Redagavimo režimas" + }, + "device": { + "all": "Visi", + "all-devices": "Visi įrenginiai", + "groups": "Grupės", + "shared": "Bendrinami", + "device": "Įrenginys", + "device-required": "Įrenginys būtinas.", + "devices": "Įrenginiai", + "management": "Įrenginių valdymas", + "view-devices": "Peržiūrėti įrenginius", + "device-alias": "Įrenginio pseudonimas", + "device-type-max-length": "Įrenginio tipas negali viršyti 256 simbolių", + "aliases": "Įrenginių pseudonimai aliases", + "no-alias-matching": "'{{alias}}' nerasta.", + "no-aliases-found": "Pseudonimų nėra.", + "no-key-matching": "'{{key}}' nerasta.", + "no-keys-found": "Raktų nėra.", + "create-new-alias": "Sukurti naują!", + "create-new-key": "Sukurti naują!", + "duplicate-alias-error": "'{{alias}}' dubliuojasi.
Įrenginio pseudonimai skydelyje turi būti unikalūs.", + "configure-alias": "Sukonfigūruoti '{{alias}}' pseudonimą", + "no-devices-matching": "Įrenginių, attinkančių '{{entity}}' nėra.", + "alias": "Pseudonimas", + "alias-required": "Įrenginio pseudonimas būtinas.", + "remove-alias": "Panaikinti įrenginio pseudonimą", + "add-alias": "Pridėti įrenginio pseudonimą", + "name-starts-with": "Įrenginio pavadinimas prasideda", + "help-text": "Naudokite '%' simbolį pagal tai, kaip norite ieškoti: '%įrenginio_pavadinimo_fragmentas%', '%įrenginio_pavadinimo_pabaiga', 'įrenginio_pavadinimo_pradžia%'.", + "device-list": "Įrenginių sąrašas", + "use-device-name-filter": "Naudoti filtrą", + "device-list-empty": "Įrenginiai nepasirinkti.", + "device-name-filter-required": "Įrenginio pavadinimo filtras turi būti nustatytas.", + "device-name-filter-no-device-matched": "Įrenginių, kurių pavadinimas prasideda '{{device}}' nėra.", + "add": "Pridėti įrenginį", + "assign-to-customer": "Priskirti kientui", + "assign-device-to-customer": "Įrenginį (-ius) priskirti klientui", + "assign-device-to-customer-text": "Pasirinkite įrenginius, kuriuos norite priskirti klientui", + "make-public": "Įrenginį padaryti viešu", + "make-private": "Įrenginį padaryti privačiu", + "no-devices-text": "Įrenginių nėra", + "assign-to-customer-text": "Pasirinkite klientą, kuriam norite priskirti įrenginį (-ius)", + "device-details": "Įrenginio informacija", + "add-device-text": "Pridėti naują įrenginį", + "credentials": "Įgaliojimai", + "manage-credentials": "Valdyti įgaliojimus", + "delete": "Panaikinti įrenginį", + "assign-devices": "priskirti įrenginius", + "assign-devices-text": "Priskirti { count, plural, =1 {1 įrenginį} other {# įrenginius} } klientui", + "delete-devices": "Panaikinti įrenginius", + "unassign-from-customer": "Atseti nuo kliento", + "unassign-devices": "Atsieti įrenginius", + "unassign-devices-action-title": "Atsieti { count, plural, =1 {1 įrenginį} other {# įrenginius} } nuo kliento", + "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": "Priskirti naują įrenginį", + "make-public-device-title": "Ar tikrai norite įrenginį '{{deviceName}}' padaryti viešu?", + "make-public-device-text": "Po patvirtinimo įrenginys ir visi jo duomenys bus vieši ir prieinami kitiems vartotojams.", + "make-private-device-title": "Ar tikrai norite įrenginį '{{deviceName}}' padaryti privačiu?", + "make-private-device-text": "Po patvirtinimo įrenginys ir visi jo duomenys taps privatūs ir nebus prieinami kitiems vartotojams.", + "view-credentials": "Peržiūrėti įgaliojimus", + "delete-device-title": "Ar tikrai norite panaikinti įrenginį '{{deviceName}}'?", + "delete-device-text": "Būkite dėmesingi, po patvirtinimo įrenginys ir visa su juo susijusi informacija bus panaikinta ir jų atkurti nebegalėsite", + "delete-devices-title": "Ar tikrai norite panaikinti { count, plural, =1 {1 įrenginį} other {# įrenginius} }?", + "delete-devices-action-title": "Panaikinti { count, plural, =1 {1 įrenginį} other {# įrenginius} }", + "delete-devices-text": "Būkite dėmesingi, po patvirtinimo, visi pasirinkti įrenginiai ir su jais susijusi informacija bus panaikinta ir jų atkurti nebegalėsite.", + "unassign-device-title": "Ar tikrai norite atsieti įrenginį '{{deviceName}}'?", + "unassign-device-text": "Po patvirtinimo įrenginys bus atsietas ir klientas jo nebematys.", + "unassign-device": "Atsieti įrenginį", + "unassign-devices-title": "Ar tikrai norite atsieti { count, plural, =1 {1 įrenginį} other {# įrenginius} }?", + "unassign-devices-text": "Po patvirtinimo visi pasirinkti įrenginiai bus atsieti nuo kliento.", + "device-credentials": "įrenginio įgaliojimai", + "loading-device-credentials": "Įkeliami įrenginio įgaliojimai...", + "credentials-type": "Įgaliojimų tipas", + "access-token": "Prieigos raktas", + "access-token-required": "Prieigos raktas būtinas.", + "access-token-invalid": "Prieigos rakto ilgis turi būti nuo 1 iki 32 simbolių.", + "certificate-pem-format": "Certificate in PEM format", + "certificate-pem-format-required": "Certificate is required.", + "copy-access-token": "Copy Access token", + "copy-certificate": "Copy Certificate", + "copy-client-id": "Copy Client ID", + "copy-user-name": "Copy User name", + "copy-password": "Copy Password", + "generate-client-id": "Generate Client ID", + "generate-user-name": "Generate User name", + "generate-password": "Generate Password", + "generate-access-token": "Generate Access Token", + "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": "Įrenginio tipas", + "device-type-required": "Įrenginio tipas būtinas.", + "select-device-type": "Pasirinkite įrenginio tipą", + "enter-device-type": "Įveskite įrenginio tipą", + "any-device": "Bet kuris įrenginys", + "no-device-types-matching": "Įrenginio tipų, atitinkančių '{{entitySubtype}}', nėra.", + "device-type-list-empty": "Nepasirinktas įrenginio tipas!", + "device-types": "Įrenginių tipai", + "name": "Pavadinimas", + "name-required": "Pavadinimas būtinas.", + "name-max-length": "Pavadinimas negali viršyti 256 simbolių", + "label-max-length": "Etiketė negali viršyti 256 simbolių", + "description": "Aprašymas", + "label": "Etiketė", + "events": "Įvykiai", + "details": "Informacija", + "copyId": "Kopijuoti įrenginio Id", + "copyAccessToken": "Kopijuoti prieigos raktą", + "copy-mqtt-authentication": "Copy MQTT credentials", + "idCopiedMessage": "Įrenginio Id nukopijuotas į iškarpinę", + "accessTokenCopiedMessage": "Įrenginio prieigos raktas nukopijuotas į iškarpinę", + "mqtt-authentication-copied-message": "Device MQTT authentication has been copied to clipboard", + "assignedToCustomer": "Priskirtas klientui", + "unable-delete-device-alias-title": "Įrenginio pseudonimo panaikinti nepavyko", + "unable-delete-device-alias-text": "Įrenginio pseudonimas '{{deviceAlias}}' nes jis naudojamas šiuose valdikliuose:
{{widgetsList}}", + "is-gateway": "Is gateway", + "overwrite-activity-time": "Overwrite activity time for connected device", + "device-filter": "Įrenginių filtras", + "device-filter-title": "Įrenginių filtras", + "filter-title": "Filtras", + "device-state": "Įrenginio būsena", + "state": "Būsena", + "any": "Visos", + "active": "Aktyvus", + "inactive": "Neaktyvus", + "public": "Viešas", + "device-public": "Įrenginys yra viešas", + "select-device": "Pasirinkite įrenginį", + "select-group-to-add": "Pasirinkite grupę, į kurią norite pridėti pažymėtus įrenginius", + "select-group-to-move": "Pasirinkite grupę, į kurią norite perkelti pažymėtus įrenginius", + "remove-devices-from-group": "Ar tikrai norite iš grupės '{{entityGroup}}' pašalinti { count, plural, =1 {1 įrenginį} other {# įrenginius} }?", + "group": "Įrenginių grupė", + "list-of-groups": "{ count, plural, =1 {Viena įrenginio grupė} other {# įrenginių grupių sąrašas} }", + "group-name-starts-with": "Įrenginių grupės, kurių pavadinimai prasideda '{{prefix}}'", + "import": "Importuoti įrenginį", + "device-file": "Įrenginio failas", + "search": "Įrenginių paieška", + "selected-devices": "Pasirinkta { count, plural, =1 {1 įrenginys} other {# įrenginiai} }", + "device-configuration": "Įrenginių konfigūracija", + "transport-configuration": "Transport configuration", + "wizard": { + "device-details": "Informacija apie įrenginį" + }, + "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.", + "time": "Time", + "connectivity": { + "check-connectivity": "Check connectivity", + "device-created-check-connectivity": "Device created. Let's check connectivity!", + "loading-check-connectivity-command": "Loading check connectivity commands...", + "use-following-instructions": "Use the following instructions for sending telemetry on behalf of the device using shell", + "execute-following-command": "Execute the following command", + "install-curl-windows": "Starting Windows 10 b17063, cURL is available by default", + "install-curl-macos": "Starting Mac OS X 10.2 6C115 (Jaguar), cURL is available by default", + "install-mqtt-windows": "Use the instructions to download, install, setup and run mosquitto_pub", + "install-coap-client": "Use the instructions to download, install, setup and run coap-client", + "install-necessary-client-tools": "Install necessary client tools", + "mqtts-x509-command": "Use the following documentation to connect the device via MQTT with authorization X509", + "coaps-x509-command": "Use the following documentation to connect the device via CoAP over DTLS with authorization X509", + "snmp-command": "Use the following documentation to connect the device through the SNMP.", + "sparkplug-command": "Use the following documentation to connect the device through the MQTT Sparkplug.", + "lwm2m-command": "Use the following documentation to connect the device through the LWM2M." + } + }, + "asset-profile": { + "asset-profile": "Profilis", + "asset-profiles": "Asset profiles", + "all-asset-profiles": "Visi", + "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", + "default-edge-rule-chain": "Default edge rule chain", + "default-edge-rule-chain-hint": "Used on edge as rule chain to process incoming data for assets of this asset profile", + "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": "Profilis", + "device-profiles": "Device profiles", + "all-device-profiles": "Visi", + "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", + "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", + "transport-type-http": "HTTP", + "description": "Description", + "default": "Default", + "profile-configuration": "Profile configuration", + "transport-configuration": "Transport configuration", + "default-rule-chain": "Default rule chain", + "default-edge-rule-chain": "Default edge rule chain", + "default-edge-rule-chain-hint": "Used on edge as rule chain to process incoming data for devices of this device profile", + "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-topic-filters-spark-plug": "MQTT Sparkplug B Edge of Network (EoN) node.", + "mqtt-device-topic-filters-spark-plug-hint": "Allow connections from EoN nodes with Sparkplug B payload and topic format.", + "mqtt-device-topic-filters-spark-plug-attribute-metric-names": "SparkPlug metrics to store as attributes.", + "mqtt-device-topic-filters-spark-plug-attribute-metric-names-hint": "Names of SparkPlug metrics that will be stored as device attributes. All other metrics will be stored as device telemetry.", + "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 publish topic filter", + "attributes-subscribe-topic-filter": "Attributes subscribe topic filter", + "attributes-topic-filter-required": "Attributes publish topic filter is required.", + "attributes-subscribe-topic-filter-required": "Attributes subscribe topic 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-owner-hierarchy": "Propagate alarm to entity owners hierarchy", + "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", + "provision-strategy-x509": { + "certificate-chain": "X509 Certificates Chain", + "certificate-chain-hint": "X.509 certificates strategy is used to provision devices by client certificates in two-way TLS communication.", + "allow-create-new-devices": "Create new devices", + "allow-create-new-devices-hint": "If selected new devices will be created and client certificate will be used as device credentials.", + "certificate-value": "Certificate in PEM format", + "certificate-value-required": "Certificate in PEM format is required", + "cn-regex-variable": "CN Regular Expression variable", + "cn-regex-variable-required": "CN Regular Expression variable is required", + "cn-regex-variable-hint": "Required to fetch device name from device's X509 certificate's common name." + }, + "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": "Uždaryti dialogo langą", + "error-message-title": "Klaidos pranešimas:", + "error-details-title": "Informacija apie klaidą" + }, + "direction": { + "column": "Stulpelis", + "row": "Eilutė" + }, + "edge": { + "all": "All", + "all-edges": "All edges", + "groups": "Groups", + "shared": "Shared", + "edge": "Edge", + "edge-instances": "Edge instances", + "instances": "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.", + "edge-license-key": "Edge License Key", + "edge-license-key-required": "Edge License Key is required.", + "edge-license-key-max-length": "Edge License Key should be less than 31", + "edge-license-key-hint": "IMPORTANT NOTE: default edge license key provided only for evaluation purposes.
This key is active only for 30 days after the activation. White-labeling feature is disabled.
To obtain your license key please navigate to the pricing page and select the best license option for your case.", + "cloud-endpoint": "Cloud Endpoint", + "cloud-endpoint-required": "Cloud Endpoint is required.", + "cloud-endpoint-max-length": "Cloud Endpoint should be less than 256", + "cloud-endpoint-hint": "Edge requires HTTP(s) access to Cloud (ThingsBoard PE) to verify the license key. Please specify Cloud URL that Edge is able to connect to.", + "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", + "install-connect-instructions": "Install & Connect Instructions", + "install-connect-instructions-edge-created": "Edge created! Check Install & Connect Instructions", + "loading-edge-instructions": "Loading edge instructions...", + "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", + "manage-assets": "Manage assets", + "manage-devices": "Manage devices", + "manage-entity-views": "Manage entity views", + "manage-dashboards": "Manage dashboards", + "manage-rulechains": "Manage rule chains", + "assets": "Edge assets", + "devices": "Edge devices", + "entity-views": "Edge entity views", + "dashboard": "Edge dashboard", + "dashboards": "Edge Dashboards", + "rulechain-templates": "Rule chain templates", + "edge-rulechain-templates": "Edge rule chain templates", + "converter-templates": "Converter templates", + "edge-converter-templates": "Edge converter templates", + "integration-templates": "Integration templates", + "edge-integration-templates": "Edge integration templates", + "rulechains": "Edge rule chains", + "integrations": "Integrations", + "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", + "assign-to-edge": "Assign to edge", + "assign-to-edge-title": "Assign Entity Group(s) to Edge", + "manage-edge-user-groups": "Manage edge user groups", + "manage-edge-asset-groups": "Manage edge asset groups", + "manage-edge-device-groups": "Manage edge device groups", + "manage-edge-entity-view-groups": "Manage edge entity view groups", + "manage-edge-dashboard-groups": "Manage edge dashboard groups", + "manage-edge-rule-chains": "Manage edge rule chains", + "manage-edge-integrations": "Manage edge integrations", + "manage-edge-scheduler-events": "Manage edge scheduler events", + "select-group-to-add": "Select target group to add selected edges", + "select-group-to-move": "Select target group to move selected edges", + "remove-edges-from-group": "Are you sure you want to remove { count, plural, =1 {1 edge} other {# edges} } from group '{entityGroup}'?", + "group": "Group of edges", + "list-of-groups": "{ count, plural, =1 {One edge group} other {List of # edge groups} }", + "group-name-starts-with": "Edge groups whose names start with '{{prefix}}'", + "unassign-entity-group-from-edge-title": "Are you sure you want to unassign the entity group '{{ entityGroupName }}'?", + "unassign-entity-group-from-edge-text": "After the confirmation the entity group will be unassigned and won't be accessible by the edge.", + "unassign-entity-group-from-edge": "Unassign entity group from edge", + "unassign-entity-groups-from-edge-title": "Are you sure you want to unassign {{count}} entity groups?", + "unassign-entity-groups-from-edge-text": "After the confirmation entity groups will be unassigned and won't be accessible by the edge.", + "unassign-entity-groups-from-edge": "Unassign entity groups from edge", + "unassign-scheduler-events-from-edge": "Unassign scheduler events from edge", + "unassign-scheduler-event-from-edge-title": "Are you sure you want to unassign scheduler event '{{schedulerEventName}}'?", + "unassign-scheduler-event-from-edge-text": "After the confirmation the scheduler event will be unassigned and won't be accessible by the edge.", + "unassign-scheduler-events-from-edge-title": "Are you sure you want to unassign { count, plural, =1 {1 scheduler event} other {# scheduler events} }?", + "unassign-scheduler-events-from-edge-text": "After the confirmation all selected scheduler events will be unassigned and won't be accessible by the edge.", + "manage-user-groups": "Manage user groups", + "manage-asset-groups": "Manage asset groups", + "manage-device-groups": "Manage device groups", + "manage-dashboard-groups": "Manage dashboard groups", + "manage-entity-view-groups": "Manage entity view groups", + "manage-scheduler-events": "Manage scheduler events", + "manage-integrations": "Manage integrations", + "assign-scheduler-event-to-edge-title": "Assign Scheduler Events To Edge", + "assign-scheduler-event-to-edge-text": "Please select the scheduler events to assign to the edge", + "assign-entity-groups-to-edge-text": "Please select the entity groups to assign to the edge", + "assign-integration-to-edge-title": "Assign Integrations To Edge", + "assign-integration-to-edge-text": "Please select the integrations to assign to the edge", + "missing-attributes-title": "Edge has missing attribute(s)", + "missing-attributes-text": "Integration(s), assigned to edge, have attribute placeholders that are not available on this edge.

List of missing edge attributes(s):
{{missingEdgeAttributes}}", + "missing-all-related-attributes-title": "Edge(s) have missing attribute(s)", + "missing-all-related-attributes-text": "Edge(s), related to integration, don't have attributes that are present inside integration configuration as attribute placeholders.

List of missing edge attributes(s):
{{missingEdgeAttributes}}", + "quick-overview-widget-header": "{{edgeName}} Quick Overview" + }, + "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-entity-group": "Entity Group", + "type-rule-chain": "Rule Chain", + "type-rule-chain-metadata": "Rule Chain Metadata", + "type-edge": "Edge", + "type-user": "User", + "type-tenant": "Tenant", + "type-tenant-profile": "Tenant Profile", + "type-customer": "Customer", + "type-relation": "Relation", + "type-widgets-bundle": "Widgets Bundle", + "type-widgets-type": "Widgets Type", + "type-admin-settings": "Admin Settings", + "type-ota-package": "Ota Package", + "type-queue": "Queue", + "type-scheduler-event": "Scheduler Event", + "type-white-labeling": "White Labeling", + "type-login-white-labeling": "Login White Labeling", + "type-custom-translation": "Custom Translation", + "type-role": "Role", + "type-group-permission": "Group Permission", + "type-integration": "Integration", + "type-converter": "Converter", + "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-alarm-assigned": "Alarm Assigned", + "action-type-alarm-unassigned": "Alarm Unassigned", + "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", + "action-type-added-to-entity-group": "Added to Entity Group", + "action-type-removed-from-entity-group": "Removed from Entity Group", + "action-type-change-owner": "Change owner" + }, + "error": { + "unable-to-connect": "Nepavyksta prisijungti prie serverio! Patikrinkite interneto ryšį.", + "unhandled-error-code": "Neapdorotos klaidos kodas: {{errorCode}}", + "unknown-error": "Nežinoma klaida" + }, + "entity": { + "entity": "Subjektas", + "entities": "Subjektai", + "entities-count": "Subjektų kiekis", + "alarms-count": "Įspėjimų kiekis", + "aliases": "Subjektų pseudonimai", + "aliases-short": "Pseudonimai", + "entity-alias": "Subjekto pseudonimas", + "unable-delete-entity-alias-title": "Subjekto pseudonimo panaikinti nepavyko", + "unable-delete-entity-alias-text": "Subjekto pseudonimas '{{entityAlias}}' negali būti panaikintas, nes jis naudojamas valdikliuose:
{{widgetsList}}", + "duplicate-alias-error": "Pseudonimas '{{alias}}' dubliuojasi.
Subjektų pseudonimai skydelyje negali kartotis.", + "missing-entity-filter-error": "Trūksta pseudonimo '{{alias}}' filtro.", + "configure-alias": "Konfigūruoti '{{alias}}' pseudonimą", + "alias": "Pseudonimas", + "alias-required": "Subjekto pseudonimas būtinas.", + "remove-alias": "Panaikinti subjekto pseudonimą", + "add-alias": "Pridėti subjekto pseudonimą", + "entity-list": "Subjektų sąrašas", + "entity-type": "Subjekto tipas", + "entity-types": "Subjektų tipai", + "entity-type-list": "Subjektų tipų sąrašas", + "any-entity": "Bet kuris subjektas", + "add-entity-type": "Pridėti subjekto tipą", + "enter-entity-type": "Įveskitesubjekto tipą", + "no-entities-matching": "Subjektų, atitinkančių '{{entity}}' nėra.", + "no-entity-types-matching": "Subjektų tipų, atitinkančių '{{entityType}}' nėra.", + "name-starts-with": "Pavadinimas prasideda", + "help-text": "naudokite simbolį '%' pagal tai, kaip norite ieškoti: '%subjekto_pavadinimo_fragmentas%', '%subjekto_pavadinimo_pabaiga', 'subjekto_pavadinimo_pradžia%'.", + "use-entity-name-filter": "Naudoti filtrą", + "entity-list-empty": "Subjektai nepasirinkti.", + "entity-type-list-required": "Nors vienas subjekto tipas turi būti pasirinktas.", + "entity-name-filter-required": "Subjekto vardo filtras būtinas.", + "entity-name-filter-no-entity-matched": "Subjektų, kurių pavadinimas prasideda '{{entity}}' nėra.", + "all-subtypes": "Visi", + "select-entities": "Pasirinkte subjektus", + "no-aliases-found": "Pseudonimų nėra.", + "no-alias-matching": "'{{alias}}' nėra.", + "create-new-alias": "Sukurti naują!", + "create-new": "Sukurti naują", + "key": "Raktas", + "key-name": "Rakto pavadinimas", + "no-keys-found": "Rakto nėra.", + "no-key-matching": "'{{key}}' nėra.", + "create-new-key": "Sukurti naują!", + "type": "Tipas", + "type-required": "Subjekto tipas būtinas.", + "type-device": "Įrenginys", + "type-devices": "Įrenginiai", + "list-of-devices": "{ count, plural, =1 {Vienas įrenginys} other {# įrenginių sąrašas} }", + "device-name-starts-with": "Įrenginiai, kurių pavadinimas prasideda '{{prefix}}'", + "type-device-profile": "Įrenginio profilis", + "type-device-profiles": "Device profiles", + "clear-selected-profiles": "Clear selected 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": "Turtas", + "type-assets": "Turtas", + "list-of-assets": "{ count, plural, =1 {Turtas} other {# Turto sąrašas} }", + "asset-name-starts-with": "Turtas, kurio pavadinimas prasideda '{{prefix}}'", + "type-entity-view": "Subjekto rodinys", + "type-entity-views": "Subjektų rodinys", + "list-of-entity-views": "{ count, plural, =1 {Vienas subjekto rodinys} other {# Subjektų rodinių sąrašas} }", + "entity-view-name-starts-with": "Subjektų rodiniai, kurių pavadinimai prasideda '{{prefix}}'", + "type-rule": "Taisyklė", + "type-rules": "Taisyklės", + "list-of-rules": "{ count, plural, =1 {Viena taisyklė} other {# Taisyklių sąrašas} }", + "rule-name-starts-with": "Taisyklės, kurių pavadinimai prasideda '{{prefix}}'", + "type-plugin": "Papildinys", + "type-plugins": "Papildiniai", + "list-of-plugins": "{ count, plural, =1 {Vienas papildinys} other {# Papildinių sąrašas} }", + "plugin-name-starts-with": "Papildiniai, kurių pavadinimai prasideda '{{prefix}}'", + "type-tenant": "Valdytojas", + "type-tenants": "valdytojai", + "list-of-tenants": "{ count, plural, =1 {Vienas valdytojas} other {# Valdytojų sąrašas} }", + "tenant-name-starts-with": "Valdytojai, kurių pavadinimai prasideda '{{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": "Klientas", + "type-customers": "Klientai", + "list-of-customers": "{ count, plural, =1 {Vienas klientas} other {# Klientų sąrašas} }", + "customer-name-starts-with": "Klientai, kurių pavadinimai prasideda '{{prefix}}'", + "type-user": "Vartotojas", + "type-users": "Vartotojai", + "list-of-users": "{ count, plural, =1 {Vienas vartotojas} other {# Vartotojų sąrašas} }", + "user-name-starts-with": "Vartotojai, kurių pavadinimai prasideda '{{prefix}}'", + "type-dashboard": "Skydelis", + "type-dashboards": "Skydeliai", + "list-of-dashboards": "{ count, plural, =1 {Vienas skydelis} other {# Skydelių sąrašas} }", + "dashboard-name-starts-with": "Skydeliai, kurių pavadinimai prasideda '{{prefix}}'", + "type-alarm": "Įspėjimas", + "type-alarms": "Įspėjimai", + "list-of-alarms": "{ count, plural, =1 {Vienas įspėjimas} other {# Įspėjimų sąrašas} }", + "alarm-name-starts-with": "Įspėjimai, kurių pavadinimai prasideda '{{prefix}}'", + "type-rulechain": "Taisyklių grandinė", + "type-rulechains": "Taisyklių grandinės", + "list-of-rulechains": "{ count, plural, =1 {Viena taisyklių grandinė} other {# Taisyklių grandinių sąrašas} }", + "rulechain-name-starts-with": "Taisyklių grandinės, kurų pavadinimai prasideda '{{prefix}}'", + "type-scheduler-event": "Tvarkaraščio įvykis", + "type-scheduler-events": "Tvarkaraščio įvykiai", + "list-of-scheduler-events": "{ count, plural, =1 {Vienas tvarkaraščio įvykis} other {# Tvarkaraščio įvykių sąrašas} }", + "scheduler-event-name-starts-with": "Tvarkaraščio įvykiai, kurių pavadinimai prasideda '{{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": "Tasyklė", + "type-rulenodes": "Taisyklės", + "list-of-rulenodes": "{ count, plural, =1 {Viena taisyklė} other {# Taisyklių sąrašas} }", + "rulenode-name-starts-with": "Taisyklės, kurių pavadinimai prasideda '{{prefix}}'", + "type-current-customer": "Dabartinis klientas", + "type-current-tenant": "Dabartinis valdytojas", + "type-current-user": "Dabartinis vartotojas", + "type-current-user-owner": "Dabartinio vartotojo savininkas", + "type-widgets-bundle": "Valdiklių rinkinys", + "type-widgets-bundles": "Valdiklių rinkiniai", + "list-of-widgets-bundles": "{ count, plural, =1 {Vienas valdiklių rinkinys} other {# Valdiklių rinkinių sąrašas} }", + "type-widget-type": "Valdiklio tipas", + "type-widget-types": "Valdiklių tipai", + "list-of-widget-types": "{ count, plural, =1 {Vienas valdiklio tipas} other {# Valdiklių tipų sąrašas} }", + "search": "Subjektų paieška", + "selected-entities": "Pasirinkta { count, plural, =1 {1 subjektas} other {# subjektai} }", + "entity-name": "Subjekto pavadinimas", + "entity-label": "Subjekto etikelė", + "details": "Informacija apie subjektą", + "no-entities-prompt": "Subjektų nėra", + "no-data": "Duomenų atvaizdavimui nėra", + "columns-to-display": "Rodomi stulpeliai", + "type-api-usage-state": "Api Usage State", + "type-entity-group": "Subjektų grupė", + "type-converter": "Duomenų keitiklis", + "type-converters": "Duomenų keitikliai", + "list-of-converters": "{ count, plural, =1 {Vienas duomenų keitiklis} other {# Duomenų keitiklių sąrašas} }", + "converter-name-starts-with": "Duomenų keitikliai, kurių pavadinimai prasideda '{{prefix}}'", + "type-integration": "Integracija", + "type-integrations": "Integracijos", + "list-of-integrations": "{ count, plural, =1 {Viena integracija} other {# Integracijų sąrašas} }", + "integration-name-starts-with": "Integracijos, kurių pavadinimai prasideda '{{prefix}}'", + "type-role": "Rolė", + "type-roles": "Rolės", + "list-of-roles": "{ count, plural, =1 {Viena rolė} other {# rolių sąrašas} }", + "role-name-starts-with": "Rolės, kurių pavadinimai prasideda '{{prefix}}'", + "type-group-permission": "Grupės leidimai", + "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", + "type-rpc": "RPC", + "type-queue": "Queue", + "type-notification": "Notification", + "type-notification-rule": "Notification rule", + "type-notification-rules": "Notification rules", + "list-of-notification-rules": "{ count, plural, =1 {One notification rule} other {List of # notification rules} }", + "type-notification-target": "Notification recipient", + "type-notification-targets": "Notification recipients", + "list-of-notification-targets": "{ count, plural, =1 {One notification recipient} other {List of # notification recipients} }", + "type-notification-request": "Notification request", + "type-notification-template": "Notification template", + "type-notification-templates": "Notification templates", + "list-of-notification-templates": "{ count, plural, =1 {One notification template} other {List of # notification templates} }", + "customer-name": "Klientas", + "sub-customer-name": "Priklausantis klientas", + "parent-customer-name": "Pirminis klientas", + "parent-sub-customer-name": "Pirminis klientas", + "include-customer-entities": "Rodyti klientui priklausančius klientus", + "include-sub-customer-entities": "Rodyti priklausančių klientų subjektus", + "groups": "Grupė" + }, + "entity-group": { + "entity-group": "Subjektų grupė", + "details": "Informacija", + "columns": "Stulpeliai", + "add-column": "Pridėti stulpelį", + "column-value": "Reikšmė", + "column-value-required": "Stulpelio reikšmė būtina.", + "column-title": "Pavadinimas", + "default-sort-order": "Numatytoji rūšiavimo tvarka", + "default-sort-order-required": "Numatytoji rūšiavimo tvarka būtina.", + "hide-in-mobile-view": "Nerodyti mobiliąjame režime", + "use-cell-style-function": "Taikyti langelio stiliaus funkciją", + "use-cell-content-function": "Taikyti langelio turinio funkciją", + "edit-column": "Redaguoti stulpelį", + "column-details": "Stulpelio informacija", + "actions": "Veiksmai", + "settings": "Nustatymai", + "search": "Subjektų grupių paieška", + "delete": "Panaikinti subjektų grupę", + "name": "Pavadinimas", + "name-required": "Pavadinimas būtinas.", + "name-max-length": "Pavadinimas turi būti trumpesnis nei 256 simboliai", + "description": "Aprašymas", + "add": "Pridėti subjektų grupę", + "open-entity-group": "Atverti subjektų grupę", + "add-entity-group-text": "Pridėti naują subjektų grupę", + "no-entity-groups-text": "Subjektų grupių nėra", + "entity-group-details": "Informacija apie subjektų grupę", + "selected-entity-groups": "Pasirinkta { count, plural, =1 {1 subjektų grupė} other {# subjektų grupės} }", + "delete-entity-groups": "Panaikinti subjektų grupes", + "delete-entity-group-title": "Ar tikrai norite panaikinti subjektų grupę '{{entityGroupName}}'?", + "delete-entity-group-text": "Būkite dėmesingi, po patvirtinimo, subjektų grupė ir visa su ja susijusi informacija bus pašalinta ir jų atkurti nebegalėsite.", + "delete-entity-groups-title": "Ar tikrai norite panaikinti { count, plural, =1 {1 subjektų grupę} other {# subjektų grupes} }?", + "delete-entity-groups-action-title": "Panaikinti { count, plural, =1 {1 subjektų grupę} other {# subjektų grupes} }", + "delete-entity-groups-text": "Būkite dėmesingi, po patvirtinimo visos subjektų grupės ir su jomis susijusi informacija bus panaikintos ir jų atkurti nebegalėsite.", + "device-groups": "Įrenginių grupės", + "shared-device-groups": "Bendrinamos įrenginių grupės", + "asset-groups": "Turto grupės", + "shared-asset-groups": "Bendrinamos turto grupės", + "customer-groups": "Klientų grupės", + "shared-customer-groups": "Bendrinamos klientų grupės", + "device-group": "Įrenginių grupė", + "asset-group": "Turto grupė", + "user-group": "Vartotojų grupė", + "user-groups": "Vartotojų grupės", + "customer-group": "Klientų grupė", + "entity-view-groups": "Subjektų rodinių grupė", + "shared-entity-view-groups": "Bendrinamos subjektų rodinių grupės", + "entity-view-group": "Subjektų rodinių grupė", + "edge-groups": "Edge groups", + "shared-edge-groups": "Shared edge groups", + "edge-group": "Edge group", + "dashboard-groups": "Skydelių grupės", + "shared-dashboard-groups": "Bendrinamos skydelių grupės", + "dashboard-group": "Skydelių grupė", + "fetch-more": "Gauti daugiau", + "column-type": { + "column-type": "Stulpelio tipas", + "client-attribute": "Kliento atributas", + "shared-attribute": "Bendrinamas atributas", + "server-attribute": "Serverio atributas", + "timeseries": "Telemetrija", + "entity-field": "Subjekto laukas" + }, + "column-type-required": "Stulpelio tipas būtinas.", + "entity-field": { + "created-time": "Sukūrimo laikas", + "name": "Pavadinimas", + "type": "Tipas", + "device_profile": "Device profile", + "asset_profile": "Asset profile", + "assigned_customer": "Priskirtas klientas", + "authority": "Authority", + "first_name": "Vardas", + "last_name": "Pavardė", + "email": "El. paštas", + "title": "Pavadinimas", + "country": "Šalis", + "state": "Apskritis/rajonas", + "city": "Miestas", + "address": "Adresas", + "address2": "Adresas 2", + "zip": "Pašto kodas", + "phone": "Tel, numeris", + "label": "Etiketė" + }, + "sort-order": { + "asc": "Didėjanti", + "desc": "Mažėjanti", + "none": "Nėra" + }, + "details-mode": { + "on-row-click": "Spustelėjus eilutėje", + "on-action-button-click": "Paspaudus informacijos mygtuką", + "disabled": "Išjungta" + }, + "change-owner": "Pakeisti savininką", + "select-target-owner": "Pasirinkite savininką", + "no-owners-matching": "Savinikų atitinkančių '{{owner}}' nėra.", + "target-owner-required": "Savininkas būtinas.", + "confirm-change-owner-title": "Ar tikrai norite pakeisti savininką { count, plural, =1 {1 pasirinktam subjektui} other {# pasirinktiems subjektams} }?", + "confirm-change-owner-text": "Būkite dėmesingi, po patvirtinimo, visi pasirinkti subjektai iš esamo savininko bus perkelti į naujo savininko grupę 'Visi'.", + "add-to-group": "Pridėti į grupę", + "move-to-group": "Perkelti į grupę", + "select-entity-group": "Pasirinkite subjektų grupę", + "no-entity-groups-matching": "Subjektų grupių, atitinkančių '{{entityGroup}}' nėra.", + "create-new-entity-group": "Sukurti naują!", + "target-entity-group-required": "Subjektų grupė būtina.", + "select-user-group": "Pasirinkite vartotojų grupę", + "no-user-groups-matching": "Vartotojų grupių, atitinkančių '{{entityGroup}}' nėra.", + "target-user-group-required": "Vartotojų grupė būtina.", + "remove-from-group": "Pašalinti iš grupės", + "group-table-title": "Grupių lentelės pavadinimas", + "enable-search": "Leisti subjektų paiešką", + "enable-add": "Leisti pridėti subjektus", + "enable-delete": "Leisti panaikinti subjektus", + "enable-selection": "Leisti subjektų pasirinkimą", + "enable-group-transfer": "Leisti perkėlimus tarp grupių", + "display-pagination": "Rodyti dalinimą puslapiais", + "default-page-size": "Numatytasis puslapio dydis", + "enable-assignment-actions": "Leisti priskirti", + "enable-credentials-management": "Leisti valdyti įgaliojimus", + "enable-login-as-user": "Leisti prisijungti kaip vartotojui", + "enable-users-management": "Leisti valdyti vartotojus", + "enable-customers-management": "Leisti valdyti klientus", + "enable-assets-management": "Leisti valdyti turtą", + "enable-devices-management": "Leisti valdyti įrenginius", + "enable-entity-views-management": "Leisti subjektų rodinių valdymą", + "enable-edges-management": "Enable edges management", + "enable-dashboards-management": "Leisti skydelių valdymą", + "enable-scheduler-events-management": "Leisti tvarkaraščio įvykių valdymą", + "open-details-on": "Atverti subjekto informaciją", + "select-existing": "Pasirinkti egzistuojančią subjektų grupę", + "create-new": "Sukurti naują subjektų grupę", + "new-entity-group-name": "Naujos subjektų grupės pavadinimas", + "entity-group-list": "Subjektų grupių sąrašas", + "entity-group-list-empty": "Nepasirinktos subjektų grupės.", + "name-starts-with": "Subjektų grupės pavadinimas prasideda", + "entity-group-name-filter-required": "Subjektų grupės filtras būtinas.", + "roles": "Rolės", + "permissions": "Leidimai", + "public": "Vieša", + "entity-group-public": "Subjektų grupė yra vieša", + "make-public": "Subjektų grupę padaryti vieša", + "make-private": "Subjektų grupę padaryti privačia", + "make-public-entity-group-title": "Ar tikrai norite subjektų grupę '{{entityGroupName}}' padaryti vieša?", + "make-public-entity-group-text": "Po patvirtinimo subjektų grupė ir visi jai priklausantys subjektai taps vieši ir matomi kitiems vartotojams.", + "make-private-entity-group-title": "Ar tikrai norite subjektų grupę '{{entityGroupName}}' padaryti privačia?", + "make-private-entity-group-text": "Po patvirtinimo, subjektų grupė ir visi jai priklausantys subjektai taps privatūs ir nematomi kitiems vartotojams.", + "share": "Pasdalinti grupe", + "copyId": "Kopijuoti subjektų grupės Id", + "idCopiedMessage": "Subjektų grupės Id nukopijuotas į škarpinę", + "entity-group-name": "Subjektų grupės pavadinimas", + "all-users": "Visi vartotojai", + "owner": "Savininkas", + "owner-required": "Savininkas būtinas.", + "manage-owner-and-groups": "Savininkas ir grupės", + "owner-and-groups": "Savininkas ir grupės" + }, + "entity-field": { + "created-time": "Sukūrimo laikas", + "name": "Pavadinimas", + "type": "Tipas", + "first-name": "Vardas", + "last-name": "Pavardė", + "email": "El. paštas", + "title": "Pavadinimas", + "country": "Šalis", + "state": "Rajonas", + "city": "Miestas", + "address": "Addresas", + "address2": "Addresas 2", + "zip": "Pašto kodas", + "phone": "Telefonas", + "label": "Etikelė", + "configuration": "Konfigūracija", + "schedule": "Grafikas", + "originatorId": "Iniciatoriaus Id", + "originatorType": "Iniciatoriaus tipas" + }, + "entity-view": { + "all": "Visi", + "all-entity-views": "Visi subjektų rodiniai", + "groups": "Grupės", + "shared": "bendrinami", + "entity-view": "Subjektų rodiniai", + "entity-view-required": "Subjeto rodinys būtinas.", + "entity-views": "Subjektų rodiniai", + "management": "Subjektų rodinių valdymas", + "view-entity-views": "Peržiūrėti subjektų rodinius", + "entity-view-alias": "Subjektų rodinio pseudonimas", + "aliases": "Subjektų rodinio pseudonimai", + "no-alias-matching": "'{{alias}}' nerastas.", + "no-aliases-found": "Pseudonimų nėra.", + "no-key-matching": "'{{key}}' nerastas.", + "no-keys-found": "Raktų nėra.", + "create-new-alias": "Sukurti naują!", + "create-new-key": "Sukurti naują!", + "duplicate-alias-error": "'{{alias}}' dubliuojasi.
Įrenginio pseudonimai skydelyje turi būti unikalūs.", + "configure-alias": "Konfigūruoti '{{alias}}' pseudonimą", + "no-entity-views-matching": "Subjektų rodinių, atitinkančių '{{entity}}' nėra.", + "public": "Viešas", + "alias": "Pseudonimas", + "alias-required": "Subjekto rodinio pseudonimas būtinas.", + "remove-alias": "Panaikinti subjekto rodinio pseudonimą", + "add-alias": "Pridėti subjekto rodinio pseudonimą", + "name-starts-with": "Subjekto rodinio pavadinimas prasideda", + "help-text": "Naudokite '%' simbolį pagal tai, kaip norite ieškoti: '%subjekto_rodinio_pavadinimo_fragmentas%', '%subjekto_rodinio_pavadinimo_pabaiga', 'subjekto_rodinio_pavadinimo_pradžia%'.", + "entity-view-list": "Subjektų rodinių sąrašas", + "use-entity-view-name-filter": "Naudoti filtrą", + "entity-view-list-empty": "Subjektai nepasirinkti.", + "entity-view-name-filter-required": "Subjekto rodinio pavadinimo filtras būtinas.", + "entity-view-name-filter-no-entity-view-matched": "Subjektų rodinių, kurių pavadinimai prasideda '{{entityView}}' nėra.", + "add": "Pridėti subjektų rodinį", + "entity-view-public": "Subjektų rodnys yra viešas", + "assign-to-customer": "Priskirti klientui", + "assign-entity-view-to-customer": "Subjektų rodinį (-ius) priskirti klientui", + "assign-entity-view-to-customer-text": "Pasirinkite subjektų rodinius, kuriuos norite priskirti klientui", + "no-entity-views-text": "Subjektų rodinių nėra", + "assign-to-customer-text": "Pasirinkite klientą, kuriam priskirti subjektų rodinį (-ius)", + "entity-view-details": "Informacija apie subjektų rodinį", + "add-entity-view-text": "Pridėti naują subjektų rodinį", + "delete": "Panikinti subjektų rodinį", + "assign-entity-views": "Priskirti subjektų rodinius", + "assign-entity-views-text": "Priskirti { count, plural, =1 {1 subjektų rodinį} other {# subjektų rodinius} } klientui", + "delete-entity-views": "Panaikinti subjektų rodinius", + "make-public": "Subjektų rodinį padaryti viešu", + "make-private": "Subjektų rodinį padaryti privačiu", + "unassign-from-customer": "Atsieti nuo kliento", + "unassign-entity-views": "Atsieti subjektų rodinius", + "unassign-entity-views-action-title": "Atsieti { count, plural, =1 {1 subjektų rodinį} other {# subjektų rodinius} } nuo kliento", + "assign-new-entity-view": "Priskirti naują subjektų rodinį", + "delete-entity-view-title": "Ar tikrai norite panaikinti subjektų rodinį '{{entityViewName}}'?", + "delete-entity-view-text": "Būkite dėmesingi, po patvirtinimo, subjektų rodinys ir visa su juo susijusi informacija bus panaikinta ir jų atkurti nebegalėsite.", + "delete-entity-views-title": "Ar tikrai norite panaikinti { count, plural, =1 {1 subketų rodinį} other {# subjektų rodinius} }?", + "delete-entity-views-action-title": "Panaikinti { count, plural, =1 {1 subjektų rodinį} other {# subjektų rodinius} }", + "delete-entity-views-text": "Būkite dėmesingi, po patvirtinimo, visi pasirinkti subjektų rodiniai ir visa su jais susijusi informacija bus panaikinta ir jų atkurti nebegalėsite.", + "make-public-entity-view-title": "Ar tikrai norite subjektų rodinį '{{entityViewName}}' padaryti viešu?", + "make-public-entity-view-text": "Po patvirtinimo subjektų rodinys ir visa su juo susijusi informacija taps vieša ir matoma kitiems vartotojams.", + "make-private-entity-view-title": "Ar tikrai norite subjektų rodinį '{{entityViewName}}' padaryti privačiu?", + "make-private-entity-view-text": "Po patvirtinimo subjektų rodinys ir visa su juo susijusi informacija taps privati ir kiti vartotojai jų nebematys.", + "unassign-entity-view-title": "Ar tikrai norite atsieti subjektų rodinį '{{entityViewName}}'?", + "unassign-entity-view-text": "Po patvirtinimo subjektų rodinys bus atsietas ir klientas jo nebematys.", + "unassign-entity-view": "Atsieti subjektų rodinį", + "unassign-entity-views-title": "Ar tikrai norite atsieti { count, plural, =1 {1 subjektų rodinį} other {# subjektų rodinius} }?", + "unassign-entity-views-text": "Po patvirtinimo visi pasirinkti subjektų rodiniai bus atsieti ir klientas jų nebematys.", + "entity-view-type": "Subjektų rodinio tipas", + "entity-view-type-required": "Subjektų rodinio tipas būtinas.", + "select-entity-view-type": "Pasirinkite subjektų rodinio tipą", + "enter-entity-view-type": "Įveskite subjektų rodinio tipą", + "any-entity-view": "Bet kuris subjektų tipas", + "no-entity-view-types-matching": "Subjektų rodinių, atitinkančių '{{entitySubtype}}' nėra.", + "entity-view-type-list-empty": "Subjektų tipai nepasirinkti.", + "entity-view-types": "Subjektų rodinio tipai", + "created-time": "Sukūrimo laikas", + "name": "Pavadinimas", + "name-required": "Pavadinimas būtinas.", + "name-max-length": "Pavadinimas negali viršyti 256 simbolių", + "type-max-length": "Rodinio tipas negali viršyti 256 simbolių", + "description": "Aprašymas", + "events": "Įvykiai", + "details": "Informacija", + "copyId": "Kopijuoti subjekto rodinio Id", + "idCopiedMessage": "Subjektų rodinio Id nukopjuotas į iškarpinę", + "assignedToCustomer": "Priskirtas klientui", + "unable-entity-view-device-alias-title": "Subjektų rodinio pseudonimo ištrinti nepavyko", + "unable-entity-view-device-alias-text": "Įrenginio pseudonimas '{{entityViewAlias}}' Negali būti ištrintas, nes jis naudojamas valdikliuose:
{{widgetsList}}", + "select-entity-view": "Pasirinkti subjektų rodinį", + "start-date": "Pradžios data", + "start-ts": "Pradžios laikas", + "end-date": "Pabaigos data", + "end-ts": "Pabaigos laikas", + "date-limits": "Datos limitas", + "client-attributes": "Kliento atributai", + "shared-attributes": "Bendrinami atributai", + "server-attributes": "Serverio atributai", + "timeseries": "Telemetrija", + "client-attributes-placeholder": "Kliento atributai", + "shared-attributes-placeholder": "Bendrinami atributai", + "server-attributes-placeholder": "Serverio atributai", + "timeseries-placeholder": "Telemetrija", + "target-entity": "Tikslinis subjektas", + "attributes-propagation": "Atributų paplitimas", + "attributes-propagation-hint": "Subjektų rodinys automatikškai kopijuos nurodytus atributus iš tikslinio subjekto kiekvieną kartą, kai išsaugosite ar atnaujinsite šį rodinį. Norint išsaugoti sistemos spartą, tikslinio subjekto atributai neperkeliami į subjektų rodinį kiekvieną kartą kai jie pasikeičia. Tačiau jūs galite įjungti automatinį atributų reikšmių perdavimą, taisyklių grandinėje sukonfigūravę \"copy to view\" taisyklę ir \"Post attributes\" bei \"Attributes Updated\" žinutes nukreipę į naują taisyklę.", + "timeseries-data": "Temetrijos duomenys", + "timeseries-data-hint": "Sukonfigūruokite tikslinio objekto telemetrijos duomenų raktus, kurie bus pasiekiami objekto rodinyje. Šios telemetrijos duomenys yra tik skaitomi.", + "select-group-to-add": "Pasirinkite grupę į kurią pridėti pažymėtus subjektų rodinius", + "select-group-to-move": "Pasirinkite grupę į kurią perkelti pažymėtus subjektų rodinius", + "remove-entity-views-from-group": "Ar tikrai norite iš '{{entityGroup}}' grupės pašalinti { count, plural, =1 {1 subjektų rodinį} other {# subjektų rodinius} }?", + "group": "Subjektų rodinių grupė", + "list-of-groups": "{ count, plural, =1 {Viena subjektų rodinių grupė} other {# subjektų rodinių grupių sąrašas} }", + "group-name-starts-with": "Subjektų rodinių grupės pavadinimas prasideda '{{prefix}}'", + "search": "Subjektų rodinių paieška", + "selected-entity-views": "Pasirinkta { count, plural, =1 {1 subjektų rodinys} other {# subjektų rodiniai} }", + "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": { + "events": "Įvykiai", + "event-type": "Įvykio tipas", + "events-filter": "Įvykių filtras", + "clean-events": "Įšvalyti įvykius", + "type-error": "Klaida", + "type-lc-event": "Gyvavimo ciklo įvykis", + "type-stats": "Statistika", + "type-debug-converter": "Debug", + "type-debug-integration": "Debug", + "type-debug-rule-node": "Debug", + "type-debug-rule-chain": "Debug", + "no-events-prompt": "Įvykių nėra", + "error": "Klaida", + "alarm": "Įspėjimas", + "event-time": "Įvykio laikas", + "server": "Serveris", + "body": "Body", + "method": "Metodas", + "type": "Tipas", + "in": "Į", + "out": "Iš", + "metadata": "Metadata", + "message": "Message", + "entity": "Entity", + "message-id": "Message Id", + "copy-message-id": "Copy message Id", + "message-type": "Message Type", + "data-type": "Data Type", + "relation-type": "Relation Type", + "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?", + "started": "Started", + "updated": "Updated", + "stopped": "Stopped" + }, + "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", + "view": "View 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", + "sas": "Shared Access Signature", + "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", + "text": "TEXT", + "json": "JSON", + "binary": "BINARY", + "hex": "HEX" + }, + "feature": { + "advanced-features": "Advanced features" + }, + "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!", + "create-new": "Create new", + "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", + "duplicate-filter-action": "Duplicate 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": "Rodyti per visą ekraną", + "exit": "Išjungti rodymą per visą ekraną", + "toggle": "Rodyti per visą ekraną", + "fullscreen": "Rodyti per visą ekraną" + }, + "function": { + "function": "Funkcija" + }, + "gateway": { + "add-entry": "Add configuration", + "advanced": "Advanced", + "checking-device-activity": "Checking device activity", + "command": "Docker commands", + "command-copied-message": "Docker command has been copied to clipboard", + "configuration": "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", + "connectors-config": "Connectors configuration", + "connectors-table-enabled": "Enabled", + "connectors-table-name": "Name", + "connectors-table-type": "Type", + "connectors-table-status": "Status", + "connectors-table-actions": "Actions", + "connectors-table-key": "Key", + "connectors-table-class": "Class", + "rpc-command-send": "Send", + "rpc-command-result": "Result", + "rpc-command-edit-params": "Edit parameters", + "gateway-configuration": "General Configuration", + "docker-label": "In order to run ThingsBoard IoT gateway in docker with credentials for this device you can use the following commands.", + "create-new-gateway": "Create a new gateway", + "create-new-gateway-text": "Are you sure you want create a new gateway with name: '{{gatewayName}}'?", + "created-time": "Created time", + "configuration-delete-dialog-header": "Configurations will be deleted", + "configuration-delete-dialog-body": "Turning off Remote Configuration is possible only if there is physical access to the Gateway. All previous configurations will be deleted.

\nTo turn off configuration, enter gateway name below", + "configuration-delete-dialog-input": "Gateway name", + "configuration-delete-dialog-input-required": "Gateway name is mandatory", + "configuration-delete-dialog-confirm": "Turn Off", + "delete": "Delete configuration", + "download-tip": "Download configuration file", + "drop-file": "Drop file here or", + "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.", + "grpc": "GRPC", + "grpc-keep-alive-timeout": "Keep alive timeout (in ms)", + "grpc-keep-alive-timeout-required": "Keep alive timeout is required", + "grpc-keep-alive-timeout-min": "Keep alive timeout can not be less then 1", + "grpc-keep-alive-timeout-pattern": "Keep alive timeout is not valid", + "grpc-keep-alive": "Keep alive (in ms)", + "grpc-keep-alive-required": "Keep alive is required", + "grpc-keep-alive-min": "Keep alive can not be less then 1", + "grpc-keep-alive-pattern": "Keep alive is not valid", + "grpc-min-time-between-pings": "Min time between pings (in ms)", + "grpc-min-time-between-pings-required": "Min time between pings is required", + "grpc-min-time-between-pings-min": "Min time between pings can not be less then 1", + "grpc-min-time-between-pings-pattern": "Min time between pings is not valid", + "grpc-min-ping-interval-without-data": "Min ping interval without data (in ms)", + "grpc-min-ping-interval-without-data-required": "Min ping interval without data is required", + "grpc-min-ping-interval-without-data-min": "Min ping interval without data can not be less then 1", + "grpc-min-ping-interval-without-data-pattern": "Min ping interval without data is not valid", + "grpc-max-pings-without-data": "Max pings without data", + "grpc-max-pings-without-data-required": "Max pings without data is required", + "grpc-max-pings-without-data-min": "Max pings without data can not be less then 1", + "grpc-max-pings-without-data-pattern": "Max pings without data is not valid", + "inactivity-check-period-seconds": "Inactivity check period (in sec)", + "inactivity-check-period-seconds-required": "Inactivity check period is required", + "inactivity-check-period-seconds-min": "Inactivity check period can not be less then 1", + "inactivity-check-period-seconds-pattern": "Inactivity check period is not valid", + "inactivity-timeout-seconds": "Inactivity timeout (in sec)", + "inactivity-timeout-seconds-required": "Inactivity timeout is required", + "inactivity-timeout-seconds-min": "Inactivity timeout can not be less then 1", + "inactivity-timeout-seconds-pattern": "Inactivity timeout is not valid", + "json-parse": "Not valid JSON.", + "json-required": "Field cannot be empty.", + "logs": { + "logs": "Logs", + "days": "days", + "hours": "hours", + "minutes": "minutes", + "seconds": "seconds", + "date-format": "Date format", + "date-format-required": "Date format required", + "log-format": "Log format", + "log-type": "Log type", + "log-format-required": "Log format required", + "remote": "Remote logging", + "remote-logs": "Remote logs", + "local": "Local logging", + "level": "Log level", + "file-path": "File path", + "file-path-required": "File path required", + "saving-period": "Log saving period", + "saving-period-min": "Log saving period can not be less then 1", + "saving-period-required": "Log saving period required", + "backup-count": "Backup count", + "backup-count-min": "Backup count can not be less then 1", + "backup-count-required": "Backup count required" + }, + "min-pack-send-delay": "Min pack send delay (in ms)", + "min-pack-send-delay-required": "Min pack send delay is required", + "min-pack-send-delay-min": "Min pack send delay can not be less then 0", + "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.", + "permit-without-calls": "Keep alive permit without calls", + "remote": "Remote configuration", + "remote-logging-level": "Logging level", + "remove-entry": "Remove configuration", + "remote-shell": "Remote shell", + "remote-configuration": "Remote Configuration", + "other": "Other", + "save-tip": "Save configuration file", + "security-type": "Security type", + "security-types": { + "access-token": "Access Token", + "username-password": "Username and Password", + "tls": "TLS", + "tls-access-token": "TLS + Access Token", + "tls-private-key": "TLS + Private Key" + }, + "server-port": "Server port", + "statistics": { + "statistic": "Statistic", + "statistics": "Statistics", + "statistic-commands-empty": "No statistics available", + "commands": "Commands", + "send-period": "Statistic send period (in sec)", + "send-period-required": "Statistic send period is required", + "send-period-min": "Statistic send period can not be less then 60", + "send-period-pattern": "Statistic send period is not valid", + "check-connectors-configuration": "Check connectors configuration (in sec)", + "check-connectors-configuration-required": "Check connectors configuration is required", + "check-connectors-configuration-min": "Check connectors configuration can not be less then 1", + "check-connectors-configuration-pattern": "Check connectors configuration is not valid", + "add": "Add command", + "timeout": "Timeout", + "timeout-ms": "Timeout (in ms)", + "timeout-required": "Timeout is required", + "timeout-min": "Timeout can not be less then 1", + "timeout-pattern": "Timeout is not valid", + "attribute-name": "Attribute name", + "attribute-name-required": "Attribute name is required", + "command": "Command", + "command-required": "Command is required", + "remove": "Remove command" + }, + "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-read-record-count": "Read record count in storage", + "storage-read-record-count-min": "Minimum number of records is 1.", + "storage-read-record-count-pattern": "Number is not valid.", + "storage-read-record-count-required": "Read record count is required.", + "storage-max-read-record-count": "Max read record count in storage", + "storage-max-read-record-count-min": "Minimum number of records is 1.", + "storage-max-read-record-count-pattern": "Number is not valid.", + "storage-max-read-record-count-required": "Max Read record count is required.", + "storage-data-folder-path": "Data folder path", + "storage-data-folder-path-required": "Data folder path 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", + "sqlite": "SQLITE" + }, + "thingsboard": "ThingsBoard", + "general": "General", + "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", + "messages-ttl-check-in-hours": "Messages TTL check in hours", + "messages-ttl-check-in-hours-required": "Messages TTL check in hours is required.", + "messages-ttl-check-in-hours-min": "Min number is 1.", + "messages-ttl-check-in-hours-pattern": "Number is not valid.", + "messages-ttl-in-days": "Messages TTL in days", + "messages-ttl-in-days-required": "Messages TTL in days is required.", + "messages-ttl-in-days-min": "Min number is 1.", + "messages-ttl-in-days-pattern": "Number is not valid.", + "mqtt-qos": "QoS", + "mqtt-qos-required": "QoS is required", + "mqtt-qos-range": "QoS values range is from 0 to 1", + "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", + "hints": { + "remote-configuration": "Enables remote configuration and management of the gateway", + "remote-shell": "Enables remote control of the operating system with the gateway from the Remote Shell widget", + "host": "Hostname or IP address of ThingsBoard server", + "port": "Port of MQTT service on ThingsBoard server", + "token": "Access token for the gateway from ThingsBoard server", + "client-id": "MQTT client id for the gateway form ThingsBoard server", + "username": "MQTT username for the gateway form ThingsBoard server", + "password": "MQTT password for the gateway form ThingsBoard server", + "ca-cert": "Path to CA certificate file", + "date-form": "Date format in log message", + "data-folder": "Path to folder, that will contains data (Relative or Absolute)", + "log-format": "Log message format", + "remote-log": "Enables remote logging and logs reading from the gateway", + "backup-count": "If backup count is > 0, when a rollover is done, no more than backup count files are kept - the oldest ones are deleted", + "storage": "Provides configuration for saving incoming data before it is sent to the platform", + "max-file-count": "Maximum count of file that will be created", + "max-read-count": "Count of messages to get from storage and send to ThingsBoard", + "max-records": "Maximum count of records that will be stored in one file", + "read-record-count": "Count of messages to get from storage and send to ThingsBoard", + "max-records-count": "Maximum count of data in storage before send to ThingsBoard", + "ttl-check-hour": "How often will Gateway check data for obsolescence", + "ttl-messages-day": "Maximum days that storage will save data", + "commands": "Commands for collecting additional statistic", + "attribute": "Statistic telemetry key", + "timeout": "Timeout for command executing", + "command": "The result of the command execution, will be used as the value for telemetry", + "check-device-activity": "Enables monitor the activity of each connected device", + "inactivity-timeout": "Time after whose the gateway will disconnect device", + "inactivity-period": "Periodicity of device activity check", + "minimal-pack-delay": "Delay between sending packs of messages (Decreasing this setting results in increased CPU usage)", + "qos": "Quality of Service in MQTT messaging (0 - at most once, 1 - at least once)", + "server-port": "Network port on which GRPC server will listen for incoming connections.", + "grpc-keep-alive-timeout": "Maximum time the server should wait for a keepalive ping response before considering the connection dead.", + "grpc-keep-alive": "Duration between two successive keepalive ping messages when there is no active RPC call.", + "grpc-min-time-between-pings": "Minimum amount of time the server should wait between sending keepalive ping messages", + "grpc-max-pings-without-data": "Maximum number of keepalive ping messages that the server can send without receiving any data before it considers the connection dead.", + "grpc-min-ping-interval-without-data": "Minimum amount of time the server should wait between sending keepalive ping messages when there is no data being sent or received.", + "permit-without-calls": "Allow server to keep the GRPC connection alive even when there are no active RPC calls." + } + }, + "grid": { + "delete-item-title": "Ar tikrai norite pašalinti šį elementą?", + "delete-item-text": "Būkite dėmesingi, po patvirtinimo šio elemento ir visos su juo susijusios informacijos atkurti nebegalėsite.", + "delete-items-title": "Ar tikrai norite pašalinti { count, plural, =1 {1 elementą} other {# elementus} }?", + "delete-items-action-title": "Panaikinti { count, plural, =1 {1 elementą} other {# elementus} }", + "delete-items-text": "Būkite dėmesingi, po patvirtinimo visi pasirinkti elementai ir su jais susijusi informacija bus pašalinti ir jų atkurti nebegalėsite.", + "add-item-text": "Pridėti naują elementą", + "no-items-text": "Elementų nėra", + "item-details": "Informacija apie elementą", + "delete-item": "Panaikinti elementą", + "delete-items": "Panaikinti elmentus", + "scroll-to-top": "Slinkti į viršų" + }, + "help": { + "goto-help-page": "Pagalba", + "show-help": "Pagalba" + }, + "home": { + "home": "Pagrindinis", + "profile": "Profilis", + "logout": "Atsijungti", + "menu": "Meniu", + "avatar": "Paveiksliukas", + "open-user-menu": "Atverti vartoto meniu" + }, + "file-input": { + "browse-file": "Pasirinkti failą", + "browse-files": "Pasirinkti failus" + }, + "image-input": { + "drop-images-or": "Užvilkite paveikslėlį arba", + "drag-and-drop": "Užvilkite", + "or": "arba", + "browse": "Pasirinkite", + "no-images": "Paveikslėlis nepasirinktas", + "images": "paveikslėliai" + }, + "import": { + "no-file": "Nepasirinktas failas", + "drop-file": "Nuvilkite JSON failą arba spustelėkite, kad pasirinktumėte failą, kurį norite įkelti.", + "drop-csv-file": "Nuvilkite CSV failą arba spustelėkite, kad pasirinktumėte failą, kurį norite įkelti.", + "drop-json-file-or": "Nuvilkite JSON failą arba", + "drop-file-csv": "Nuvilkite CSV failą arba spustelėkite, kad pasirinktumėte failą, kurį norite įkelti.", + "drop-file-csv-or": "Nuvilkite CSV failą arba", + "column-value": "Reikšmė", + "column-title": "Pavadinimas", + "column-example": "Duomenų pavyzdys", + "column-key": "Atributo/telemetrijos raktas", + "credentials": "Įgaliojimai", + "csv-delimiter": "CSV skyriklis", + "csv-first-line-header": "Pirmoje eilutėje stulpelių pavadinimai", + "csv-update-data": "Atnaujinti artibutą/telemetriją", + "details": "Informacija", + "import-csv-number-columns-error": "Faile turi būti bent du stulpeliai", + "import-csv-invalid-format-error": "Neteisingas failo formatas. Elutė: '{{line}}'", + "column-type": { + "name": "Pavadinimas", + "type": "Tipas", + "label": "Etiketė", + "column-type": "Stulpelio tipas", + "client-attribute": "Kliento atributas", + "shared-attribute": "Bendrinamas atributas", + "server-attribute": "Serverio atributas", + "timeseries": "Telemetrija", + "entity-field": "Subjekto laukas", + "access-token": "Prieigos raktas", + "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" + }, + "snmp": { + "host": "SNMP host", + "port": "SNMP port", + "version": "SNMP version (v1, v2c or v3)", + "community-string": "SNMP community string" + }, + "isgateway": "Is Gateway", + "activity-time-from-gateway-device": "Activity time from gateway device", + "description": "Description", + "edge-license-key": "License Key", + "cloud-endpoint": "Cloud Endpoint", + "routing-key": "Edge key", + "secret": "Edge secret" + }, + "stepper-text": { + "select-file": "Pasirinkite failą", + "configuration": "Importo konfigūracija", + "column-type": "Pasirinkite stulpelių tipus", + "creat-entities": "Sukurti naujus subjektus" + }, + "message": { + "create-entities": "Sukurti {{count}} nauji subjektai.", + "update-entities": "Atnaujinti {{count}} subjektai.", + "error-entities": "Kuriant {{count}} subjektų įvyko klaidų." + } + }, + "integration": { + "integration": "Integration", + "integrations": "Integrations", + "integrations-center": "Integrations center", + "select-integration": "Select integration", + "no-integrations-matching": "No integrations matching '{{entity}}' were found.", + "integration-required": "Integration is required", + "delete": "Delete integration", + "management": "Integrations management", + "add-integration-text": "Add new integration", + "no-integrations-text": "No integrations found", + "data-convertor-name": "{{convertorType}} data converter for {{integrationName}}", + "integration-name": "{{integrationType}} integration", + "selected-integrations": "{ count, plural, =1 {1 integration} other {# integrations} } selected", + "delete-integration-title": "Are you sure you want to delete the integration '{{integrationName}}'?", + "delete-integration-text": "Be careful, after the confirmation the integration and all related data will become unrecoverable.", + "delete-integrations-title": "Are you sure you want to delete { count, plural, =1 {1 integration} other {# integrations} }?", + "delete-integrations-action-title": "Delete { count, plural, =1 {1 integration} other {# integrations} }", + "delete-integrations-text": "Be careful, after the confirmation all selected integrations will be removed and all related data will become unrecoverable.", + "events": "Events", + "enabled": "Enable integration", + "allow-create-devices-or-assets": "Allow create devices or assets", + "all-types": "All integration types", + "add": "Add Integration", + "search": "Search integrations", + "integration-details": "Integration details", + "details": "Details", + "copyId": "Copy integration Id", + "idCopiedMessage": "Integration Id has been copied to clipboard", + "debug-mode": "Debug mode", + "enable-security": "Enable security", + "enable-security-new": "Enable security for automatic token updates", + "headers-filter": "Headers filter", + "header": "Header", + "no-headers-filter": "No headers filter", + "downlink-url": "Downlink URL", + "downlink-url-required": "Downlink URL is required", + "create-loriot-output": "Create Loriot Application output", + "send-downlink": "Send downlink", + "allow-downlink": "Allow downlink", + "server": "Server", + "server-required": "Server is required", + "domain": "Domain", + "app-id": "Application ID", + "app-id-required": "Application ID is required", + "app-token": "Application Access Token", + "app-token-required": "Application Access Token is required", + "email": "Email", + "email-required": "Email is required", + "application-uri": "Application URI", + "as-id": "AS ID", + "as-id-required": "AS ID is required.", + "as-key": "AS Key", + "as-key-required": "AS Key is required.", + "client-id-new": "Client Id", + "client-id-new-required": "Client Id (login) is required (login).", + "client-secret": "Client Secret", + "client-secret-required": "Client Secret (password) is required (password).", + "max-time-diff-in-seconds": "Maximum time difference (seconds)", + "max-time-diff-in-seconds-required": "Maximum time difference is required.", + "created-time": "Created time", + "name": "Name", + "name-required": "Name is required.", + "name-max-length": "Name should be less than 256", + "description": "Description", + "base-url": "Base URL", + "base-url-required": "Base URL is required", + "security-key": "Security key", + "http-endpoint": "HTTP endpoint URL", + "replace-no-content-to-ok": "Replace response status from 'No-Content' to 'OK'", + "copy-http-endpoint-url": "Copy HTTP endpoint URL", + "http-endpoint-url-copied-message": "HTTP endpoint URL has been copied to clipboard", + "host": "Host", + "host-required": "Host is required.", + "host-private": "Host must be in the public network.", + "url-private": "URL must be in the public network.", + "host-type": "Host type", + "host-type-required": "Host type is required.", + "api-version": "Use API v3", + "custom-host": "Custom host", + "custom-host-required": "Custom host is required.", + "port": "Port", + "port-required": "Port is required.", + "port-range": "Port should be in a range from 1 to 65535.", + "connect-timeout": "Connection timeout (sec)", + "connect-timeout-required": "Connection timeout is required.", + "connect-timeout-range": "Connection timeout should be in a range from 1 to 200.", + "client-id": "Client ID", + "client-id-required": "Client ID is required.", + "client-id-hint": "Hint: Optional. Leave empty for auto-generated client ID. Be careful when specifying the Client ID. Majority of the MQTT brokers will not allow multiple connections with the same client ID. To connect to such brokers, your MQTT client ID must be unique.", + "max-bytes-in-message": "Max bytes in message", + "max-bytes-in-message-range": "Max bytes in message should be in a range from 1 to 256000000.", + "device-id": "Device ID", + "device-id-required": "Device ID is required.", + "device-id-range": "Device ID should be in a range from 1 to 65535 characters.", + "device-id-pattern": "Device ID should consist of numbers, upper and lower case letters. [MQTT-3.1.3-5]", + "group-id": "Group ID", + "group-id-required": "Group ID is required.", + "topics": "Topics", + "topics-required": "Topic is required.", + "routing-keys": "Routing keys", + "routing-keys-required": "Routing key is required.", + "queues": "Queues", + "queues-required": "Queue name is required.", + "durable": "Durable", + "exclusive": "Exclusive", + "autoDelete": "Auto delete", + "exchange-name": "Exchange name", + "exchange-name-required": "Exchange name is required.", + "downlink-topic": "Downlink topic", + "connection-timeout": "Connection timeout, ms", + "connection-timeout-min": "Invalid connection timeout value.", + "handshake-timeout": "Handshake timeout, ms", + "handshake-timeout-min": "Invalid handshake timeout value.", + "virtual-host": "Virtual host", + "rabbit-mq-poll-interval": "Poll interval, ms", + "rabbit-mq-poll-interval-min": "Invalid poll interval value.", + "application-server-url": "Application server URL", + "application-server-url-required": "Application server URL is required.", + "application-server-api-token": "Application server API Token", + "application-server-api-token-required": "Application server API Token is required.", + "use-api-four-plus": "Use API for ChirpStack 4+", + "bootstrap-servers": "Bootstrap servers", + "bootstrap-servers-required": "Bootstrap servers is required.", + "poll-interval": "Poll interval", + "poll-interval-required": "Poll interval is required.", + "poll-interval-min-value": "Poll interval value can't be less then 1", + "auto-create-topics": "Auto create topics", + "clean-session": "Clean session", + "enable-ssl": "Enable SSL", + "credentials": "Credentials", + "credentials-type": "Credentials type", + "credentials-type-required": "Credentials type is required.", + "username": "Username", + "username-required": "Username is required.", + "password": "Password", + "password-required": "Password is required.", + "azure-ca-cert": "CA certificate file", + "ca-cert": "CA certificate file", + "private-key": "Private key file", + "private-key-password": "Private key password", + "cert": "Certificate file", + "no-file": "No file selected.", + "drop-file": "Drop a file or click to select a file to upload.", + "drop-file-or": "Drag and drop a file or", + "check-connection": "Check connection", + "check-success": "Connection established.", + "topic-filters": "Topic filters", + "remove-topic-filter": "Remove topic filter", + "add-topic-filter": "Add topic filter", + "add-topic-filter-prompt": "Please add topic filter", + "topic": "Topic", + "mqtt-qos": "QoS", + "mqtt-qos-required": "QoS is required", + "mqtt-qos-range": "QoS values range is from 0 to 2", + "mqtt-qos-at-most-once": "At most once", + "mqtt-qos-at-least-once": "At least once", + "mqtt-qos-exactly-once": "Exactly once", + "downlink-topic-pattern": "Downlink topic pattern", + "downlink-topic-pattern-required": "Downlink topic pattern is required.", + "retained-message": "Retained", + "aws-access-key-id": "AWS access key Id", + "aws-secret-access-key": "AWS secret access key", + "aws-region": "Region", + "aws-iot-endpoint": "AWS IoT Endpoint", + "aws-iot-endpoint-required": "AWS IoT Endpoint is required.", + "aws-iot-credentials": "AWS IoT Credentials", + "aws-sqs-polling-period-in-seconds": "Polling period in seconds", + "aws-sqs-queue-url": "SQS Queue URL", + "aws-sqs-queue-url-required": "SQS Queue URL is required", + "aws-sqs-access-key-id-required": "Access Key Id is required", + "aws-sqs-secret-access-key-required": "Secret Access Key is required", + "application-credentials": "Application Credentials", + "api-key": "API Key", + "api-key-required": "API Key is required.", + "api-key-format": "Invalid API Key format.", + "auth-token": "Authentication Token", + "auth-token-required": "Authentication Token is required.", + "region": "Region", + "region-required": "Region is required.", + "application-id": "Application ID", + "application-id-required": "Application ID is required.", + "access-id": "Access Id", + "access-id-required": "Access Id is required.", + "access-key": "Access Key", + "access-key-required": "Access Key is required.", + "access-key-min-length": "Access Key is too short.", + "access-key-max-length": "Access Key is too long.", + "connection-parameters": "Connection parameters", + "service-bus-namespace-name": "Service Bus Namespace Name", + "service-bus-namespace-name-required": "Service Bus Namespace Name is required.", + "connection-string": "Connection string", + "consumer-group": "Consumer group", + "connection-string-required": "Connection string required!", + "event-hub-name": "Event Hub Name", + "event-hub-name-required": "Event Hub Name is required.", + "event-iot-hub-name-required": "Iot Hub Name is required for downlink", + "sas-key-name": "SAS Key Name", + "sas-key-name-required": "SAS Key Name is required.", + "sas-key": "SAS Key", + "sas-key-required": "SAS Key is required.", + "iot-hub-name": "IoT Hub Name (required for downlink)", + "hostname": "Hostname", + "hostname-required": "Hostname is required", + "integration-clazz": "Integration class", + "integration-clazz-required": "Integration class is required", + "integration-configuration": "Integration JSON configuration", + "metadata": "Metadata", + "type": "Integration type", + "select-integration-type": "Select integration type", + "type-required": "Integration type is required.", + "type-not-found": "Integration type not found", + "uplink-converter": "Uplink data converter", + "uplink-converter-required": "Uplink data converter is required.", + "downlink-converter": "Downlink data converter", + "type-http": "HTTP", + "type-http-description": "Transferring text or multimedia files protocol.", + "type-ocean-connect": "OceanConnect", + "type-ocean-connect-description": "Integration of devices with different capabilities with the IoT platform.", + "type-sigfox": "SigFox", + "type-sigfox-description": "Lightweight protocol to handle small data transmissions.", + "type-thingpark": "ThingPark", + "type-thingpark-description": "Message-oriented transport layer protocol.", + "type-loriot": "Loriot", + "type-loriot-description": "Distributed LoRaWAN infrastructure.", + "type-thingpark-enterprise": "ThingParkEnterprise", + "type-thingpark-enterprise-description": "Messaging platform for the LPWA network.", + "type-tmobile-iot-cdp": "iotcreators.com (T-Mobile – IoT CDP)", + "type-tmobile-iot-cdp-description": "Mobile IoT connectivity service.", + "type-mqtt": "MQTT", + "type-mqtt-description": "Machine to machine network protocol.", + "type-aws-iot": "AWS IoT", + "type-aws-iot-description": "Management, storage, and analytics service.", + "type-aws-sqs": "AWS SQS", + "type-aws-sqs-description": "Fully managed message queuing service.", + "type-aws-kinesis": "AWS Kinesis", + "type-aws-kinesis-description": "Service for real-time video and data streams.", + "type-ibm-watson-iot": "IBM Watson IoT", + "type-ibm-watson-iot-description": "Fully managed, cloud-hosted service.", + "type-ttn": "The Things Stack Community", + "type-ttn-description": "Open and decentralized LoRaWAN network.", + "type-tti": "The Things Stack Industries", + "type-tti-description": "Software to operate private LoRaWAN networks at scale.", + "type-chirpstack": "ChirpStack", + "type-chirpstack-description": "LoRaWAN network configuration server.", + "type-particle": "Particle", + "type-particle-description": "IoT enablement platform-as-a-service.", + "type-azure-event-hub": "Azure Event Hub", + "type-azure-event-hub-description": "Big data streaming platform and event ingestion service.", + "type-azure-iot-hub": "Azure IoT Hub", + "type-azure-iot-hub-description": "Cloud-hosted messaging service.", + "type-opc-ua": "OPC-UA", + "type-opc-ua-description": "Cross-platform for data exchange from sensors to cloud apps.", + "type-custom": "Custom", + "type-coap": "CoAP", + "type-coap-description": "Machine to machine network protocol for constrained devices.", + "type-udp": "UDP", + "type-udp-description": "Message-oriented transport layer protocol.", + "type-tcp": "TCP", + "type-tcp-description": "Connection-oriented protocol.", + "type-kafka": "Kafka", + "type-kafka-description": "Streaming analytics, data integration streaming platform.", + "type-rabbitmq": "RabbitMQ", + "type-rabbitmq-description": "Message broker that supports multiple messaging protocols.", + "type-pubsub": "Pub/Sub", + "type-pubsub-description": "Service for asynchronous messaging between apps.", + "type-apache-pulsar": "Apache Pulsar", + "type-apache-pulsar-description": "Distributed pub-sub messaging and streaming platform.", + "type-tuya": "Tuya", + "type-tuya-description": "Cloud-hosted messaging. device platform.", + "type-azure-service-bus": "Azure Service Bus", + "type-azure-service-bus-description": "Fully managed enterprise message broker with message queues and publish-subscribe topics.", + "opc-ua-application-name": "Application name", + "opc-ua-application-uri": "Application uri", + "opc-ua-scan-period-in-seconds": "Scan period in seconds", + "opc-ua-scan-period-in-seconds-required": "Scan period is required", + "opc-ua-timeout": "Timeout in milliseconds", + "opc-ua-timeout-required": "Timeout is required", + "opc-ua-security": "Security", + "opc-ua-security-required": "Security is required", + "opc-ua-identity": "Identity", + "opc-ua-identity-required": "Identity is required", + "opc-ua-keystore": "Keystore", + "add-opc-ua-keystore-prompt": "Please add keystore file", + "opc-ua-keystore-required": "Keystore is required", + "opc-ua-type": "Type", + "opc-ua-keystore-type": "Type", + "opc-ua-keystore-type-required": "Type is required", + "opc-ua-keystore-location": "Location *", + "opc-ua-keystore-password": "Password", + "opc-ua-keystore-password-required": "Password is required", + "opc-ua-keystore-alias": "Alias", + "opc-ua-keystore-alias-required": "Alias is required", + "opc-ua-keystore-key-password": "Key password", + "opc-ua-keystore-key-password-required": "Key password is required", + "opc-ua-mapping": "Mapping", + "add-opc-ua-mapping-prompt": "Please add mapping", + "opc-ua-mapping-type": "Mapping type", + "opc-ua-mapping-type-required": "Mapping type is required", + "opc-ua-device-node-pattern": "Device Node Pattern", + "opc-ua-device-node-pattern-required": "Device Node Pattern is required", + "opc-ua-namespace": "Namespace", + "opc-ua-add-map": "Add mapping element", + "kinesis-stream-name": "Stream name", + "kinesis-stream-name-required": "Stream name is required", + "kinesis-region": "Region", + "kinesis-region-required": "Region is required", + "kinesis-access-key-id": "Access Key Id", + "kinesis-access-key-id-required": "Access Key Id is required", + "kinesis-secret-access-key": "Secret Access Key", + "kinesis-secret-access-key-required": "Secret Access Key is required", + "kinesis-use-consumers-with-enhanced-fan-out": "Use Consumers with Enhanced Fan-Out", + "kinesis-use-credentials-from-instance-metadata": "Use credentials from the Amazon EC2 Instance Metadata Service", + "kinesis-application-name": "Application name (by default equals Stream name)", + "kinesis-initial-position-in-stream": "Initial position in stream", + "kinesis-initial-position-in-stream-required": "Initial position in stream required", + "kinesis-max-records": "Max records", + "kinesis-max-records-required": "Max records is required", + "kinesis-max-records-length-range": "Max records length should be in a range from 1 to 10000", + "kinesis-request-timeout": "Request timeout in seconds", + "kinesis-request-timeout-required": "Request timeout is required", + "other-properties": "Other properties", + "subscription-tags": "Subscription tags", + "remove-subscription-tag": "Remove subscription tag", + "add-subscription-tag": "Add subscription tag", + "add-subscription-tag-prompt": "Please add subscription tag", + "key": "Key", + "path": "Path", + "required": "Required", + "integration-key": "Integration key", + "copy-integration-key": "Copy integration key", + "integration-key-copied-message": "Integration key has been copied to clipboard", + "integration-secret": "Integration secret", + "copy-integration-secret": "Copy integration secret", + "integration-secret-copied-message": "Integration secret has been copied to clipboard", + "execute-remotely": "Execute remotely", + "remote": "Remote", + "handler-configuration": "Handler Configuration", + "handler-configuration-type": "Handler Type", + "so-broadcast": "Enable broadcast - integration will accepts broadcast address packets", + "so-keepalive-option": "Enable sending of keep-alive messages on connection-oriented sockets", + "so-reuse-addr": "Bind the process to a port", + "tcp-no-delay": "Forces a socket to send the data without buffering (disable Nagle's buffering algorithm)", + "fail-fast": "Thrown exception soon as the decoder notices the length of the frame will exceed max size", + "strip-delimiter": "Strip Delimiter", + "length-field-offset": "Length Field Offset", + "length-field-offset-required": "Length Field Offset is required.", + "length-field-offset-range": "Length Field Offset should be in a range from 0 to 8.", + "length-field-length": "Length Field Length", + "length-field-length-required": "Length Field Length is required.", + "length-field-length-range": "Length Field Length should be in a range from 0 to 8.", + "length-adjustment": "Length Adjustment (the compensation value to add to the value of the length field)", + "length-adjustment-required": "Length Adjustment is required.", + "length-adjustment-range": "Length Adjustment should be in a range from 0 to 8.", + "byte-order": "Byte Order of the length field", + "initial-bytes-to-strip": "Number of first bytes to strip out from the decoded frame", + "initial-bytes-to-strip-required": "Number of first bytes to strip out from the decoded frame is required.", + "initial-bytes-to-strip-range": "Number of first bytes to strip out from the decoded frame should be in a range from 0 to 8.", + "so-backlog-option": "Max number of pending connects on the socket", + "so-backlog-option-required": "The max number of pending connects on the socket is required.", + "so-backlog-option-range": "The max number of pending connects on the socket should be in a range from 1 to 65535.", + "so-rcv-buf": "Size of the buffer for inbound socket (in KB)", + "so-rcv-buf-required": "Size of the buffer for inbound socket (in KB) is required.", + "so-rcv-buf-range": "Size of the buffer for inbound socket (in KB) should be in a range from 1 to 65535.", + "so-snd-buf": "Size of the buffer for outbound socket (in KB)", + "so-snd-buf-required": "Size of the buffer for outbound socket (in KB) is required.", + "so-snd-buf-range": "Size of the buffer for outbound socket (in KB) should be in a range from 1 to 65535.", + "charset-name": "Charset Name", + "charset-name-required": "Charset Name is required.", + "message-separator": "Message Separator", + "message-separator-required": "Message Separator is required.", + "character-sequence": "Character Sequence", + "character-sequence-required": "Character Sequence is required.", + "max-frame-length": "Max Frame Length (in bytes)", + "max-frame-length-required": "Max Frame Length (in bytes) is required.", + "max-frame-length-range": "Max Frame Length (in bytes) should be in a range from 1 to 65535.", + "handler-type": "Handler Type", + "message-size": "Message Size", + "message-size-required": "Message Size is required.", + "service-url": "Service URL", + "service-url-required": "Service URL is required.", + "subscription-name": "Subscription name", + "subscription-name-required": "Subscription name is required.", + "max-num-messages": "Max number of messages", + "max-num-messages-required": "Max number of messages is required.", + "max-num-bytes": "Max number of bytes", + "max-num-bytes-required": "Max number of bytes required.", + "timeout-in-ms": "Timeout in milliseconds", + "timeout-in-ms-required": "Timeout in milliseconds is required.", + "user-id": "User ID", + "user-id-required": "User ID is required.", + "token": "Token", + "token-required": "Token is required.", + "project-id": "Project ID", + "project-id-required": "Project ID is required.", + "subscription-id": "Subscription ID", + "subscription-id-required": "Subscription ID is required.", + "service-account-key": "Service Account key file", + "service-account-key-required": "Service Account key file is required.", + "tcp": { + "system-line-separator": "System Line Separator", + "nul-delimiter": "Nul Delimiter", + "byte-order-little-endian": "Little Endian", + "byte-order-big-endian": "Big Endian" + }, + "cache-size": "Cache Size", + "cache-time-to-live": "Cache time to live in minutes", + "min-cache-size": "Cache size can't be lower 0", + "min-cache-time-to-live": "Cache time to live can't be lower 0", + "max-cache-time-to-live": "Invalid time of cache live, select between 0 and 525600", + "coap-security-mode": "Security mode", + "coap-security-mode-required": "CoAP security mode is required", + "coap-security-mode-no-secure": "NO SECURE", + "coap-security-mode-dtls": "DTLS", + "coap-security-mode-mixed": "MIXED", + "coap-endpoint": "CoAP endpoint URL", + "coap-endpoint-url-copied-message": "CoAP endpoint URL has been copied to clipboard", + "copy-coap-endpoint-url": "Copy CoAP endpoint URL", + "copy-coap-dtls-endpoint-url": "Copy CoAP DTLS endpoint URL", + "coap-dtls-base-url": "DTLS Base URL", + "coap-dtls-base-url-required": "DTLS Base URL is required", + "coap-dtls-endpoint": "CoAP DTLS endpoint URL", + "coap-dtls-endpoint-url-copied-message": "CoAP DTLS endpoint URL has been copied to clipboard", + "unassign-integration-title": "Are you sure you want to unassign the integration '{{integrationName}}'?", + "unassign-integration-from-edge-text": "After the confirmation the integration will be unassigned and won't be accessible by the edge.", + "unassign-integrations-from-edge-title": "Are you sure you want to unassign { count, plural, =1 {1 integration} other {# integrations} }?", + "unassign-integrations-from-edge-text": "After the confirmation all selected integrations will be unassigned and won't be accessible by the edge.", + "unassign-integrations": "Unassign integrations", + "edge-placeholder-hint": "You can use placeholder ${{ATTRIBUTE_KEY}} to substitute integration field with attribute value from specific Edge entity. For example, 'Edge A' has attribute 'baseUrl' that equals to 'http://localhost:9999'. You can set ${{baseUrl}} as one of the integration fields, and it is going to be replaced to 'http://localhost:9999' during the assignment of this integration to 'Edge A'. Additionally, if 'Edge A' attribute 'baseUrl' is going to be updated - integration with new updated value is going to be automatically provisioned to 'Edge A'.", + "status": { + "status": "Status", + "active": "Active", + "disabled": "Disabled", + "failed": "Failed", + "pending": "Pending" + }, + "daily-activity": "Daily activity", + "enable-debug-mode": "Enable debug mode", + "disable-debug-mode": "Disable debug mode", + "advanced-settings": "Advanced settings", + "basic-settings": "Basic settings", + "existing-converter": "Select existing", + "new-converter": "Create new", + "connection": "Connection", + "connected": "Connected", + "not-connected": "Not connected", + "environment": "Environment", + "environment-required": "Environment is required.", + "region-cn": "China", + "region-us": "United States", + "region-eu": "Europe", + "region-in": "India", + "topic-name": "Topic name", + "topic-name-required": "Topic name is required", + "sub-name": "Subscription name", + "sub-name-required": "Subscription name is required", + "downlink-connection-string": "Downlink connection string", + "downlink-connection-string-required": "Downlink connection string is required", + "downlink-topic-name": "Downlink topic name", + "downlink-topic-name-required": "Downlink topic name is required" + }, + "item": { + "selected": "Pasirinkta" + }, + "js-func": { + "no-return-error": "Funkcija privalo grąžinti reikšmę!", + "return-type-mismatch": "Funkcijos grąžinama reikšmė privalo būti '{{type}}' tipo!", + "tidy": "Tidy", + "mini": "Mini" + }, + "key-val": { + "key": "Raktas", + "value": "Reikšmė", + "remove-entry": "Panaiknti įrašą", + "add-entry": "Pridėti įrašą", + "no-data": "Įrašų nėra" + }, + "layout": { + "layout": "Maketas", + "layouts": "Maketai", + "manage": "Valdyti maketus", + "settings": "Maketų nustatymai", + "color": "Spalva", + "main": "Pagrindinis", + "right": "Į dešinę", + "left": "Į kairę", + "select": "Pasirinkti maketą", + "percentage-width": "Procentinis plotis (%)", + "fixed-width": "Fiksuotas plotis (px)", + "left-width": "Kairysis stulpelis (%)", + "right-width": "Dešinysis stulpelis (%)", + "pick-fixed-side": "Fiksuota pusė: ", + "layout-fixed-width": "Fiksuotas plotis (px)", + "value-min-error": "Reikšmė turi būti dedesnė nei {{min}}{{unit}}", + "value-max-error": "Reikšmė turi būti mažesnė nei {{max}}{{unit}}", + "layout-fixed-width-required": "Fiksuotas plotis būtinas", + "right-width-percentage-required": "Dešinės pusės procentas būtinas", + "left-width-percentage-required": "kairės pusės procentas būtinas", + "divider": "Daliklis", + "right-side": "Dešinės pusės maketas", + "left-side": "Kairės pusės maketas" + }, + "legend": { + "direction": "Elementų išdėstymo kryptis", + "position": "Legendos pozicija", + "show-values": "Rodyti reikšmes", + "min-option": "Min", + "max-option": "Max", + "average-option": "Vidurkis", + "total-option": "Viso", + "latest-option": "Naujausi", + "sort-legend": "Legendoje rodyti duomenų raktus", + "show-max": "Rodyti didžiausią reišmę", + "show-min": "Rodyti mažiausią reikšmę", + "show-avg": "Rodyti vidutinę reikšmę", + "show-total": "Rodyti suminę rekšmę", + "show-latest": "Rodyti naujausią reikšmę", + "settings": "Legendos nustatymai", + "min": "Min", + "max": "Max", + "avg": "Vidurkis", + "total": "Viso", + "latest": "Naujausia", + "comparison-time-ago": { + "previousInterval": "(previous interval)", + "customInterval": "(custom interval)", + "days": "(day ago)", + "weeks": "(week ago)", + "months": "(month ago)", + "years": "(year ago)" + } + }, + "login": { + "login": "Prisijungti", + "request-password-reset": "Prašyti atstatyti slaptažodį", + "reset-password": "Atstatyti slaptažodį", + "create-password": "Nustatyti slaptažodį", + "two-factor-authentication": "Dviejų veiksnių autentifikavimas", + "passwords-mismatch-error": "Įvesti slaptažodžiai turi sutapti!", + "password-again": "Slaptažodį įveskite dar kartą", + "sign-in": "Prisijunkite", + "username": "Vartoto vardas (el. paštas)", + "remember-me": "Prisiminti mane", + "forgot-password": "Pamiršote slaptažodį?", + "password-reset": "Slaptažodis atstatytas", + "expired-password-reset-message": "Jūsų slaptažodio galiojimas baigėsi! Nustatykite naują slaptažodį.", + "new-password": "Naujas slaptažodis", + "new-password-again": "Pakartokite naują slaptažodį", + "password-link-sent-message": "Atstatymo nuoroda išsiųsta", + "email": "El. paštas", + "no-account": "Neturi paskyros?", + "create-account": "Sukurti paskyrą", + "login-with": "Prisijungti kaip {{name}}", + "or": "arba", + "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" + }, + "signup": { + "firstname": "Vardas", + "lastname": "Pavardė", + "email": "El. paštas", + "signup": "Registracija", + "create-password": "nustatyti slaptažodį", + "repeat-password": "Pakartokite slaptažodį", + "have-account": "Jau turite paskyrą?", + "signin": "Prisijungti", + "no-captcha-message": "Turite patvirtinti, jog nesate robotas", + "password-length-message": "Jūsų slaptažodis turi būti ne nei 6 simboliai", + "email-verification": "El. pašto adreso patvirtinimas", + "email-verification-message": "El. laiškas su patvirtinimo informacija išsiųstas nurodytu el. pašto adresu.
Sekite laiške pateiktas intrukcijas ir atlikite prisijungimui reikalingus veiksmus.
Pastaba: Jei nematote el. laiško, patikrinkite 'Šlamšto' katalogą arba bandykite dar kartą siųsti laišką paspausdami 'Siųsti dar kartą' mygtuką", + "account-activation-title": "Paskyros aktyvavimas", + "account-activated": "Paskyra aktyvuota!", + "account-activated-text": "Sveikiname!
Jūsų paskyra aktyvuota.", + "resend": "Siųsti dar kartą", + "inactive-user-exists-title": "Neaktyvuotas vartotojas sistemoje jau yra", + "inactive-user-exists-text": "Vartotojas su tokiu el. pašto adresu jau užregistruotas, tačiau šis adresas nepatvirtintas.
Paspauskite 'Siųsti dar kartą' mygtuką jei norite išsiųsti patvirtinimo laišką.", + "activating-account": "Paskyra aktyvuojama...", + "activating-account-text": "Jūsų paskyra aktyvuojama. Palaukite...", + "accept-privacy-policy": "Sutikti su privatumo politika", + "accept": "Sutikti", + "privacy-policy": "Privatumo politika", + "accept-privacy-policy-message": "You must accept our Privacy Policy", + "recaptcha-title": "reCAPTCHA", + "terms-of-use": "Terms of Use", + "accept-terms-of-use-message": "You must accept our Terms Of Use" + }, + "notification": { + "action-button": "Action button", + "action-type": "Action type", + "active": "Active", + "add-notification-recipients-group": "Add notification recipients group", + "add-notification-template": "Add notification template", + "add-recipient": "Add recipient", + "add-recipients": "Add recipients", + "add-rule": "Add rule", + "add-stage": "Add stage", + "add-template": "Add template", + "after": "After", + "alarm-assignment-trigger-settings": "Alarm assignment trigger settings", + "alarm-comment-trigger-settings": "Alarm comment trigger settings", + "alarm-trigger-settings": "Alarm trigger settings", + "all": "All", + "api-feature-hint": "If the field is empty, the trigger will be applied to all api features", + "api-usage-trigger-settings": "API usage trigger settings", + "new-platform-version-trigger-settings": "New platform version trigger settings", + "rate-limits-trigger-settings": "Exceeded rate limits trigger settings", + "at-least-one-should-be-selected": "At least one should be selected", + "basic-settings": "Basic settings", + "button-text": "Button text", + "button-text-required": "Button text is required", + "button-text-max-length": "Button text should be less than or equal to {{ length }} characters", + "compose": "Compose", + "conversation": "Conversation", + "conversation-required": "Conversation is required", + "copy-notification-template": "Copy notification template", + "copy-rule": "Copy rule", + "copy-template": "Copy template", + "create-new": "Create new", + "created": "Created", + "delete-notification-text": "Be careful, after the confirmation the notification will become unrecoverable.", + "delete-notification-title": "Are you sure you want to delete the notification?", + "delete-notifications-text": "Be careful, after the confirmation notifications will become unrecoverable.", + "delete-notifications-title": "Are you sure you want to delete { count, plural, =1 {1 notification} other {# notifications} }?", + "delete-recipient-text": "Be careful, after the confirmation the recipient will become unrecoverable.", + "delete-recipient-title": "Are you sure you want to delete recipient '{{recipientName}}'?", + "delete-recipients-text": "Be careful, after the confirmation recipients will become unrecoverable.", + "delete-recipients-title": "Are you sure you want to delete { count, plural, =1 {1 recipient} other {# recipients} }?", + "delete-request-text": "Be careful, after the confirmation request will become unrecoverable.", + "delete-request-title": "Are you sure you want to delete request?", + "delete-requests-text": "Be careful, after the confirmation requests will become unrecoverable.", + "delete-requests-title": "Are you sure you want to delete { count, plural, =1 {1 request} other {# requests} }?", + "delete-rule-text": "Be careful, after the confirmation rule will become unrecoverable.", + "delete-rule-title": "Are you sure you want to delete rule '{{ruleName}}'?", + "delete-rules-text": "Be careful, after the confirmation rules will become unrecoverable.", + "delete-rules-title": "Are you sure you want to delete { count, plural, =1 {1 rule} other {# rules} }?", + "delete-template-text": "Be careful, after the confirmation template will become unrecoverable.", + "delete-template-title": "Are you sure you want to delete template '{{templateName}}'?", + "delete-templates-text": "Be careful, after the confirmation templates will become unrecoverable.", + "delete-templates-title": "Are you sure you want to delete { count, plural, =1 {1 template} other {# templates} }?", + "deleted": "Deleted", + "delivery-method": { + "delivery-method": "Delivery method", + "email": "Email", + "email-preview": "Email notification preview", + "slack": "Slack", + "slack-preview": "Slack notification preview", + "microsoft-teams": "Microsoft Teams", + "microsoft-teams-preview": "Microsoft Teams notification preview", + "sms": "SMS", + "sms-preview": "SMS notification preview", + "web": "Web", + "web-preview": "Web notification preview" + }, + "delivery-method-not-configure-click": "Delivery method is not configured. Click to setup.", + "delivery-method-not-configure-contact": "Delivery method is not configured. Contact your system administrator.", + "delivery-methods": "Delivery methods", + "description": "Description", + "device-activity-trigger-settings": "Device active trigger settings", + "device-list-rule-hint": "If the field is empty, the trigger will be applied to all devices", + "device-profiles-list-rule-hint": "If the field is empty, the trigger will be applied to all device profiles", + "disabled": "Disabled", + "edit-notification-recipients-group": "Edit notification recipients group", + "edit-notification-template": "Edit notification template", + "edit-rule": "Edit rule", + "edit-template": "Edit template", + "enabled": "Enabled", + "entities-limit-trigger-settings": "Entities limit trigger settings", + "entity-action-trigger-settings": "Entity action trigger settings", + "entity-type": "Entity type", + "escalation-chain": "Escalation chain", + "failed-send": "Sending failures", + "fails": "{ count, plural, =1 {1 fail} other {# fails} }", + "filter": "Filter", + "first-recipient": "First recipient", + "inactive": "Inactive", + "inbox": "Inbox", + "notification-inbox": "Notifications / Inbox", + "input-field-support-templatization": "Input field support templatization.", + "input-fields-support-templatization": "Input fields support templatization.", + "integration-action-trigger-settings": "Integration events trigger settings", + "integration-list-rule-hint": "If the field is empty, the trigger will be applied to all integrations", + "link": "Link", + "link-required": "Link is required", + "link-type": { + "dashboard": "Open dashboard", + "link": "Open URL link" + }, + "loading-notifications": "Loading notifications...", + "management": "Notification management", + "mark-all-as-read": "Mark all as read", + "mark-as-read": "Mark as read", + "message": "Message", + "message-required": "Message is required", + "message-max-length": "Message should be less than or equal to {{ length }} characters", + "name": "Name", + "name-required": "Name is required", + "new-notification": "New notification", + "no-inbox-notification": "No notification found", + "no-notification-request": "No notification request", + "no-notification-templates": "No notification templates found", + "no-notifications-yet": "No notifications yet", + "no-recipients-notification": "No recipients notification", + "no-rule": "No rule configured", + "no-rules-notification": "No rules notification", + "no-severity-found": "No severity found", + "no-severity-matching": "'{{severity}}' not found.", + "no-template-matching": "No resource matching '{{template}}' were found.", + "not-found-slack-recipient": "Slack recipient not found", + "notification": "Notification", + "notification-center": "Notification center", + "notify": "notify", + "notify-again": "Notify again", + "notify-alarm-action": { + "acknowledged": "Alarm acknowledged", + "assigned": "Alarm assigned", + "cleared": "Alarm cleared", + "created": "Alarm created", + "severity-changed": "Alarm severity changed", + "unassigned": "Alarm unassigned" + }, + "notify-on": "Notify on", + "notify-on-comment-update": "Notify on comment update", + "notify-on-required": "Notify on is required", + "notify-on-unassign": "Notify on unassign", + "notify-only-integrations-errors": "Only notify on error", + "notify-only-user-comments": "Notify only user comments", + "only-rule-chain-lifecycle-failures": "Only rule chain lifecycle failures", + "only-rule-node-lifecycle-failures": "Only rule node lifecycle failures", + "platform-users": "Platform users", + "rate-limits": "Rate limits", + "rate-limits-hint": "If the field is empty, the trigger will be applied to all rate limits", + "recipient": "Recipient", + "recipient-group": "Recipient group", + "recipient-type": { + "affected-tenant-administrators": "Affected tenant administrators", + "affected-user": "Affected user", + "affected-user-hint": "Affected user hint", + "all-users": "All users", + "customer-users": "Customer users", + "system-administrators": "System administrators", + "tenant-administrators": "Tenant administrators", + "user-filters": "User filter", + "user-group-list": "User group list", + "user-list": "User list", + "user-role": "User role", + "users-entity-owner": "Users of the entity owner", + "users-entity-owner-hint": "Users of the entity owner hint" + }, + "recipients": "Recipients", + "notification-recipients": "Notifications / Recipients", + "recipients-count": "{ count, plural, =1 {1 recipient} other {# recipients} }", + "recipients-required": "Recipients are required", + "refresh-allow-delivery-method": "Refresh allow delivery method", + "request-search": "Request search", + "request-status": { + "processing": "Processing", + "scheduled": "Scheduled", + "sent": "Sent" + }, + "review": "Review", + "rule": "Rule", + "rule-chain-list-rule-hint": "If the field is empty, the trigger will be applied to all rule chains", + "rule-engine-events-trigger-settings": "Rule engine events trigger settings", + "rule-engine-filter": "Rule engine filter", + "rule-name": "Rule name", + "rule-name-required": "Name is required", + "rule-disable": "Disable notification rule", + "rule-enable": "Enable notification rule", + "rule-node-filter": "Rule node filter", + "rules": "Rules", + "notification-rules": "Notifications / Rules", + "scheduler-later": "Schedule for later", + "search-notification": "Search notifications", + "search-recipients": "Search recipients", + "search-rules": "Search rules", + "search-templates": "Search templates", + "see-documentation": "See documentation", + "selected-notifications": "{ count, plural, =1 {1 notification} other {# notifications} } selected", + "selected-recipients": "{ count, plural, =1 {1 recipient} other {# recipients} } selected", + "selected-requests": "{ count, plural, =1 {1 request} other {# requests} } selected", + "selected-rules": "{ count, plural, =1 {1 rule} other {# rules} } selected", + "selected-template": "{ count, plural, =1 {1 template} other {# templates} } selected", + "send-notification": "Send notification", + "sent": "Sent", + "notification-sent": "Notifications / Sent", + "set-entity-from-notification": "Set entity from notification to dashboard state", + "slack-chanel-type": "Slack channel type", + "slack-chanel-types": { + "direct": "Direct message", + "private-channel": "Private channel", + "public-channel": "Public channel" + }, + "start-from-scratch": "Start from scratch", + "status": "Status", + "stop-escalation-alarm-status-become": "Stop the escalation on the alarm status become:", + "subject": "Subject", + "subject-required": "Subject is required", + "template": "Template", + "template-name": "Template name", + "template-required": "Template is required", + "template-type": { + "alarm": "Alarm", + "alarm-assignment": "Alarm assignment", + "alarm-comment": "Alarm comment", + "api-usage-limit": "API usage limit", + "device-activity": "Device activity", + "entities-limit": "Entities limit", + "entity-action": "Entity action", + "general": "General", + "integration-lifecycle-event": "Integration lifecycle event", + "rule-engine-lifecycle-event": "Rule engine lifecycle event", + "rule-node": "Rule node", + "new-platform-version": "New platform version", + "rate-limits": "Exceeded rate limits" + }, + "templates": "Templates", + "notification-templates": "Notifications / Templates", + "tenant-profiles-list-rule-hint": "If the field is empty, the trigger will be applied to all tenant profiles", + "tenants-list-rule-hint": "If the field is empty, the trigger will be applied to all tenants", + "threshold": "Threshold", + "theme-color": "Theme color", + "time": "Time", + "track-rule-node-events": "Track rule node events", + "trigger": { + "alarm": "Alarm", + "alarm-assignment": "Alarm assignment", + "alarm-comment": "Alarm comment", + "api-usage-limit": "API usage limit", + "device-activity": "Device activity", + "entities-limit": "Entities limit", + "entity-action": "Entity action", + "integration-lifecycle-event": "Integration lifecycle event", + "rule-engine-lifecycle-event": "Rule engine lifecycle event", + "new-platform-version": "New platform version", + "rate-limits": "Exceeded rate limits", + "trigger": "Trigger", + "trigger-required": "Trigger is required" + }, + "type": "Type", + "unread": "Unread", + "updated": "Updated", + "use-template": "Use template", + "view-all": "View all", + "view-notification-recipients-group": "View notification recipients group", + "view-notification-template": "View notification template", + "view-rule": "View rule", + "warning": "Warning", + "webhook-url": "Webhook URL", + "webhook-url-required": "Webhook URL is required", + "channel-name": "Channel name", + "channel-name-required": "Channel name is required", + "settings": { + "notification-settings": "Notification settings", + "reset-all": "Reset all settings", + "reset-all-title": "Are you sure you want to reset form?", + "reset-all-text": "After the confirmation, the settings form will reset to the default value and save.", + "type": "Type", + "enable-all": "Enable all", + "disable-all": "Disable all", + "delivery-not-configured": "Delivery method is not configured" + } + }, + "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", + "cant-applied-group-all": "Can't be applied for group All", + "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": "Viršus", + "bottom": "Apačia", + "left": "Kairioji pusė", + "right": "Dešinioji pusė" + }, + "profile": { + "profile": "Profilis", + "last-login-time": "Paskutinio prisijungimo laikas", + "change-password": "Pakeisti slaptažodį", + "current-password": "Dabartinis slaptažodis", + "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", + "general-settings": "General security settings", + "access-token": "Access token", + "access-token-required": "Access token is required", + "clientId": "Client ID", + "clientId-required": "Client ID is required", + "username": "Username", + "username-required": "Username is required", + "ca-cert": "CA certificate", + "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": "Ryšiai", + "direction": "Direction", + "clear-relation-type": "Clear relation type", + "search-direction": { + "FROM": "Iš", + "TO": "Į" + }, + "direction-type": { + "FROM": "iš", + "TO": "į" + }, + "from-relations": "Išeinantys ryšiai", + "to-relations": "įeinantys ryšiai", + "selected-relations": "Pasirinkta { count, plural, =1 {1 ryšys} other {# ryšiai} } selected", + "type": "Tipas", + "to-entity-type": "Į subjekto tipą", + "to-entity-name": "Į subjektą", + "from-entity-type": "iš subjektų tipo", + "from-entity-name": "Iš subjekto", + "to-entity": "į subjektą", + "from-entity": "Iš subjekto", + "delete": "panaikinti ryšį", + "relation-type": "Ryšio tipas", + "relation-type-required": "Ryšio tipas būtinas.", + "relation-type-max-length": "Ryšio tipas negali viršyti 256 simbolių", + "any-relation-type": "Bet kuris tipas", + "add": "pridėti ryšį", + "edit": "Redaguoti ryšį", + "view": "peržiūrėti ryšį", + "delete-to-relation-title": "Ar tikrai norite panaikinti ryšį su '{{entityName}}' subjektu?", + "delete-to-relation-text": "Būkite dėmesingi, po patvirtinimo '{{entityName}}' subjektas neteks ryšio su dabartiniu subjektu.", + "delete-to-relations-title": "Ar tikrai norite panaikinti { count, plural, =1 {1 ryšį} other {# ryšius} }?", + "delete-to-relations-text": "Būkite dėmesingi, po patvirtinimo visi pasirinkti ryšiai bus panaikinti ir atitinkami subjektai neteks ryšių su dabartiniu subjektu.", + "delete-from-relation-title": "Ar tikrai norite panaikinti ryšį iš '{{entityName}}' subjekto?", + "delete-from-relation-text": "Būkite dėmesingi, po patvirtinimo, dabartinis subjektas neteks ryšio su '{{entityName}}' subjektu.", + "delete-from-relations-title": "Ar tikrai norite panaikinti { count, plural, =1 {1 ryšį} other {# ryšius} }?", + "delete-from-relations-text": "Būkite dėmesingi, po patvirtinimo, visi pasirinkti ryšiai bus panaikinti ir dabartinis subjektas neteks ryšių su atitinkamais subjektais.", + "remove-relation-filter": "Panaikinti ryšių filtrą", + "remove-filter": "Panakinti filtrą", + "add-relation-filter": "Pridėti ryšių filtrą", + "any-relation": "Bet kuris ryšys", + "relation-filters": "Ryšių filtrai", + "additional-info": "Papildoma informacija (JSON)", + "invalid-additional-info": "Papildomos JSON informacijos išanalizuoti nepavyko.", + "no-relations-text": "Ryšių nėra" + }, + "resource": { + "add": "Add Resource", + "all-types": "All", + "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", + "type": { + "jks": "JKS", + "js-module": "JS module", + "lwm2m-model": "LWM2M model", + "pkcs-12": "PKCS #12" + } + }, + "rulechain": { + "rulechain": "Rule chain", + "rulechain-events": "Rule chain events", + "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", + "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-tscheduler-eventemplate-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": { + "rule-node-events": "Rule node events", + "details": "Details", + "events": "Events", + "search": "Search nodes", + "open-node-library": "Open node library", + "close-node-library": "Close node library", + "add": "Add rule node", + "name": "Name", + "name-required": "Name is required.", + "name-max-length": "Name should be less than 256", + "type": "Type", + "rule-node-description": "Rule node 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", + "singleton-mode": "Singleton 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-analytics": "Analytics", + "type-analytics-details": "Perform analysis of streamed or persisted data", + "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", + "test-with-this-message": "{{test}} with this message" + }, + "role": { + "role": "Rolė", + "roles": "Rolės", + "management": "Rolių valdymas", + "view-roles": "Peržiūrėti roles", + "no-roles-matching": "Rolių, atitinkančių '{{entity}}' nėra.", + "role-list": "Rolių sąrašas", + "role-list-required": "Rolių sąrašas būtinas", + "add": "Pridėti rolę", + "view": "Peržiūrėti rolę", + "search": "Rolių paieška", + "selected-roles": "Pasirinkta { count, plural, =1 {1 rolė} other {# rolės} }", + "no-roles-text": "Rolių nėra", + "role-details": "Informacija apie rolę", + "add-role-text": "Pridėti naują rolę", + "delete": "Panaikinti rolę", + "delete-roles": "Panaikinti roles", + "delete-role-title": "Ar tikrai norite panaikinti '{{roleName}}' rolę?", + "delete-role-text": "Būkite dėmesingi, po patvirtinimo, rolė ir visa su ja susijusi informacija bus pašalinta.", + "delete-roles-title": "Ar tikrai norite panaikinti { count, plural, =1 {1 rolę} other {# roles} }?", + "delete-roles-action-title": "Panaikinti { count, plural, =1 {1 rolę} other {# roles} }", + "delete-roles-text": "Būkite dėmesingi, po patvirtinimo, visos pasirinktos rolės ir su jomis susijusi informacija bus pašalintos.", + "role-type": "Tipas", + "role-type-required": "Rolės tipas būtinas.", + "select-role-type": "Pasirinkite rolės tipą", + "enter-role-type": "Įveskite rolės tipą", + "any-role": "Bet kuri rolė", + "no-role-types-matching": "Rolių tipų, atitinkančių '{{entitySubtype}}' nėra.", + "role-type-list-empty": "Nepasirinktas rolės tipas.", + "role-types": "Rolių tipais", + "created-time": "Sukųrimo laikas", + "name": "Pavadinimas", + "name-required": "Pavadinimas būtinas.", + "name-max-length": "Pavadinimas negali viršyti 256 simbolių", + "description": "Aprašymas", + "events": "Įvykiai", + "details": "Informacija", + "copyId": "Kopijuoti rolės Id", + "idCopiedMessage": "Rolės Id nukopijuotas į iškarpinę", + "permissions": "Leidimai", + "role-required": "Rolė būtina", + "roles-required": "Rolės būtinos", + "display-type": { + "GENERIC": "Bendroji", + "GROUP": "Grupinė" + } + }, + "group-permission": { + "user-group-roles": "Vartotojų grupės rolės", + "entity-group-permissions": "Subjektų grupės leidimai", + "role-type": "Rolės tipas", + "role-name": "Rolės pavadinimas", + "group-type": "Grupės tipas", + "group-name": "Grupės pavadinimas", + "group-owner": "Grupės savininkas", + "user-group-name": "Vartotojų grupės pavadinimas", + "user-group-owner": "Vartotojų grupės savininkas", + "edit": "Redaguoti leidimus", + "delete": "Naikinti leidimus", + "selected-group-permissions": "Pasirinkta { count, plural, =1 {1 grupinis leidimas} other {# grupiniai leidimai} }", + "delete-group-permission-title": "Ar tikrai norite panaikinti '{{roleName}}' grupinį leidimą?", + "delete-group-permission-text": "Būkite dėmesingi, po patvirtinimo, grupinis leidimas ir visa su juo sisijusi informacija bus pašalinta.", + "delete-group-permission": "Panaikinti grupinį leidima", + "delete-group-permissions-title": "Ar tikrai norite panaikinti { count, plural, =1 {1 grupinį leidimą} other {# grupinius leidimus} }?", + "delete-group-permissions-text": "Būkite dėmesingi, po patvirtinimo, pasirinkti grupiniai leidimai ir visa su jais susijusi informacija bus panaikinta ir atitinkami vartotojai neteks prieigos prie tam tikrų resursų.", + "delete-group-permissions": "Panaikinti grupinius leidimus", + "add-group-permission": "Pridėti grupinį leidimą", + "edit-group-permission": "Redaguoti grupinį leidimą", + "entity-group": "Subjektų grupė", + "user-group": "Vartotojų grupė", + "no-owners-matching": "Savininkų, atitinkančių '{{owner}}' nėra.", + "target-owner-required": "Subjektų grupės savininkas būtinas.", + "target-user-group-owner-required": "Vartotojų grupės savininkas būtinas.", + "no-group-permissions-text": "Grupinių leidimų nėra" + }, + "permission": { + "permissions-required": "Turi būti nurodytas nors vienas leidimas.", + "remove-permission": "Panaikinti leidimą", + "add-permission": "Pridėti leidimą", + "other": "Kita", + "resource": { + "resource": "Resursas", + "select-resource": "Pasirinkite resursą", + "resource-required": "Resursas būtinas", + "no-resources-matching": "Resursų, atitinkančių '{{resource}}' nėra.", + "display-type": { + "ALL": "Visi", + "PROFILE": "Profilis", + "ADMIN_SETTINGS": "Administratoriaus nustatymai", + "ALARM": "Įspėjimai", + "DEVICE": "Įrenginys", + "DEVICE_PROFILE": "Įrenginio profilis", + "ASSET": "Turtas", + "CUSTOMER": "Klientas", + "DASHBOARD": "Skydelis", + "ENTITY_VIEW": "Subjekto rodinys", + "EDGE": "Edge", + "TENANT": "Valdytojas", + "TENANT_PROFILE": "Valdytojo profilis", + "RULE_CHAIN": "Taisyklių grandinė", + "USER": "Vartotojas", + "WIDGETS_BUNDLE": "Valdiklių rinkinys", + "WIDGET_TYPE": "Valdiklio tipas", + "CONVERTER": "Keitiklis", + "INTEGRATION": "Integracija", + "SCHEDULER_EVENT": "Tvarkaraščio įvykis", + "BLOB_ENTITY": "Blob subjektas", + "CUSTOMER_GROUP": "Klientų grupė", + "DEVICE_GROUP": "Įrenginių grupė", + "ASSET_GROUP": "Turto grupė", + "USER_GROUP": "Vartotojų grupė", + "ENTITY_VIEW_GROUP": "Subjektų rodinių grupė", + "DASHBOARD_GROUP": "Skydelių grupė", + "ROLE": "Rolė", + "GROUP_PERMISSION": "Grupinis leidimas", + "WHITE_LABELING": "Tinkinimas", + "AUDIT_LOG": "Audito žurnalas", + "API_USAGE_STATE": "API Usage State", + "TB_RESOURCE": "Resource", + "EDGE_GROUP": "Edge Group", + "OTA_PACKAGE": "Ota package", + "QUEUE": "Queue", + "VERSION_CONTROL": "Version control", + "ASSET_PROFILE": "Asset Profile", + "NOTIFICATION": "Notification" + } + }, + "operation": { + "operation": "Operacija", + "operations": "Operacijos", + "operations-required": "Turi būti nurodyta nors viena operacija.", + "enter-operation": "Įveskite operaciją", + "no-operations-matching": "Operacijų, atitinkančių '{{operation}}' nėra.", + "display-type": { + "ALL": "Visi", + "CREATE": "Sukurti", + "READ": "Skaityti", + "WRITE": "Rašyti", + "DELETE": "Panaikinti", + "ASSIGN_TO_CUSTOMER": "Priskirti klientui", + "UNASSIGN_FROM_CUSTOMER": "Atsieti nuo kliento", + "RPC_CALL": "RPC kvietimas", + "READ_CREDENTIALS": "Skaityti įgaliojimus", + "WRITE_CREDENTIALS": "Rašyti įgaliojimus", + "READ_ATTRIBUTES": "Skaityti atributus", + "WRITE_ATTRIBUTES": "Rašyti atributus", + "READ_TELEMETRY": "Skaityti telemetriją", + "WRITE_TELEMETRY": "Rašyti telemetriją", + "CLAIM_DEVICES": "Patvirtinti įrenginius", + "IMPERSONATE": "Įeiti kaip vartotojui", + "CHANGE_OWNER": "Pakeisti savininką", + "ADD_TO_GROUP": "Įtraukti į grupę", + "REMOVE_FROM_GROUP": "Pašalinti iš grupės", + "SHARE_GROUP": "Bendrinti grupę", + "ASSIGN_TO_TENANT": "Priskirti valdytojui" + } + } + }, + "scheduler": { + "scheduler": "Tvarkaraštis", + "scheduler-event": "Tvarkaraščio įvykis", + "select-scheduler-event": "Pasirinkite tvarkaraščio įvykį", + "no-scheduler-events-matching": "Tvarkaraščio įvykių, atitinkančių '{{entity}}' nėra.", + "scheduler-event-required": "Tvarkaraščio įvykis būtinas", + "management": "Tvarkaraščio valdymas", + "scheduler-events": "Tvarkaraščio įvykiai", + "add-scheduler-event": "Pridėti tvarkaraščio įvykį", + "search-scheduler-events": "Tvarkaraščio įvykių paieška", + "created-time": "Sukūrimo laikas", + "name": "Pavadinimas", + "name-required": "Pavadinimas būtinas", + "name-max-length": "Pavadinimas negali viršyti 256 simbolių", + "type": "Tipas", + "created_customer": "Sukūrė klientas", + "edit-scheduler-event": "Redaguoti tvarkaraščio įvykį", + "view-scheduler-event": "Peržiūrėti tvarkaraščio įvykį", + "delete-scheduler-event": "Panaikinti tvarkaraščio įvykį", + "no-scheduler-events": "Tvarkaraščio įvykių nėra", + "selected-scheduler-events": "Pasirinkta { count, plural, =1 {1 tvarkaraščio įvykis} other {# tvarkaraščio įvykiai} }", + "delete-scheduler-event-title": "Ar tikrai norite panaikinti tvarkaraščio įvykį '{{schedulerEventName}}'?", + "delete-scheduler-event-text": "`", + "delete-scheduler-events-title": "Ar tikrai norite panaikinti { count, plural, =1 {1 tvarkaraščio įvykį} other {# tvarkaraščio įvykius} }?", + "delete-scheduler-events-text": "Būkite dėmesingi, po patvirtinimo, pasirinkti tvarkaraščio įvykiai ir visa su jais susijusi informacija bus panaikinta.", + "create": "Sukurti tvarkaraščio įvykį", + "edit": "Redaguoti tvarkaraščio įvykį", + "view": "Peržiūrėti tvarkaraščio įvykį", + "configuration": "Konfigūracija", + "schedule": "Tvarkaraštis", + "start-time": "Pradžios laikas", + "repeat": "Pakartoti", + "repeats": "Pakartojimai", + "daily": "Kasdieną", + "every-n-days": "Kas N dienas", + "every-n-days-text": "Every { days, plural, =1 {day} other {# days} }", + "weekly": "Kas savaitę", + "every-n-weeks": "Kas N savaites", + "every-n-weeks-text": "Every { weeks, plural, =1 {week} other {# weeks} }", + "monthly": "Kas mėnesį", + "yearly": "Kas metus", + "timer": "Nurodytu laiku", + "repeats-required": "Pakartojimai būtini.", + "repeat-on": "Kartoti", + "repeat-every": "Kartoti kiekvieną", + "repeat-every-n-days": "Kartoti kas N dienas", + "repeat-days-required": "Pakartojim dienų skaičius būtinas.", + "invalid-repeat-days-value": "Pakartojimo dienų skaičius turi būti sveikas teigiamas skaičius.", + "repeat-every-n-weeks": "Kartoti kas N savaites", + "repeat-weeks-required": "Pakartojimo savaičių skaičius būtinas.", + "invalid-repeat-weeks-value": "Pakartojimo savaičių skaičius turi būti sveikas teigiamas skaičius.", + "ends-on": "Pabaiga", + "sunday-label": "Sk", + "monday-label": "Pr", + "tuesday-label": "An", + "wednesday-label": "Tr", + "thursday-label": "Kt", + "friday-label": "Pn", + "saturday-label": "Št", + "repeat-on-sunday": "Kartoti sekmadieniais", + "repeat-on-monday": "Kartoti pirmadieniais", + "repeat-on-tuesday": "Kartoti antradieniais", + "repeat-on-wednesday": "Kartoti trečiadieniais", + "repeat-on-thursday": "Kartoti ketvirtadieniais", + "repeat-on-friday": "Kartoti penktadieniais", + "repeat-on-saturday": "Kartoti šeštadieniais", + "event-type": "Įvykio tipas", + "select-event-type": "Pasirinkite įvykio tiipą", + "event-type-required": "Įvykio tipas būtinas.", + "event-type-max-length": "Įvykio tipas negali viršyti 256 simbolių.", + "list-mode": "Sąrašas", + "calendar-mode": "Kalendorius", + "calendar-view-type": "Kalendoriaus peržiūros tipas", + "month": "Mėnuo", + "week": "Savaitė", + "day": "Diena", + "agenda-week": "Savaitinis tvarkaraštis", + "agenda-day": "Dienų tvarkaraštis", + "list-year": "Sąrašas metams", + "list-month": "Sąrašas mėnesiui", + "list-week": "Sąrašas savaitei", + "list-day": "Sąrašas dienai", + "today": "Šiandien", + "navigate-before": "Ankstesnis", + "navigate-next": "Sekantis", + "starting-from": "Pradėti nuo", + "until": "iki", + "on": "į", + "sunday": "Sekmadienis", + "monday": "Pirmadienis", + "tuesday": "Antradienis", + "wednesday": "Trečiadienis", + "thursday": "Ketvirtadienis", + "friday": "Penktadienis", + "saturday": "Šesštadienis", + "originator": "Iniciatorius", + "single-entity": "Vienas subjektas", + "group-of-entities": "Subjektų grupė", + "entities-group-owner": "Subjektų grupės savininkas", + "single-device": "Vienas įrenginys", + "group-of-devices": "Įrenginių grupė", + "devices-group-owner": "Įrenginių grupės savininkas", + "message-body": "Pranešimo tekstas", + "target": "Tikslas", + "rpc-method": "Method", + "rpc-method-required": "Method is required", + "rpc-method-white-space": "White space is not allowed.", + "rpc-params": "Params", + "select-dashboard-state": "Pasirinkite skydelio būseną", + "hours": "Valandos", + "minutes": "Minutės", + "seconds": "Sekundės", + "time-interval-required": "Laiko intervalas būtinas", + "time-unit-required": "Laiko vienetas būtinas", + "every-hour": "kas { count, plural, =1 {valandą} other {# valandas} }", + "every-minute": "kas { count, plural, =1 {minutę} other {# minutes} }", + "every-second": "kas { count, plural, =1 {sekundę} other {# sekundes} }", + "invalid-time": "Neteisingas laikas" + }, + "report": { + "report-config": "Ataskaitos konfigūracija", + "email-config": "Elektroninio pašto konfigūracija", + "dashboard-state-param": "Skydelio būsenos parametro reikšmė", + "base-url": "Pagrindinis URL", + "base-url-required": "Pagrindinis URL būtinas.", + "use-dashboard-timewindow": "Naudoti skydelio laiko langą", + "timewindow": "Laiko langas", + "name-pattern": "Ataskaitos pavadinimo šablonas", + "name-pattern-required": "Ataskaitos pavadinimo šablonas būtinas", + "type": "Ataskaitos tipas", + "use-current-user-credentials": "Naudoti dabartinio vartotojo įgaliojimus", + "customer-user-credentials": "Kliento vartotojo įgaliojimai", + "customer-user-credentials-required": "Kliento vartotojo įgaliojimai būtini", + "generate-test-report": "Generuoti bandomąją ataskaitą", + "send-email": "Siųsti laišką", + "from": "Nuo", + "from-required": "Būtina nurodyti nuo ko siunčiamas laiškas.", + "to": "Kam", + "to-required": "Būtina nurodyti kam siunčiamas laiškas.", + "cc": "Cc", + "bcc": "Bcc", + "subject": "Tema", + "subject-required": "Būtina nurodyti laiško temą.", + "body": "Tekstas", + "body-required": "Būtina įrašyti laiško tekstą." + }, + "blob-entity": { + "blob-entity": "Blob subjektas", + "select-blob-entity": "Pasirinkite blob subjektą", + "no-blob-entities-matching": "Blob subjektų, atitinkančių '{{entity}}' nėra.", + "blob-entity-required": "Blob subjektas būtinas", + "files": "Failai", + "search": "Failų paieška", + "clear-search": "Išvalyti paiešką", + "no-blob-entities-prompt": "Failų nėra", + "report": "Ataskaita", + "created-time": "Sukūrimo laikas", + "name": "Pavadinimas", + "type": "Tipas", + "created_customer": "Skūrė klientas", + "selected-blob-entities": "Pasirinkta{ count, plural, =1 {1 failas} other {# failai} }", + "download-blob-entity": "Atsisiųsti failą", + "delete-blob-entity": "Ištrinti failą", + "delete-blob-entity-title": "Ar tikrai norite ištrinti '{{blobEntityName}}' failą?", + "delete-blob-entity-text": "Būkite dėmesingi, po patvirtinimo failas bus ištrintas.", + "delete-blob-entities-title": "Artikrai norite ištrinti { count, plural, =1 {1 failą} other {# failus} }?", + "delete-blob-entities-text": "Būkite dėmesingi, po patvirtinimo visi pasirinkti failai ir su jais susijusi informacija bus ištrinta." + }, + "timezone": { + "timezone": "Laiko juosta", + "select-timezone": "Pasirinkti laiko juostą", + "no-timezones-matching": "Laiko juostų, atitinkančių '{{timezone}}' nėra.", + "timezone-required": "Laiko juosta būtina.", + "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}}", + "custom-properties": "Custom properties", + "custom-properties-hint": "Custom queue (topic) creation properties, e.g. 'retention.ms:604800000;retention.bytes:1048576000'", + "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": "Valdytojas", + "tenants": "Valdytojai", + "management": "Valdytojų valdymas", + "add": "Pridėti valdytoją", + "admins": "Administratoriai", + "manage-tenant-admins": "Valdyti valdytojo administratorius", + "delete": "Ištrinti valdytoją", + "add-tenant-text": "Pridėti naują valdytoją", + "no-tenants-text": "Valdytojų nėra", + "tenant-details": "Informacija apie valdytoją", + "title-max-length": "Pavadinimas negali viršyti 256 simbolių", + "delete-tenant-title": "Ar tikrai norite ištrinti valdytoją '{{tenantTitle}}'?", + "delete-tenant-text": "Būkite dėmesingi, po patvirtinimo valdytojas ir visa su juo susijusi informacija bus pašalinta.", + "delete-tenants-title": "Ar tikrai norite pašalinti { count, plural, =1 {1 valdytoją} other {# valdytojus} }?", + "delete-tenants-action-title": "Pašalinti { count, plural, =1 {1 valdytoją} other {# valdytojus} }", + "delete-tenants-text": "Būkite dėmesingi, po patvirtinimo, visi pasirinkti valdytojai ir su jais susijusi informacija bus pašalinta.", + "title": "Pavadinimas", + "title-required": "Pavadinimas būtinas.", + "description": "Aprašymas", + "details": "Informacija", + "events": "Įvykiai", + "copyId": "Kopijuoti valdytojo Id", + "idCopiedMessage": "Valdytojo Id nukopijuotas į iškarpinę", + "select-tenant": "Pasirinkti valdytoją", + "no-tenants-matching": "Valdytojų, atitinkančių '{{entity}}' nėra.", + "tenant-required": "Valdytojas būtinas", + "allow-white-labeling": "Leisti personalizavimą", + "allow-customer-white-labeling": "Leisti kliento personalizavimą", + "search": "Valdytojų paieška", + "selected-tenants": "Pasirinkta { count, plural, =1 {1 valdytojas} other {# valdutojai} }", + "isolated-tb-rule-engine": "Use isolated ThingsBoard Rule Engine queues", + "isolated-tb-rule-engine-details": "Each tenant will have dedicated Rule Engine queues" + }, + "tenant-profile": { + "tenant-profile": "Valdytojų profilis", + "tenant-profiles": "Valdytojų profiliai", + "add": "Pridėti valdytojų profilį", + "add-profile": "Pridėti profilį", + "edit": "Redaguoti valdytojų profilį", + "tenant-profile-details": "Informacija apie valdytojų profilį", + "no-tenant-profiles-text": "Valdytojų profilių nėra", + "name-max-length": "Pavadinimas negali viršyti 256 simbolių", + "search": "Valdytojų profilių paieška", + "selected-tenant-profiles": "Pasirinkta { count, plural, =1 {1 valdytojų profilis} other {# valdytojų profiliai} }", + "no-tenant-profiles-matching": "Valdytojų profilių, atitinkančių '{{entity}}' nėra.", + "tenant-profile-required": "Valdytojų profilis būtinas", + "idCopiedMessage": "Valdytojų profilio Id nukopijuotas į iškarpinę", + "set-default": "Valdytojų profilį nustatyti kaip pagrndinį", + "delete": "Panaikinti valdytojų profilį", + "copyId": "Kopijuoti valdytojų profilio Id", + "name": "Pavadinimas", + "name-required": "Pavadinimas būtinas.", + "data": "Profilio duomenys", + "profile-configuration": "Profilio konfigūracija", + "description": "Aprašymas", + "default": "Pagrindinis", + "delete-tenant-profile-title": "Ar tikrai norite panaikinti valdytojų profilį '{{tenantProfileName}}'?", + "delete-tenant-profile-text": "Būkite dėmesingi, po patvirtinimo, valdytojų profilis ir visa su juo susijusi informacija bus pašalinta.", + "delete-tenant-profiles-title": "Ar tikrai norite pašalinti { count, plural, =1 {1 valdytojų profilį} other {# valdytojų profilius} }?", + "delete-tenant-profiles-text": "Būkite dėmesingi, po patvirtinimo, visi pasirinkti valdytojų profiliai ir su jais susijusi informacija bus pašalinta.", + "set-default-tenant-profile-title": "Ar tikrai norite valdytojų profilį '{{tenantProfileName}}' nustatyti kaip pagrindinį?", + "set-default-tenant-profile-text": "Po patvirtinimo, valdytojų profilis bus nustatytas kaip pagrinfinis ir bus priskiriamas naujiems valdytojams, kuriems valdytojų profilis nenurodytas.", + "no-tenant-profiles-found": "Valdytojų profilių nėra.", + "create-new-tenant-profile": "Sukurti naują!", + "create-tenant-profile": "Sukuri naują valdytojo profilį", + "import": "Importuoti valdytojo profilį", + "export": "Eksportuoti valdytojo profilį", + "export-failed-error": "Valdytojo profilio importuoti nepavyko: {{error}}", + "tenant-profile-file": "Valdytojo profilio failas", + "invalid-tenant-profile-file-error": "Valdytojo profilio importuoti nepavyko: Neteisinga valdytojo profilio duomenų struktūra.", + "advanced-settings": "Pažangūs nustatymai", + "entities": "Subjektai", + "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-integrations": "Integrations maximum number", + "maximum-integrations-required": "Integrations maximum number is required.", + "maximum-integrations-range": "Integrations maximum number can't be negative", + "maximum-converters": "Converters maximum number", + "maximum-converters-required": "Converters maximum number is required.", + "maximum-converters-range": "Converters maximum number can't be negative", + "maximum-scheduler-events": "Scheduler events maximum number", + "maximum-scheduler-events-required": "Scheduler events maximum number is required.", + "maximum-scheduler-events-range": "Scheduler events 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", + "rest-requests-for-tenant": "REST requests for tenant", + "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", + "tenant-notification-request-rate-limit": "Notification requests", + "tenant-notification-requests-per-rule-rate-limit": "Notification requests per notification rule", + "integration-msgs-per-tenant-rate-limit": "Tenant integration messages", + "integration-msgs-per-device-rate-limit": "Device integration messages", + "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", + "sms-enabled": "SMS enabled", + "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", + "queue-size-is-limited-by-system-configuration": "The size of the queue is also limited by the system configuration.", + "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-tenant-rest-limits-title": "Edit REST requests for tenant 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", + "edit-tenant-notification-request-rate-limit-title": "Edit notification requests rate limits", + "edit-tenant-notification-requests-per-rule-rate-limit-title": "Edit notification requests per notification rule rate limits", + "edit-integration-msgs-per-tenant-rate-limit-title": "Edit integration messages per tenant rate limits", + "edit-integration-msgs-per-device-rate-limit-title": "Edit integration messages per device 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 sekundė} other {# sekundės} }", + "minutes-interval": "{ minutes, plural, =1 {1 minutė} other {# minutės} }", + "hours-interval": "{ hours, plural, =1 {1 valanda} other {# valandos} }", + "days-interval": "{ days, plural, =1 {1 diena} other {# dienos} }", + "days": "Dienos", + "hours": "Valandos", + "minutes": "Minutės", + "seconds": "Sekundės", + "advanced": "Patyręs vartotojas", + "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-quarter": "Previous quarter", + "previous-half-year": "Previous half year", + "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-quarter": "Current quarter", + "current-quarter-so-far": "Current quarter so far", + "current-half-year": "Current half year", + "current-half-year-so-far": "Current half year so far", + "current-year": "Current year", + "current-year-so-far": "Current year so far" + } + }, + "timeunit": { + "milliseconds": "Milisekundės", + "seconds": "Sekundės", + "minutes": "Minutės", + "hours": "Valandos", + "days": "Dienos" + }, + "timewindow": { + "timewindow": "Laikotarpio vaizdas", + "years": "{ years, plural, =1 { metai } other {# metai } }", + "years-short": "{{ years }}m", + "months": "{ months, plural, =1 { mėnuo } other {# mėnesiai } }", + "months-short": "{{ months }}mėn", + "weeks": "{ weeks, plural, =1 { savaitė } other {# savaitės } }", + "weeks-short": "{{ weeks }}sav", + "days": "{ days, plural, =1 { diena } other {# dienos } }", + "days-short": "{{ days }}d", + "hours": "{ hours, plural, =0 { valandų } =1 {1 valanda } other {# valandos } }", + "hr": "{{ hr }} val", + "hr-short": "{{ hr }}val", + "minutes": "{ minutes, plural, =0 { minučių } =1 {1 minutė } other {# minutės } }", + "min": "{{ min }} min", + "min-short": "{{ min }}min", + "seconds": "{ seconds, plural, =0 { sekundžių } =1 {1 sekundė } other {# sekundės } }", + "sec": "{{ sec }} sek", + "sec-short": "{{ sec }}s", + "short": { + "days": "{ days, plural, =1 {1 diena } other {# dienos } }", + "hours": "{ hours, plural, =1 {1 valanda } other {# valandos } }", + "minutes": "{{minutes}} min ", + "seconds": "{{seconds}} sek " + }, + "realtime": "Realiu laiku", + "history": "istorija", + "last-prefix": "Paskutinės", + "period": "nuo {{ startTime }} iki {{ endTime }}", + "edit": "Redaguoti laikotarpio vaizdą", + "date-range": "Datų intervalas", + "for-all-time": "Viskas", + "last": "Paskutinės", + "time-period": "Laikotarpis", + "hide": "Slėpti", + "interval": "Interval", + "just-now": "Just now", + "just-now-lower": "just now", + "ago": "ago", + "style": "Timewindow style", + "icon": "Icon", + "icon-position": "Icon position", + "icon-position-left": "Left", + "icon-position-right": "Right", + "font": "Font", + "color": "Color", + "displayTypePrefix": "Display Realtime/History prefix", + "preview": "Preview" + }, + "unit": { + "millimeter": "Millimeter", + "centimeter": "Centimeter", + "angstrom": "Angstrom", + "nanometer": "Nanometer", + "micrometer": "Micrometer", + "meter": "Meter", + "kilometer": "Kilometer", + "inch": "Inch", + "foot": "Foot", + "yard": "Yard", + "mile": "Mile", + "nautical-mile": "Nautical Mile", + "astronomical-unit": "Astronomical Unit", + "reciprocal-metre": "Reciprocal Metre", + "meter-per-meter": "Meter per meter", + "steradian": "Steradian", + "thou": "Thou", + "barleycorn": "Barleycorn", + "hand": "Hand", + "chain": "Chain", + "furlong": "Furlong", + "league": "League", + "fathom": "Fathom", + "cable": "Cable", + "link": "Link", + "rod": "Rod", + "nanogram": "Nanogram", + "microgram": "Microgram", + "milligram": "Milligram", + "gram": "Gram", + "kilogram": "Kilogram", + "tonne": "Tonne", + "ounce": "Ounce", + "pound": "Pound", + "stone": "Stone", + "hundredweight-count": "Hundredweight count", + "short-tons": "Short tons", + "dalton": "Dalton", + "grain": "Grain", + "drachm": "Drachm", + "quarter": "Quarter", + "slug": "Slug", + "carat": "Carat", + "cubic-millimeter": "Cubic Millimeter", + "cubic-centimeter": "Cubic Centimeter", + "cubic-meter": "Cubic Meter/s", + "cubic-kilometer": "Cubic Kilometers", + "microliter": "Microliter", + "milliliter": "Milliliter", + "liter": "Liter", + "hectoliter": "Hectolitre", + "cubic-inch": "Cubic Inch", + "cubic-foot": "Cubic Foot", + "cubic-yard": "Cubic Yards", + "fluid-ounce": "Fluid Ounce", + "pint": "Pint", + "quart": "Quart", + "gallon": "Gallon", + "oil-barrels": "Oil Barrels", + "cubic-meter-per-kilogram": "Cubic Meter per Kilogram", + "gill": "Gill", + "hogshead": "Hogshead", + "teaspoon": "Teaspoon", + "tablespoon": "Tablespoon", + "cup": "Cup", + "celsius": "Celsius", + "kelvin": "Kelvin", + "rankine": "Rankine", + "fahrenheit": "Fahrenheit", + "percent": "Percent", + "meter-per-second": "Meter per Second", + "kilometer-per-hour": "Kilometer per Hour", + "foot-per-second": "Foot per Second", + "mile-per-hour": "Mile per Hour", + "knot": "Knot", + "millimeters-per-minute": "Millimeters per minute", + "kilometer-per-hour-squared": "Kilometer per hour squared", + "foot-per-second-squared": "Foot per second squared", + "pascal": "Pascal", + "kilopascal": "Kilopascal", + "megapascal": "Megapascal", + "gigapascal": "Gigapascal", + "millibar": "Millibar", + "bar": "Bar", + "kilobar": "Kilobar", + "newton": "Newton", + "newton-meter": "Newton meter", + "foot-pounds": "Foot-pounds", + "inch-pounds": "Inch-pounds", + "newton-per-meter": "Newton per meter", + "atmospheres": "Atmospheres", + "pounds-per-square-inch": "Pounds per Square Inch", + "torr": "Torr", + "inches-of-mercury": "Inches of Mercury", + "pascal-per-square-meter": "Pascal per Square Meter", + "pound-per-square-inch": "Pound per Square Inch", + "newton-per-square-meter": "Newton per Square Meter", + "kilogram-force-per-square-meter": "Kilogram-force per Square Meter", + "pascal-per-square-centimeter": "Pascal per Square Centimeter", + "ton-force-per-square-inch": "Ton-force per Square Inch", + "kilonewton-per-square-meter": "Kilonewton per Square Meter", + "newton-per-square-millimeter": "Newton per Square Millimeter", + "microjoule": "Microjoule", + "millijoule": "Millijoule", + "joule": "Joule", + "kilojoule": "Kilojoule", + "megajoule": "Megajoule", + "gigajoule": "Gigajoule", + "watt-hour": "Watt-hour", + "kilowatt-hour": "Kilowatt-hour", + "electron-volts": "Electron volts", + "joules-per-coulomb": "Joules per Coulomb", + "british-thermal-unit": "British Thermal Units", + "foot-pound": "Foot-pound", + "calorie": "Calorie", + "small-calorie": "Small Calorie", + "kilocalorie": "Kilocalorie", + "joule-per-kelvin": "Joule per Kelvin", + "joule-per-kilogram-kelvin": "Joule per Kilogram-Kelvin", + "joule-per-kilogram": "Joule per Kilogram", + "watt-per-meter-kelvin": "Watt per Meter-Kelvin", + "joule-per-cubic-meter": "Joule per Cubic Meter", + "therm": "Therm", + "electric-dipole-moment": "Electric Dipole Moment", + "magnetic-dipole-moment": "Magnetic Dipole Moment", + "debye": "Debye", + "coulomb-per-square-meter-per-volt": "Coulomb per Square Meter per Volt", + "milliwatt": "Milliwatt", + "microwatt": "Microwatt", + "watt": "Watt", + "kilowatt": "Kilowatt", + "megawatt": "Megawatt", + "gigawatt": "Gigawatt", + "metric-horsepower": "Metric Horsepower", + "milliwatt-per-square-centimeter": "Milliwatts per square centimeter", + "watt-per-square-centimeter": "Watts per square centimeter", + "kilowatt-per-square-centimeter": "Kilowatts per square centimeter", + "milliwatt-per-square-meter": "Milliwatts per square meter", + "watt-per-square-meter": "Watts per square meter", + "kilowatt-per-square-meter": "Kilowatts per square meter", + "watt-per-square-inch": "Watts per square inch", + "kilowatt-per-square-inch": "Kilowatts per square inch", + "horsepower": "Horsepower", + "btu-per-hour": "British thermal units/hour", + "coulomb": "Coulomb", + "millicoulomb": "Millicoulombs", + "microcoulomb": "Microcoulomb", + "picocoulomb": "Picocoulomb", + "coulomb-per-meter": "Coulomb per meter", + "coulomb-per-cubic-meter": "Coulomb per Cubic Meter", + "coulomb-per-square-meter": "Coulomb per Square Meter", + "square-millimeter": "Square Millimeter", + "square-centimeter": "Square Centimeter", + "square-meter": "Square Meter", + "hectare": "Hectare", + "square-kilometer": "Square Kilometer", + "square-inch": "Square Inch", + "square-foot": "Square Foot", + "square-yard": "Square Yard", + "acre": "Acre", + "square-mile": "Square Mile", + "are": "Are", + "barn": "Barn", + "circular-inch": "Circular Inch", + "milliampere-hour": "Milliampere-hour", + "milliampere-hour-tags": "electric current, current flow, electric charge, current capacity, flow of electricity, electrical flow, milliampere-hour, milliampere-hours, mAh", + "ampere-hours": "Ampere-hours", + "ampere-hours-tags": "electric current, current flow, electric charge, current capacity, flow of electricity, electrical flow, ampere, ampere-hours, Ah", + "kiloampere-hours": "Kiloampere-hours", + "kiloampere-hours-tags": "electric current, current flow, electric charge, current capacity, flow of electricity, electrical flow, kiloampere-hours, kiloampere-hour, kAh", + "nanoampere": "Nanoampere", + "nanoampere-tags": "current, amperes, nanoampere, nA", + "picoampere": "Picoampere", + "picoampere-tags": "current, amperes, picoampere, pA", + "microampere": "Microampere", + "microampere-tags": "electric current, microampere, microamperes, μA", + "milliampere": "Milliampere", + "milliampere-tags": "electric current, milliampere, milliamperes, mA", + "ampere": "Ampere", + "ampere-tags": "electric current, current flow, flow of electricity, electrical flow, ampere, amperes, amperage, A", + "kiloamperes": "Kiloamperes", + "kiloamperes-tags": "electric current, current flow, kiloamperes, kA", + "microampere-per-square-centimeter": "Microampere per square centimeter", + "microampere-per-square-centimeter-tags": "Current density, microampere per square centimeter, µA/cm²", + "ampere-per-square-meter": "Ampere per Square Meter", + "ampere-per-square-meter-tags": "current density, current per unit area, ampere per square meter, A/m²", + "ampere-per-meter": "Ampere per Meter", + "ampere-per-meter-tags": "magnetic field strength, magnetic field intensity, ampere per meter, A/m", + "oersted": "Oersted", + "oersted-tags": "magnetic field, oersted, Oe", + "bohr-magneton": "Bohr Magneton", + "bohr-magneton-tags": "atomic physics, magnetic moment, bohr magneton, μB", + "ampere-meter-squared": "Ampere-Meter Squared", + "ampere-meter-squared-tags": "magnetic moment, dipole moment, ampere-meter squared, A·m²", + "ampere-meter": "Ampere-Meter", + "ampere-meter-tags": "magnetic field, current loop, ampere-meter, A·m", + "nanovolt": "Nanovolt", + "picovolt": "Picovolt", + "millivolts": "Millivolts", + "microvolts": "Microvolts", + "volt": "Volt", + "kilovolts": "Kilovolts", + "dbmV": "dBmV", + "volt-meter": "Volt-Meter", + "kilovolt-meter": "Kilovolt-Meter", + "megavolt-meter": "Megavolt-Meter", + "microvolt-meter": "Microvolt-Meter", + "millivolt-meter": "Millivolt-Meter", + "nanovolt-meter": "Nanovolt-Meter", + "ohm": "Ohm", + "microohm": "Microohm", + "milliohm": "Milliohm", + "kilohm": "Kilohm", + "megohm": "Megohm", + "gigohm": "Gigohm", + "hertz": "Hertz", + "kilohertz": "Kilohertz", + "megahertz": "Megahertz", + "gigahertz": "Gigahertz", + "rpm": "Revolutions Per Minute", + "candela-per-square-meter": "Candela per square meter", + "candela": "Candela", + "lumen": "Lumen", + "lux": "Lux", + "foot-candle": "Foot-candle", + "lumen-per-square-meter": "Lumen per square meter", + "lux-second": "Lux second", + "lumen-second": "Lumen second", + "lumens-per-watt": "Lumens per watt", + "absorbance": "Absorbance", + "mole": "Mole", + "nanomole": "Nanomole", + "micromole": "MicroMole", + "millimole": "Millimole", + "kilomole": "Kilomole", + "mole-per-cubic-meter": "Mole per Cubic Meter", + "rssi": "RSSI", + "ppm": "Parts Per Million", + "ppb": "Parts Per Billion", + "micrograms-per-cubic-meter": "Micrograms per Cubic Meter", + "aqi": "AQI", + "gram-per-cubic-meter": "Gram per cubic meter", + "gram-per-kilogram": "Specific Humidity", + "millimeters-per-second": "Millimeters per second", + "neper": "Neper", + "bel": "Bel", + "decibel": "Decibel", + "meters-per-second-squared": "Meters per second squared", + "becquerel": "Becquerel", + "curie": "Curie", + "gray": "Gray", + "sievert": "Sievert", + "roentgen": "Roentgen", + "cps": "Counts per Second", + "rad": "Rad", + "rem": "Rem", + "dps": "Disintegrations per second", + "rutherford": "Rutherford", + "coulombs-per-kilogram": "Coulombs per kilogram", + "becquerels-per-cubic-meter": "Becquerels per cubic meter", + "curies-per-liter": "Curies per liter", + "becquerels-per-second": "Becquerels per second", + "curies-per-second": "Curies per second", + "gy-per-second": "Gray per Second", + "watt-per-steradian": "Watt per Steradian", + "watt-per-square-metre-steradian": "Watt per Square Metre-Steradian", + "ph-level": "pH Level", + "turbidity": "Turbidity", + "mg-per-liter": "Milligrams per liter", + "microsiemens-per-centimeter": "Microsiemens per centimeter", + "millisiemens-per-meter": "Millisiemens per meter", + "siemens-per-meter": "Siemens per meter", + "kilogram-per-cubic-meter": "Kilogram per cubic meter", + "gram-per-cubic-centimeter": "Gram per cubic centimeter", + "kilogram-per-square-meter": "Kilogram per square metre", + "milligram-per-milliliter": "Milligram per milliliter", + "pound-per-cubic-foot": "Pound per cubic foot", + "ounces-per-cubic-inch": "Ounces per cubic inch", + "tons-per-cubic-yard": "Tons per cubic yard", + "particle-density": "Particle density", + "kilometers-per-liter": "Kilometers per liter", + "miles-per-gallon": "Miles per gallon", + "liters-per-100-km": "Liters per 100 km", + "gallons-per-mile": "Gallons per mile", + "liters-per-hour": "Liters per hour", + "gallons-per-hour": "Gallons per hour", + "beats-per-minute": "Beats per minute", + "millimeters-of-mercury": "Millimeters of mercury", + "milligrams-per-deciliter": "Milligrams per deciliter", + "g-force": "G-force", + "kilonewton": "Kilonewton", + "kilogram-force": "Kilogram-Force", + "pound-force": "Pound-Force", + "kilopound-force": "Kilopound-Force", + "dyne": "Dyne", + "poundal": "Poundal", + "kip": "Kip", + "gal": "Gal", + "gravity": "Gravity", + "hectopascal": "Hectopascal", + "atmosphere": "Atmosphere", + "millibars": "Millibars", + "inch-of-mercury": "One inch of mercury", + "richter-scale": "Richter Scale", + "second": "Second", + "minute": "Minute", + "hour": "Hour", + "day": "Day", + "week": "Week", + "month": "Month", + "year": "Year", + "cubic-foot-per-minute": "Cubic Foot Per Minute", + "cubic-meters-per-hour": "Cubic Meters Per Hour", + "cubic-meters-per-second": "Cubic Meters Per Second", + "liter-per-second": "Liter Per Second", + "liter-per-minute": "Liter Per Minute", + "gallons-per-minute": "Gallons Per Minute", + "cubic-foot-per-second": "Cubic foot per second", + "milliliters-per-minute": "Milliliters per minute", + "bit": "Bit", + "byte": "Byte", + "kilobyte": "Kilobyte", + "megabyte": "Megabyte", + "gigabyte": "Gigabyte", + "terabyte": "Terabyte", + "petabyte": "Petabyte", + "exabyte": "Exabyte", + "zettabyte": "Zettabyte", + "yottabyte": "Yottabyte", + "bit-per-second": "Bit per second", + "kilobit-per-second": "Kilobit per second", + "megabit-per-second": "Megabit per second", + "gigabit-per-second": "Gigabit per second", + "terabit-per-second": "Terabit per second", + "byte-per-second": "Byte per second", + "kilobyte-per-second": "Kilobyte per second", + "megabyte-per-second": "Megabyte per second", + "gigabyte-per-second": "Gigabyte per second", + "degree": "Degree", + "radian": "Radian", + "gradian": "Gradian", + "mil": "Mil", + "revolution": "Revolution", + "siemens": "Siemens", + "millisiemens": "Millisiemens", + "microsiemens": "Microsiemens", + "kilosiemens": "Kilosiemens", + "megasiemens": "Megasiemens", + "gigasiemens": "Gigasiemens", + "farad": "Farad", + "millifarad": "Millifarad", + "microfarad": "Microfarad", + "nanofarad": "Nanofarad", + "picofarad": "Picofarad", + "kilofarad": "Kilofarad", + "megafarad": "Megafarad", + "gigafarad": "Gigafarad", + "terfarad": "Terfarad", + "farad-per-meter": "Farad per Meter", + "tesla": "Tesla", + "gauss": "Gauss", + "kilogauss": "Kilogauss", + "millitesla": "Millitesla", + "microtesla": "Microtesla", + "nanotesla": "Nanotesla", + "kilotesla": "Kilotesla", + "megatesla": "Megatesla", + "millitesla-square-meters": "millitesla square meters", + "gamma": "Gamma", + "lambda": "Lambda", + "square-meter-per-second": "Square meter per second", + "square-centimeter-per-second": "Square centimeter per second", + "stoke": "Stoke", + "centistokes": "Centistokes", + "square-foot-per-second": "Square foot per second", + "square-inch-per-second": "Square inch per second", + "pascal-second": "Pascal-second", + "centipoise": "Centipoise", + "poise": "Poise", + "reynolds": "Reynolds", + "pound-per-foot-hour": "Pound per foot-hour", + "newton-second-per-square-meter": "Newton second per square meter", + "dyne-second-per-square-centimeter": "Dyne second per square centimeter", + "kilogram-per-meter-second": "Kilogram per meter-second", + "tesla-square-meters": "Tesla square meters", + "maxwell": "Maxwell", + "tesla-per-meter": "Tesla per Meter", + "gauss-per-centimeter": "Gauss per Centimeter", + "weber": "Weber", + "microweber": "Microweber", + "milliweber": "Milliweber", + "gauss-square-centimeter": "Gauss-Square Centimeter", + "kilogauss-square-centimeter": "Kilogauss-Square Centimeter", + "henry": "Henry", + "millihenry": "Millihenry", + "microhenry": "Microhenry", + "nanohenry": "Nanohenry", + "henry-per-meter": "Henry per Meter", + "tesla-meter-per-ampere": "Tesla Meter per Ampere", + "gauss-per-oersted": "Gauss per Oersted", + "kilogram-per-mole": "Kilogram per mole", + "gram-per-mole": "Gram per mole", + "milligram-per-mole": "Milligram per mole", + "joule-per-mole": "Joule per Mole", + "joule-per-mole-kelvin": "Joule per Mole-Kelvin", + "millivolts-per-meter": "Millivolts per meter", + "volts-per-meter": "Volts per meter", + "kilovolts-per-meter": "Kilovolts per meter", + "radian-per-second": "Radian per second", + "radian-per-second-squared": "Radian per second squared", + "revolutions-per-minute-per-second": "Angular acceleration", + "revolutions-per-minute-per-second-squared": "Angular Acceleration", + "deg-per-second": "deg/s", + "degrees-brix": "Degrees Brix", + "katal": "Katal", + "katal-per-cubic-metre": "Katal per Cubic Metre" + }, + "user": { + "all": "Visi", + "all-users": "Visi vartotojai", + "groups": "Grupės", + "user": "Vartotojas", + "users": "Vartotojai", + "management": "Vartotojų valdymas", + "customer-users": "Kliento vartotojai", + "tenant-admins": "Valdytojų administratoriai", + "sys-admin": "Sistemos administratoriai", + "tenant-admin": "Valdytojo administratoriai", + "customer": "Klientas", + "anonymous": "Anonimas", + "add": "Pridėti vartotoją", + "delete": "Panaikinti vartotoją", + "add-user-text": "Pridėti naują vartotoją", + "no-users-text": "Vartotojų nėra", + "user-details": "Informacija apie vartotoją", + "delete-users": "Panaikinti vartotojus", + "delete-user-title": "Ar tikrai norite panaikinti vartotoją '{{userEmail}}'?", + "delete-user-text": "Būkite dėmesingi, po patvirtinimo vartotojas ir visa su juo susijusi informacija bus panaikinta.", + "delete-users-title": "Ar tikrai norite panaikinti { count, plural, =1 {1 vatotoją} other {# vartotojus} }?", + "delete-users-action-title": "Panaikinti { count, plural, =1 {1 vartotoją} other {# vartotojus} }", + "delete-users-text": "Būkite dėmesingi, po patvirtinimo visi pasirinkti vartotojai ir su jis susijusi informacija bus panaikinta.", + "activation-email-sent-message": "Aktyvinimo el. laiškas sėkmingai išsiųstas!", + "resend-activation": "Iš naujo siųsti aktyvinimą", + "email": "El. pašto adresas", + "email-required": "El. pašto adresas būtinas.", + "invalid-email-format": "Neteisingas el. pašto adreso formatas.", + "first-name": "Vardas", + "last-name": "Pavardė", + "description": "Aprašymas", + "default-dashboard": "Pagrindinis skydelis", + "always-fullscreen": "Visada per visą ekraną", + "select-user": "Pasirinkti vartotoją", + "no-users-matching": "Vartotojų, atitinkančių'{{entity}}' nėra.", + "user-required": "Vartotojas būtinas", + "activation-method": "Aktyvavimo būdas", + "display-activation-link": "Rodyti aktyvavimo nuorodą", + "send-activation-mail": "Siųsti aktyvavimo el. laišką", + "activation-link": "Vartotojo aktyvavimo nuoroda", + "activation-link-text": "Vartotojui aktyvuoti, paspauskite nuorodą :", + "copy-activation-link": "Kopijuoti aktyvavimo nuorodą", + "activation-link-copied-message": "Vartotojo aktyvavimo nuoroda nukopijuota į iškarpinę", + "details": "Informacija", + "login-as-tenant-admin": "Prisijunkti kaip valdytojo administratorius", + "login-as-customer-user": "Prisijunkti kaip kliento vartotojas", + "select-group-to-add": "Pasirinkite grupę, kuriai priskirti pažymėtus vartotojus", + "select-group-to-move": "Pasirinkite grupę, į kurią perkelti pažymėtus vartotojus", + "remove-users-from-group": "Ar tikrai norite pašalinti { count, plural, =1 {1 vartotoją} other {# vartotojus} } iš grupės '{{entityGroup}}'?", + "group": "Vartotojų grupė", + "list-of-groups": "{ count, plural, =1 {Viena vartotojų grupė} other {# Vartotojų grupių sąrašas} }", + "group-name-starts-with": "Vartotojų grupės, kurios prasideda '{{prefix}}'", + "search": "Vartotojų paieška", + "selected-users": "Pasirinkta { count, plural, =1 {1 vartotojas} other {# vartotojai} }", + "disable-account": "Išjungti vartotojo paskyrą", + "enable-account": "Įjungti vartotojo paskyrą", + "enable-account-message": "Vartotojo paskyra sėkmingai įjungta!", + "disable-account-message": "Vartotojo paskyra sėkmingai išjungta!", + "copyId": "Kopijuoti vartotojo Id", + "idCopiedMessage": "Vartotojo Id nukopiuotas į iškarpinę", + "user-list": "Vartotojų sąrašas", + "user-list-required": "Vartotojų sąrašas būtinas" + }, + "value": { + "type": "Reikšmės tipas", + "string": "Tekstas", + "string-value": "Tekstinė informacija", + "string-value-required": "Tekstinė informacija būtina", + "integer": "Sveikasis skaičius", + "integer-value": "Sveikojo skaičiaus reikšmė", + "integer-value-required": "Sveikojo skaičiaus reikšmė būtina", + "invalid-integer-value": "Sveikojo skaičiaus reikšmė neteisinga", + "double": "Realusis skaičius", + "double-value": "Realiojo skaičiaus reikšmė", + "double-value-required": "Realiojo skaičiaus reikšmė būtina", + "boolean": "Loginis", + "boolean-value": "Loginė reikšmė", + "false": "Netiesa", + "true": "Tiesa", + "long": "Sveikas skaičius", + "json": "JSON", + "json-value": "JSON reikšmė", + "json-value-invalid": "Neteisingas JSON reikšmės formatas", + "json-value-required": "JSON reikšmė būtina." + }, + "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", + "export-group-entities": "Export group entities", + "export-roles": "Export roles", + "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", + "load-group-entities": "Load group entities", + "load-roles": "Load roles", + "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", + "groups-created": "{ created, plural, =1 {1 group} other {# groups} } created", + "groups-updated": "{ updated, plural, =1 {1 group} other {# groups} } updated", + "groups-deleted": "{ deleted, plural, =1 {1 group} other {# groups} } 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}}.", + "add-entity-groups": "Add entity groups", + "entity-groups": "Entity groups", + "integration-routing-key-conflict": "Failed to load the integration with external id {{entityId}}
due to the same integration key is already present in the database for another integration.
Please consider enabling the auto-generate integration key setting in the restore form.", + "auto-generate-integration-key": "Auto-generate integration key", + "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": "Valdiklių galerija", + "widget-bundle": "Valdiklių rinkinys", + "all-bundles": "Visi rinkiniai", + "select-widgets-bundle": "Pasirinkti valdiklių rinkinį", + "widgets": "Valdikliai", + "all-widgets": "Visi valdikliai", + "widget": "Valdiklis", + "select-widget": "Pasirinkti valdiklį", + "no-widgets-matching": "Valdiklių atitinkančių '{{entity}}' nėra.", + "no-widgets": "Valdiklių dar nėra", + "no-widgets-text": "Valdiklių nėra", + "management": "Valdiklių valdymas", + "editor": "Valdiklių redaktorius", + "widget-type-not-found": "Įkeliant valdiklio konfigūraciją kilo klaidų.
Gali būti, kad\n toks valdiklio tipas panaikintas.", + "widget-type-load-error": "Valdiklis neįkeltas, nes iškilo klaidų:", + "remove": "Panaikinti valdiklį", + "delete": "Panaikinti valdiklį", + "edit": "Redaguoti valdiklį", + "edit-widget-type": "Redaguoti valdikliį", + "remove-widget-title": "Ar tikrai norite panaikinti valdiklį '{{widgetTitle}}'?", + "remove-widget-text": "Būkite dėmesingi, po patvirtinimo valdiklis ir visa su juo susijusi informacija bus pašalinta.", + "timeseries": "Telemetrija", + "search-data": "Duomenų paieška", + "no-data-found": "Duomenų nėra", + "latest": "Naujausios reikšmės", + "rpc": "Valdymo valdiklis", + "alarm": "Įspėjimų valdiklis", + "static": "Statinis valdiklis", + "select-widget-type": "Pasirinkite valdiklio tipą", + "missing-widget-title-error": "Valdiklio pavadinimas būtinas!", + "widget-saved": "Valdiklis išsaugotas", + "unable-to-save-widget-error": "Valdiklio išsaugoti negalima! Valdiklyje yra klaidų!", + "save": "Valdiklis išsaugotas", + "saveAs": "valdiklį išsaugoti kaip", + "move": "Perkelti valdiklį", + "save-widget-type-as": "Valdiklį išsaugoti kaip", + "save-widget-type-as-text": "Įrašykite naują valdiklio pavadinimą", + "toggle-fullscreen": "Perjungti į viso ekrano režimą", + "run": "Paleisti valdiklį", + "widget-title": "Pavadinimas", + "title": "Pavadinimas", + "title-required": "Valdiklio pavadinimas būtinas.", + "title-max-length": "Pavadinimas negali viršyti 256 simbolių", + "system": "Sisteminis", + "type": "Tipas", + "resources": "Resursai", + "resource-url": "JavaScript/CSS URL", + "resource-is-module": "Is module", + "remove-resource": "Panaikinti resursą", + "add-resource": "Pridėti resursą", + "html": "HTML", + "tidy": "Tidy", + "css": "CSS", + "settings-schema": "Nustatymų schema", + "datakey-settings-schema": "Duomenų raktų nustatymų schema", + "latest-datakey-settings-schema": "Latest data key settings schema", + "widget-settings": "Valdiklio nustatymai", + "description": "Aprašymas", + "image-preview": "Paveiksliuko peržiūra", + "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", + "all": "All", + "actual": "Actual", + "deprecated": "Pasenęs", + "has-basic-mode": "Has basic mode", + "basic-mode-form-selector": "Basic mode form selector", + "basic-mode": "Basic", + "advanced-mode": "Advanced", + "javascript": "Javascript", + "js": "JS", + "remove-widget-type-title": "Ar tikrai norite pašalinti valdiklį '{{widgetName}}'?", + "remove-widget-type-text": "Būkite dėmesingi, po patvirtinimo valdiklis ir visa su juo susijusi informacija bus pašalinta.", + "remove-widget-type": "Panaikinti valdiklį", + "widget-types": "Valdikliai", + "delete-widget-type-title": "Ar tikrai norite panaikinti valdiklį '{{widgetTypeName}}'?", + "delete-widget-type-text": "Būkite dėmesingi, po patvirtinimo valdiklis ir visa su juo susijusi informacija bus pašalinta.", + "delete-widget-types-title": "Ar tikrai norite panaikinti { count, plural, =1 {1 valdiklį} other {# valdiklius} }?", + "delete-widget-types-text": "Būkite dėmesingi, po patvirtinimo visi pasirinkti valdikliai ir su jais susijusi informacija bus pašalinta.", + "delete-widget-type": "Panaikinti valdiklį", + "add-widget-type": "Pridėti naują valdiklį", + "widget-type-load-failed-error": "Valdiklio įkelti nepavyko!", + "widget-template-load-failed-error": "Valdiklio šablono įkelti nepavyko!", + "details": "Informacija", + "widget-type-details": "Informacija apie valdiklį", + "add": "Pridėti valdiklį", + "no-widget-types-text": "Valdiklių nėra", + "search-widget-types": "Valdiklių paieška", + "selected-widget-types": "Pasirinkta { count, plural, =1 {1 valdiklis} other {# valdikliai} }", + "undo": "Atšaukti valdiklio pakeitimus", + "export": "Eksportuoti valdiklį", + "export-data": "Eksportuoti valdiklio duomenis", + "export-to-csv": "Eksportuoti valdiklio duomenis į CSV...", + "export-to-excel": "Eksportuoti valdiklio duomenis į XLS...", + "export-to-excel-xlsx": "Eksportuoti valdiklio duomenis į XLSX...", + "no-data": "Duomenų atvaizdavimui nėra", + "data-overflow": "Valdiklyje rodoma {{count}} iš {{total}} įrašų", + "alarm-data-overflow": "Valdiklyje rodomi {{allowedEntities}} (didižiausias leistinas skaičius) įspėjimų įrašai iš {{totalEntities}}", + "search": "Valdiklių paieška", + "filter": "Valdiklių filtro tipas", + "loading-widgets": "Valdikliai įkeliami...", + "widget-template-error": "Netinkamas valdiklio HTML šablonas." + }, + "widget-action": { + "header-button": "Valdiklio antraštės mygtukas", + "open-dashboard-state": "Eiti į naują skydelio būseną", + "update-dashboard-state": "Atnaujinti dabartinę skydelio būseną", + "open-dashboard": "Eiti į kitą skydelio būseną", + "custom": "Aprašomas veiksmas", + "custom-pretty": "Aprašomas veiksmas (su HTML šablonu)", + "custom-pretty-error-title": "Custom dialog error", + "custom-pretty-template-error": "Invalid custom dialog template.", + "custom-pretty-controller-error": "Error occurred while evaluating custom dialog function.", + "mobile-action": "Mobile action", + "target-dashboard-state": "Tikslinė skydelio būsena", + "target-dashboard-state-required": "Tikslnė skydelio būsena būtina", + "set-entity-from-widget": "Nustatyti subjektą iš valdiklio", + "target-dashboard": "Tikslinis skydelis", + "open-right-layout": "Atidarykiti tinkamą skydelio išdėstymą (vaizdas mobiliesiems)", + "state-display-type": "Skydelio būsenos rodymo parinktis", + "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": "Dabartinis rinkinys", + "widgets-bundles": "Valdiklių rinkiniai", + "widgets-bundle-widgets": "Valdiklių rinkinio valdikliai", + "add": "Pridėti valdiklių rinkinį", + "delete": "Panaikinti valdiklių rinkinį", + "title": "Pavadinimas", + "title-required": "Pavadinimas būtinas.", + "title-max-length": "Pavadinimas negali viršyti 256 simbolių", + "description": "Aprašymas", + "image-preview": "Paveiksliuko peržiūra", + "add-widgets-bundle-text": "Pridėti naują valdiklių rinkinį", + "no-widgets-bundles-text": "Valdiklių rinkinių nėra", + "empty": "Valdiklių rinkinys tuščias", + "details": "Informacija", + "widgets-bundle-details": "Informacija apie valdiklių rinkinį", + "delete-widgets-bundle-title": "Ar tikrai norite panaikinti valdiklių rinkinį '{{widgetsBundleTitle}}'?", + "delete-widgets-bundle-text": "Būkite dėmesingi, po patvirtinimo valdiklių rinkinys ir visa su juo susijusi informacija bus panaikinta.", + "delete-widgets-bundles-title": "Ar tikrai norite panaikinti { count, plural, =1 {1 valdiklių rinkinį} other {# valdiklių rinkinius} }?", + "delete-widgets-bundles-action-title": "Panaikinti { count, plural, =1 {1 valdiklių rinkinį} other {# valdiklių rinknius} }", + "delete-widgets-bundles-text": "Būkite dėmesingi, po patvirtinimo visi pasirinkti valdiklių rinkiniai ir su jais susijusi informacija bus panaikinta.", + "no-widgets-bundles-matching": "Valdiklių rinkinių, atitinkančių '{{widgetsBundle}}' nėra.", + "widgets-bundle-required": "Valdiklių rinkinys būtinas.", + "system": "Sisteminis", + "import": "Importuoti valdiklių rinknį", + "export": "Eksportuoti valdiklių rinkinį", + "export-widgets-bundle-widgets-prompt": "Valdiklių rinkinių tipus įtraukti į eksportuotus duomenis (kitu atveju bus eksportuojami tik nurodytų valdiklių tipų FQN)", + "export-failed-error": "Valdiklių rinkinio eksportuoti nepavyko: {{error}}", + "create-new-widgets-bundle": "Sukurti naują valdiklių rinkinį", + "widgets-bundle-file": "Valdiklių rinkinio failas", + "invalid-widgets-bundle-file-error": "Valdiklių rinkinio importuoti nepavyko: neteisinga duomenš struktūra.", + "search": "Valdiklių rinkinių paieška", + "selected-widgets-bundles": "Pasirinkta { count, plural, =1 {1 valdiklių rinkinys} other {# valdiklių rinkiniai} }", + "open-widgets-bundle": "Atverti valdiklių rinkinį", + "loading-widgets-bundles": "Įkeliamas valdiklių rinkinys..." + }, + "widget-config": { + "data": "Duomenys", + "settings": "Nustatymai", + "advanced": "Išplėstiniai nustatymai", + "appearance": "Išvaizda", + "widget-card": "Valdiklio kortelė", + "mobile": "Mobilusis vaizdas", + "title": "Pavadinimas", + "title-tooltip": "Pavadinimo paaiškinimas", + "general-settings": "Pagrindiniai nustatymai", + "display-title": "Rodyti valdiklio pavadinimą", + "card-title": "Kortelės pavadinimas", + "drop-shadow": "Šešėlis", + "enable-fullscreen": "Įjungti rodymą per visą ekraną", + "enable-data-export": "Įjungti duomenų eksportą", + "data-export": "Duomenų eksportas", + "background-color": "Fono spalva", + "text-color": "Teksto spalva", + "border-radius": "Rėmelio storis", + "padding": "Padding", + "margin": "Margin", + "widget-style": "Valdiklio stilius", + "widget-css": "Valdiklio CSS", + "title-style": "Antraštės stilius", + "mobile-mode-settings": "Mobiliojo vaizdo režimas", + "order": "Rikiavimo tvarka", + "height": "Aukštis", + "mobile-hide": "Valdiklį paslėpti mobiliojo vaizdo režime", + "desktop-hide": "Slėpti valdiklį darbalaukio režime", + "units": "Papildomas simbolis šalia reikšmės", + "units-by-default": "Numatytieji matavimo vienetai", + "decimals": "Skaimenys po kablelio", + "decimals-by-default": "Dešimtainiai skaičiai pagal numatytuosius nustatymus", + "default-data-key-parameter-hint": "Šis parametras taikomas visoms valdiklių reikšmėms, nebent jis nustatomas duomenų rakto konfigūracijoje", + "units-short": "Vienetai", + "decimals-short": "Dešimtainiai skaičiai", + "decimals-suffix": "dešimtainiai skaičiai", + "timewindow": "Laikotarpio langas", + "use-dashboard-timewindow": "Naudoti skydelio laikotarpio langą", + "use-widget-timewindow": "Naudoti valdiklio laikotarpio langą", + "display-timewindow": "Rodyti laikotarpio langą", + "legend": "Legenda", + "display-legend": "Rodyti legendą", + "datasources": "Duomenų šaltiniai", + "datasource": "Duomenų šaltinis", + "maximum-datasources": "Daugiausiai leidžiama { count, plural, =1 {1 vienas duomenų šaltinis.} other {# duomenų šaltiniai} }", + "timeseries-key-error": "Reikia nurodyti nors vieną telemetrijos rodiklį", + "datasource-type": "Tipas", + "datasource-parameters": "Parametrai", + "remove-datasource": "Pašalinti duomenų šaltinį", + "add-datasource": "Pridėti duomenų šaltinį", + "target-device": "Tikslinis įrenginys", + "alarm-source": "Įspėjimo šaltinis", + "actions": "Veiksmai", + "action": "Veiksmas", + "add-action": "Pridėti veiksmą", + "search-actions": "Veiksmų paieška", + "no-actions-text": "Veiksmų nėra", + "action-source": "Veiksmo šaltinis", + "action-source-required": "Veiksmo šaltinis būtinas.", + "action-name": "Pavadinimas", + "action-name-required": "Veiksmo pavadinimas būtinas.", + "action-name-not-unique": "Veiksmas su tokiu pavadinimu jau yra.
Šaltinyje veiksmų pavadinimai kartotis negali.", + "action-icon": "Piktograma", + "show-hide-action-using-function": "Rodyti/slėpti veiksmą funkcijos pagalba", + "action-type": "Tipas", + "action-type-required": "Veiksmo tipas būtinas.", + "edit-action": "Redaguoti veiksmą", + "delete-action": "Panaikinti veiksmą", + "delete-action-title": "Panaikinti valdiklio veiksmą", + "delete-action-text": "Ar tikrai norite panaikinti valdiklio veiksmą '{{actionName}}'?", + "title-icon": "Antraštės piktograma", + "display-icon": "Rodyti antraštės piktoramą", + "card-icon": "Kortelės piktograma", + "icon-color": "Piktogramos spalva", + "icon-size": "Piktogramos dydis", + "advanced-settings": "Išplėstiniai nustatymai", + "data-settings": "Duomenų nustatymai", + "limits": "Ribos", + "no-data-display-message": "\"Nėra duomenų\" alternatyvus tekstas", + "data-page-size": "Maximum entities per datasource", + "settings-component-not-found": "Settings form component not found for selector '{{selector}}'", + "preview": "Preview", + "set": "Set", + "set-message": "Set message", + "advanced-title-style": "Advanced title style", + "card-style": "Card style", + "text": "Text", + "background": "Background", + "advanced-widget-style": "Advanced widget style", + "card-buttons": "Card buttons", + "show-card-buttons": "Show card buttons", + "card-border-radius": "Card border radius", + "card-appearance": "Card appearance", + "color": "Color" + }, + "widget-type": { + "import": "Importuoti valdiklį", + "export": "Eksportuoti valdiklį", + "export-widget-types": "Eksportuoti valdiklius", + "export-failed-error": "Valdiklio eksportuoti nepavyko: {{error}}", + "create-new-widget-type": "Sukurt naują valdiklį", + "widget-type-file": "Valdiklių failas", + "invalid-widget-type-file-error": "Nepavyko importuoti valdiklio: neteisinga valdiklio duomenų struktūra." + }, + "self-registration": { + "self-registration": "Self Registration", + "self-registration-url": "Self Registration URL", + "captcha-version": "reCAPTCHA version", + "captcha-action": "reCAPTCHA log action name", + "captcha-site-key": "reCAPTCHA site key", + "captcha-secret-key": "reCAPTCHA secret key", + "notification-email": "El. pašto adresas pranešimų siuntimui", + "notification-email-invalid": "Neteisingas el. pašto adreso formatas.", + "notification-email-required": "El. pašto adresas pranešimų siuntimui būtinas.", + "privacy-policy-text": "Privatumo politikos tekstas", + "terms-of-use-text": "Terms of Use text", + "text-message-page": "Tekstinė žinutė registracijos puslapiui", + "enable-mobile-self-registration": "Enable self registration from mobile application", + "mobile-self-registration-title": "Mobile application self registration settings", + "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-required": "Application package is required", + "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", + "mobile-app-scheme": "Application URL scheme", + "mobile-app-scheme-placeholder": "Ex.: customscheme", + "mobile-app-scheme-required": "Application URL scheme is required", + "mobile-app-host": "Application URL hostname", + "mobile-app-host-placeholder": "Ex.: my.app.host", + "mobile-app-host-required": "Application URL hostname is required", + "show-privacy-policy": "Show Privacy Policy", + "show-terms-of-use": "Show Terms of Use", + "domain-settings": "Domain settings", + "general-settings": "General settings" + }, + "solution-template": { + "solution-template": "Solution template", + "solution-templates": "Solution templates", + "management": "Manage solution templates", + "details": "Details", + "install": "Install", + "level": "Level", + "install-title": "Solution template successfully installed", + "install-failed-title": "Solution template installation failed", + "instructions": "Instructions", + "goto-main-dashboard": "Goto main dashboard", + "delete": "Delete", + "delete-solution-title": "Are you sure you want to delete the solution '{{solutionTitle}}'?", + "delete-solution-text": "Be careful, after the confirmation the solution and all related data will become unrecoverable.", + "installing": "Installing solution template..." + }, + "markdown": { + "edit": "Edit", + "preview": "Preview", + "copy-code": "Click to copy", + "copied": "Copied!" + }, + "white-labeling": { + "white-labeling": "Tinkinimas", + "white-labeling-general": "General White Labeling", + "login-white-labeling": "Prisijungimo puslapio tinkinimas", + "general": "General", + "login": "Prisijungti", + "preview": "Peržiūra", + "app-title": "Aplikaciijios pavadinimas", + "favicon": "Aplikacijos piktograma", + "favicon-description": "*.ico, *.gif ar *.png paveiksliukas, kurio maksimalus dydis {{kbSize}} kilobaitai.", + "favicon-size-error": "Paveiksliukas per didelis. Maksimalus leistinas dydis yra {{kbSize}} kilobaitai.", + "favicon-type-error": "Netinkamas paveiksliuko formatas. Leidžiami tik ICO, GIF ar PNG formatai.", + "drop-favicon-image": "Užvilkite svetainės piktogramos paveiksliuką arba spustelėkite, kad pasirinktumėte failą, kurį norite įkelti.", + "drop-favicon-image-or": "Drag and drop a website icon image or", + "no-favicon-image": "Piktograma nepasirinkta", + "logo": "Logo", + "logo-description": "Bet koks paveiksliukas, kurio dydis neviršija {{kbSize}} kilobaitų.", + "logo-size-error": "Logotipas per didelis. Maksimalus leistinas dydis yra {{kbSize}} kilobaitai.", + "logo-type-error": "Netinkamas logotipo failo. Only images are accepted.", + "drop-logo-image": "Užvilkite svetainės logotipo paveiksliuką arba spustelėkite, kad pasirinktumėte failą, kurį norite įkelti.", + "drop-logo-image-or": "Drag and drop a logo image or", + "no-logo-image": "Logotipas nepasirinktas", + "logo-height": "Logotipo aukštis, px", + "primary-palette": "Pirminė paletė", + "accent-palette": "Akcentų paletė", + "customize-palette": "Pritaikyti", + "advanced-css": "Advanced CSS", + "edit-palette": "Redaguoti paletę", + "save-palette": "Išsaugoti paletę", + "primary-background": "Pirminis fonas", + "secondary-background": "Antrinis fonas", + "hue1": "HUE 1", + "hue2": "HUE 2", + "hue3": "HUE 3", + "page-background-color": "Puslapio fono spalva", + "dark-foreground": "Tamsi tema", + "domain-name": "Domeno pavadinimas", + "base-url": "Bazinis 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", + "help-link-base-url": "Bazinis pagalbos puslapio url", + "ui-help-base-url": "UI help base url", + "enable-help-links": "Įjungti pagalbos nuorodas", + "error-verification-url": "Domeno pavadinime negali būti '/' ir ':' simbolių. Pavyzdžiui: thingsboard.io", + "show-platform-name-version": "Rodyti platformos pavadinimą ir versiją", + "platform-name": "Platformas pavadinimas", + "platform-version": "Platformos versija", + "version-mask": "{{name}} v.{{version}}", + "position": { + "label": "Platformos pavadinimo ir versijos išdėstymas", + "under-logo": "Po logotipu", + "bottom": "Prisijungimo formos apačioje" + } + }, + "widgets": { + "background": { + "background": "Background", + "background-settings": "Background settings", + "background-type-image": "Upload image", + "background-type-image-url": "Image URL", + "background-type-color": "Solid color", + "image-url": "Image URL", + "overlay": "Overlay", + "enable-overlay": "Enable overlay", + "blur": "Blur", + "preview": "Preview" + }, + "battery-level": { + "layout": "Layout", + "layout-vertical-solid": "Vertical. Solid", + "layout-horizontal-solid": "Horizontal. Solid", + "layout-vertical-divided": "Vertical. Divided", + "layout-horizontal-divided": "Horizontal. Divided", + "icon": "Icon", + "value": "Value", + "auto-scale": "Auto scale", + "battery-level-color": "Battery level color", + "battery-shape-color": "Battery shape color", + "battery-level-card-style": "Battery level card style" + }, + "chart": { + "common-settings": "Common settings", + "enable-stacking-mode": "Enable stacking mode", + "selection": "Time range selection", + "enable-selection-mode": "Enable selection 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": "Default font", + "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", + "tooltip": "Tooltip", + "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", + "fill-line-opacity": "Fill opacity", + "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 threshold", + "show-values-for-comparison": "Show historical values for comparison", + "comparison-values-label": "Historical values label", + "comparison-line-color": "Comparison line color", + "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", + "common-pie-settings-range-error": "Value should be in range from 0 to 1", + "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", + "series": "Series", + "add-series": "Add series", + "series-settings": "Series settings", + "remove-series": "Remove series", + "no-series": "No series configured", + "no-series-error": "At least one series should be specified", + "chart-appearance": "Chart appearance", + "vertical-grid-lines": "Vertical grid lines", + "horizontal-grid-lines": "Horizontal grid lines", + "chart-background": "Chart background", + "grid-lines-color": "Grid lines color", + "border": "Border", + "axis": "Axis", + "vertical-axis": "Vertical axis", + "ticks": "Ticks", + "horizontal-axis": "Horizontal axis" + }, + "color": { + "color-settings": "Color settings", + "color-type-constant": "Constant", + "color-type-range": "Range", + "color-type-function": "Function", + "color": "Color", + "value-range": "Value range", + "from": "From", + "to": "To", + "color-function": "Color function" + }, + "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...", + "show-connector": "Show for the connector", + "connector-state-param-key": "Connector state parameter key", + "status": "Status", + "message": "Message", + "created-time": "Created time" + }, + "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", + "relations-settings": "Relations settings", + "relate-device-to-state-entity": "Relate device to current state entity", + "relate-direction": "Relate direction", + "relate-direction-from": "From", + "relate-direction-to": "To", + "relation-type": "Relation type", + "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", + "layout": "Layout", + "row-gap": "Gap between rows in pixels", + "column-gap": "Gap between columns in pixels", + "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-json": "JSON", + "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", + "datakey-value-type-color": "Color", + "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)", + "field-appearance": "Field appearance", + "appearance-fill": "Fill", + "appearance-outline": "Outline", + "subscript-sizing": "Subscript sizing", + "subscript-sizing-fixed": "Fixed", + "subscript-sizing-dynamic": "Dynamic", + "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", + "invalid-JSON-error-message": "'Invalid JSON' error message", + "icon-settings": "Icon settings", + "dialog-editor-settings": "Dialog editor 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", + "json-invalid": "JSON value has an invalid format", + "title": "Title", + "cancel-button-label": "'Cancel' button label" + }, + "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", + "here-api-key": "HERE API key", + "here-use-new-version-api-3": "Use API version 3", + "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.')", + "apply-default-markdown-style": "Apply default markdown style", + "markdown-css": "Markdown/HTML CSS" + }, + "simple-card": { + "label": "Label", + "label-position": "Label position", + "label-position-left": "Left", + "label-position-top": "Top" + }, + "value-card": { + "layout": "Layout", + "layout-square": "Square", + "layout-vertical": "Vertical", + "layout-centered": "Centered", + "layout-simplified": "Simplified", + "layout-horizontal": "Horizontal", + "layout-horizontal-reversed": "Horizontal reversed", + "label": "Label", + "icon": "Icon", + "value": "Value", + "date": "Date", + "value-card-style": "Value card style" + }, + "aggregated-value-card": { + "subtitle": "Subtitle", + "chart": "Chart", + "values": "Values", + "value-appearance": "Value appearance", + "position": "Position", + "position-center": "Center", + "position-right-top": "Right top", + "position-right-bottom": "Right bottom", + "position-left-top": "Left top", + "position-left-bottom": "Left bottom", + "font": "Font", + "color": "Color", + "arrow": "Arrow", + "display-up-down-arrow": "Display Up/Down arrow", + "add-value": "Add value", + "remove-value": "Remove value", + "no-values": "No values configured", + "aggregation": "Aggregation", + "aggregated-value-card-style": "Aggregated value card style" + }, + "alarm-count": { + "alarm-count-card-style": "Alarm count card style" + }, + "entity-count": { + "entity-count-card-style": "Entity count card style" + }, + "count": { + "layout": "Layout", + "layout-column": "Column", + "layout-row": "Row", + "label": "Label", + "icon": "Icon", + "icon-background": "Icon background", + "value": "Value", + "chevron": "Chevron" + }, + "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", + "column-export-option": "Include column in export", + "column-export-option-always": "Always", + "column-export-option-only-visible": "Only if column visible", + "column-export-option-never": "Never", + "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", + "display-alarm-activity": "Display alarm activity", + "allow-alarms-assign": "Allow alarms assignment", + "blob-entities-table-title": "Blob entities table title", + "display-created-time": "Display created time column", + "display-type": "Display type column", + "display-customer": "Display customer column", + "no-data-display-message": "\"No data to display\" alternative message", + "force-default-blob-entity-type": "Force default blob entity type", + "scheduler-events-table-title": "Scheduler events table title", + "force-default-event-type": "Force default event type", + "columns": "Columns", + "column-settings": "Column settings", + "remove-column": "Remove column", + "add-column": "Add column", + "no-columns": "No columns configured", + "columns-to-display": "Columns to display", + "table-header": "Table header", + "header-buttons": "Header buttons", + "table-buttons": "Table buttons", + "pagination": "Pagination", + "rows": "Rows", + "timeseries-column-error": "At least one timeseries column should be specified", + "table-tabs": "Table tabs", + "show-cell-actions-menu-mobile": "Show cell actions dropdown menu in mobile mode" + }, + "scheduler": { + "enabled-scheduler-event-views": "Enabled scheduler events views", + "enabled-scheduler-event-views-both": "Both", + "enabled-scheduler-event-views-list": "List View Only", + "enabled-scheduler-event-views-calendar": "Calendar View Only", + "display-name": "Display name", + "type-name": "Type name", + "display-originator-select": "Display originator entity select", + "display-message-type-select": "Display message type select", + "display-metadata-table": "Display message metadata table", + "configuration-html-template": "Configuration HTML template", + "custom-event-types": "Custom event types", + "no-custom-event-types": "No custom event types configured", + "add-custom-event-type": "Add custom event type" + }, + "value-source": { + "value-source": "Value source", + "predefined-value": "Predefined value", + "entity-attribute": "Entity attribute", + "value": "Value", + "source-entity-alias": "Source entity alias", + "source-entity-attribute": "Source entity attribute" + }, + "widget-font": { + "font-settings": "Font settings", + "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", + "preview": "Preview", + "line-height": "Line height", + "auto": "Auto" + }, + "home": { + "no-data-available": "No data available" + }, + "system-info": { + "cpu": "CPU", + "ram": "RAM", + "disk": "Disk", + "cpu-warning-text": "Running high on CPU usage. To avoid system failure, optimize system performance.", + "cpu-critical-text": "Critically high CPU usage. To avoid system failure, optimize system performance.", + "ram-warning-text": "Running low on reserve of RAM. To avoid system failure, optimize system performance or increase the size of RAM.", + "ram-critical-text": "Critically low reserve of RAM. To avoid system failure, optimize system performance or increase the size of RAM.", + "disk-warning-text": "Running low on disk space. To avoid data loss, free up or expand the disk space.", + "disk-critical-text": "Critically low disk space. To avoid data loss, free up or expand the disk space." + }, + "cluster-info": { + "service-id": "Service id", + "service-type": "Service type", + "no-data": "No data" + }, + "transport-messages": { + "title": "Transport messages", + "info": "All the messages that came from devices" + }, + "activity": { + "title": "Activity" + }, + "documentation": { + "title": "Documentation", + "add-link": "Add link", + "add-link-title": "Add documentation link", + "name": "Name", + "name-required": "Name is required.", + "link": "Link", + "link-required": "Link is required.", + "columns": "Columns" + }, + "quick-links": { + "title": "Quick links", + "add-link": "Add link", + "add-link-title": "Add quick link", + "quick-link": "Quick link", + "quick-link-required": "Quick link is required.", + "no-links-matching": "No links matching '{{name}}' were found.", + "columns": "Columns" + }, + "recent-dashboards": { + "title": "Dashboards", + "last": "Last viewed", + "starred": "Starred", + "name": "Name", + "last-viewed": "Last viewed", + "no-last-viewed-dashboards": "No last viewed dashboards yet" + }, + "configured-features": { + "title": "Configured features", + "info": "Status of features that require configuration", + "white-labeling-feature": "White labeling", + "email-feature": "Email", + "sms-feature": "SMS", + "slack-feature": "Slack", + "oauth2-feature": "OAuth 2", + "2fa-feature": "2FA", + "feature-configured": "Feature is configured.\nClick to setup", + "feature-not-configured": "Feature is not configured.\nClick to setup" + }, + "version-info": { + "title": "Version", + "contact-us": "Contact us", + "current-version": "Current version", + "current": "Current", + "available-version": "Available version", + "available": "Available", + "upgrade": "Upgrade", + "version-is-up-to-date": "Version is up to date" + }, + "license-info": { + "title": "License info", + "view-all": "View all", + "license-portal": "License portal", + "devices-info": "{count} / { max, plural, =0 {∞} other {#} } Devices", + "assets-info": "{count} / { max, plural, =0 {∞} other {#} } Assets", + "dashboards-info": "{count} / { max, plural, =0 {∞} other {#} } Dashboards", + "integrations-info": "{count} / { max, plural, =0 {∞} other {#} } Integrations", + "community-support": "Community support", + "white-labeling": "White labeling", + "unlimited-datapoints-and-messages": "Unlimited datapoints and messages", + "unlimited-api-calls": "Unlimited API calls" + }, + "usage-info": { + "title": "Usage", + "entities": "Entities", + "api-calls": "API calls" + }, + "functions": { + "title": "Functions", + "pe-feature-tooltip": "Only on ThingsBoard\nProfessional Edition", + "switch-to-pe": "Switch to PE", + "alarms": "Alarms", + "dashboards": "Dashboards", + "entities-and-relations": "Entities & Relations", + "profiles": "Profiles", + "advanced-features": "Advanced features", + "notification-center": "Notification center", + "api-usage": "API usage", + "customers": "Customers", + "customers-hierarchy": "Customers hierarchy", + "roles-and-permissions": "Roles & Permissions", + "groups": "Groups", + "integrations": "Integrations", + "solution-templates": "Solution templates", + "scheduler": "Scheduler", + "white-labeling": "White-labeling" + }, + "devices": { + "view-docs": "View docs", + "inactive": "Inactive", + "active": "Active", + "total": "Total" + }, + "alarms": { + "critical": "Critical", + "assigned-to-me": "Assigned to me", + "total": "Total" + }, + "solution-templates": { + "title": "Solution templates", + "prototype-plan": "Prototype plan", + "startup-plan": "Startup plan" + }, + "getting-started": { + "get-started": "Get started", + "finish": "Finish", + "done-welcome-title": "Welcome on board", + "done-welcome-text": "You did great with it!", + "sys-admin": { + "step1": { + "title": "Create Tenant & Tenant Administrator", + "content": "

A tenant is an individual or an organization that owns or produces devices and assets. The tenant may have multiple tenant administrator users, customers, devices, and assets.

The Tenant Administrator can create and manage devices, assets, customers, and dashboards within the tenant account.

Follow the documentation on how to do it:

", + "how-to-create-tenant": "How to create Tenant & Tenant Administrator" + }, + "step2": { + "title": "Configure feature: Mail server", + "content": "

Mail server configuration is essential for user activation, password recovery, and alarm notification delivery.

Follow the documentation on how to do it:

", + "how-to-configure-mail-server": "How to configure Mail server" + }, + "step3": { + "title": "Configure feature: SMS provider", + "content": "

Configure SMS providers to notify the customers about the alarms via SMS.

Follow the documentation on how to do it:

", + "how-to-configure-sms-provider": "How to configure SMS provider" + }, + "step4": { + "title": "Configure feature: White-labeling", + "content": "

Easily customize your company's or product's logo and color scheme without coding and without restarting the service.

Follow the documentation on how to do it:

", + "how-to-configure-white-labeling": "How to configure White-labeling" + }, + "step5": { + "title": "Configure feature: 2FA", + "content": "

Improve security of the platform accounts with two-factor authentication.

Follow the documentation on how to do it:

", + "how-to-configure-2fa": "How to configure 2FA" + }, + "step6": { + "title": "Configure feature: OAuth 2", + "content": "

Simplify login for the tenant and customer users with Single Sign-On functionality via OAuth 2.0.

Follow the documentation on how to do it:

", + "how-to-configure-oauth2": "How to configure OAuth 2" + }, + "step7": { + "title": "Configure feature: Slack", + "content": "

Distribute notifications to the tenant and customer users via Slack according to the notification rules you set.

Follow the documentation on how to do it:

", + "how-to-configure-notifications": "How to configure Slack" + } + }, + "tenant-admin": { + "step1": { + "title": "Create device", + "content": "

Let's provision your first device to the platform via UI. Follow the documentation on how to do it:

", + "how-to-create-device": "How to create Device" + }, + "step2": { + "title": "Connect device", + "content-before": "

To connect the device you need to get the device credentials. We recommend using default auto-generated credentials which is access token for this guide.

  • Go to device table
  • Click on the device row to open device details
  • Press the button \"Copy access token\"

Use simple commands to publish data over HTTP. Don't forget to replace $ACCESS_TOKEN with your device access token:

", + "ubuntu": { + "install-curl": "Install cURL for Ubuntu:" + }, + "macos": { + "install-curl": "Install cURL for MacOS:" + }, + "windows": { + "install-curl": "Starting Windows 10 b17063, cURL is available by default." + }, + "replace-access-token": "Replace $ACCESS_TOKEN with your device's token:", + "content-after": "

You can also use other protocols such as MQTT, CoAP, etc.

Follow the documentation on how to do it:

", + "how-to-connect-device": "How to connect Device" + }, + "step3": { + "title": "Create dashboard", + "content": "

Create a dashboard to visualize data from entities such as assets, devices, etc.

Follow the documentation on how to do it:

", + "how-to-create-dashboard": "How to create Dashboard" + }, + "step4": { + "title": "Configure alarm rules", + "alarm-rules": "Alarm rules", + "content": "

Let's raise an alarm when the temperature reaches 25°C. Follow the documentation on how to do it:

", + "how-to-configure-alarm-rules": "How to configure Alarm rules" + }, + "step5": { + "title": "Create alarm", + "content-before": "

To trigger the alarm, send a new telemetry value of 26°C or higher.

", + "replace-access-token": "Replace $ACCESS_TOKEN with your device's token:", + "content-after": "

Follow the documentation on how to do it:

", + "how-to-create-alarm": "How to create Alarm" + }, + "step6": { + "title": "Create customer and share dashboard", + "content": "

By creating end-user dashboards, a customer user can only see his own devices, and data from another customer will be hidden.

Follow the documentation on how to do it:

", + "how-to-create-customer-and-share-dashboard": "How to create Customer and share Dashboard" + } + } + } + }, + "icon": { + "icon": "Icon", + "icons": "Icons", + "select-icon": "Select icon", + "material-icons": "Material icons", + "show-all": "Show all icons", + "search-icon": "Search icon", + "no-icons-found": "No icons found for '{{iconSearch}}'" + }, + "subscription": { + "entity-limit-text": "However you can upgrade your subscription plan in order to increase your limits.", + "upgrade-your-plan": "Upgrade subscription plan", + "white-labeling-feature": "White labeling feature", + "white-labeling-text-full": "Rebrand ThingsBoard platform web interface with your company or product logo and color scheme in 2 minutes.

Remove “Powered By” on the dashboards footer.
No coding or service restart required. Allow your customers to white-label their interface as well.", + "enable-white-labeling": "Enable white-labeling feature now by upgrading your subscription plan!", + "read-more": "Read more", + "white-labeling-video-text": "See video tutorial below to see how this feature works!" + }, + "subscription-error": { + "title": "Subscription violation", + "warning-title": "Subscription warning", + "upgrade-subscription-plan": "Please upgrade your subscription plan", + "upgrade-subscription-plan-to-install-solution-template": "In order to install {{solutionTemplateName}} solution you must upgrade your subscription at least to the {{planName}} plan!", + "limit-reached": { + "device-count": "You have reached maximum devices ({{value}}) allowed by your subscription plan!", + "asset-count": "You have reached maximum assets ({{value}}) allowed by your subscription plan!" + }, + "feature-disabled": { + "white-labeling": "White Labeling feature is not allowed by your subscription plan!" + } + }, + "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": "Telefono numeris tarptautiniu formatu , pvz. +37060123456" + }, + "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", + "card-click": "On card click" + } + }, + "paginator": { + "items-per-page": "Eilučių skaičius puslapyje:", + "first-page-label": "Pirmas puslapis", + "last-page-label": "Paskutinis puslapis", + "next-page-label": "Kitas puslapis", + "previous-page-label": "Ankstesnis puslapis", + "items-per-page-separator": "iš" + } +} + From 53301bf641e594a887e7ca1d6686eb3077271fca Mon Sep 17 00:00:00 2001 From: kalytka Date: Wed, 15 May 2024 14:52:59 +0300 Subject: [PATCH 50/84] Include bundle widget checkbox enabled by default --- .../import-export/export-widgets-bundle-dialog.component.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ui-ngx/src/app/shared/import-export/export-widgets-bundle-dialog.component.ts b/ui-ngx/src/app/shared/import-export/export-widgets-bundle-dialog.component.ts index 4794901f98..025a2ddf4c 100644 --- a/ui-ngx/src/app/shared/import-export/export-widgets-bundle-dialog.component.ts +++ b/ui-ngx/src/app/shared/import-export/export-widgets-bundle-dialog.component.ts @@ -22,6 +22,7 @@ import { FormControl } from '@angular/forms'; import { Router } from '@angular/router'; import { DialogComponent } from '@app/shared/components/dialog.component'; import { WidgetsBundle } from '@shared/models/widgets-bundle.model'; +import { isDefinedAndNotNull } from '@core/utils'; export interface ExportWidgetsBundleDialogData { widgetsBundle: WidgetsBundle; @@ -43,7 +44,7 @@ export class ExportWidgetsBundleDialogComponent extends DialogComponent, protected router: Router, @@ -51,7 +52,7 @@ export class ExportWidgetsBundleDialogComponent extends DialogComponent) { super(store, router, dialogRef); this.widgetsBundle = data.widgetsBundle; - if (data.includeBundleWidgetsInExport) { + if (isDefinedAndNotNull(data.includeBundleWidgetsInExport)) { this.exportWidgetsFormControl.patchValue(data.includeBundleWidgetsInExport, {emitEvent: false}); } } From 45ed484c2a3d5f930523a712b9e2e80f7b7e7f4f Mon Sep 17 00:00:00 2001 From: rusikv Date: Wed, 15 May 2024 17:26:36 +0300 Subject: [PATCH 51/84] UI: mobile app qr code improvements, cleanup --- .../json/system/widget_bundles/cards.json | 2 +- .../widget_bundles/home_page_widgets.json | 3 +- .../widget_types/home_mobile_app_qr_code.json | 27 ----- .../widget_types/mobile_app_qr_code.json | 4 +- ui-ngx/src/app/core/auth/auth.selectors.ts | 2 +- .../src/app/core/http/mobile-app.service.ts | 12 +-- .../cards/mobile-app-qr-code-widget.models.ts | 17 ++-- .../mobile-app-qrcode-widget.component.html | 6 +- .../mobile-app-qrcode-widget.component.scss | 23 ----- .../lib/mobile-app-qrcode-widget.component.ts | 71 ++++++++------ ...app-qr-code-widget-settings.component.html | 84 +++------------- ...app-qr-code-widget-settings.component.scss | 19 ---- ...e-app-qr-code-widget-settings.component.ts | 32 +----- .../admin/mobile-app-settings.component.html | 12 +-- .../admin/mobile-app-settings.component.scss | 4 - .../admin/mobile-app-settings.component.ts | 6 +- .../home-links/home-links-routing.module.ts | 98 ++++++++++--------- .../app/shared/models/mobile-app.models.ts | 5 +- .../assets/dashboard/sys_admin_home_page.json | 12 ++- .../dashboard/tenant_admin_home_page.json | 13 ++- .../assets/locale/locale.constant-en_US.json | 4 +- ui-ngx/src/form.scss | 6 ++ 22 files changed, 168 insertions(+), 294 deletions(-) delete mode 100644 application/src/main/data/json/system/widget_types/home_mobile_app_qr_code.json delete mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.scss diff --git a/application/src/main/data/json/system/widget_bundles/cards.json b/application/src/main/data/json/system/widget_bundles/cards.json index 5a839d4c82..e0f4f4c615 100644 --- a/application/src/main/data/json/system/widget_bundles/cards.json +++ b/application/src/main/data/json/system/widget_bundles/cards.json @@ -17,7 +17,7 @@ "cards.label_widget", "cards.dashboard_state_widget", "cards.qr_code", - "cards.mobile_app_qr_code", + "mobile_app_qr_code", "cards.attributes_card", "cards.html_card", "cards.html_value_card", diff --git a/application/src/main/data/json/system/widget_bundles/home_page_widgets.json b/application/src/main/data/json/system/widget_bundles/home_page_widgets.json index cb8d0ed915..a30d6ee76f 100644 --- a/application/src/main/data/json/system/widget_bundles/home_page_widgets.json +++ b/application/src/main/data/json/system/widget_bundles/home_page_widgets.json @@ -13,7 +13,6 @@ "home_page_widgets.quick_links", "home_page_widgets.documentation_links", "home_page_widgets.dashboards", - "home_page_widgets.usage_info", - "home_page_widgets.home_page_mobile_app_qr_code" + "home_page_widgets.usage_info" ] } \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_types/home_mobile_app_qr_code.json b/application/src/main/data/json/system/widget_types/home_mobile_app_qr_code.json deleted file mode 100644 index 908533b279..0000000000 --- a/application/src/main/data/json/system/widget_types/home_mobile_app_qr_code.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "fqn": "home_page_widgets.home_page_mobile_app_qr_code", - "name": "Home page mobile app QR code", - "deprecated": false, - "image": "tb-image:bW9iaWxlX2FwcF9xcl9jb2RlX3dpZGdldC5zdmc=:bW9iaWxlX2FwcF9xcl9jb2RlX3dpZGdldC5zdmc=;data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMDAiIGhlaWdodD0iMTYwIiBmaWxsPSJub25lIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE2MCIgZmlsbD0iI2ZmZiIgcng9IjQiLz48cmVjdCB3aWR0aD0iMTk5IiBoZWlnaHQ9IjE1OSIgeD0iLjUiIHk9Ii41IiBzdHJva2U9IiMwMDAiIHN0cm9rZS1vcGFjaXR5PSIuMSIgcng9IjMuNSIvPjxnIGNsaXAtcGF0aD0idXJsKCNhKSI+PHJlY3Qgd2lkdGg9IjEzNyIgaGVpZ2h0PSIxMzciIHg9IjMxLjUiIHk9IjExLjUiIGZpbGw9IiNmZmYiIHJ4PSI4LjIiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTY4LjUgMTEuNWgtMTM3djEzN2gxMzd2LTEzN1oiLz48bWFzayBpZD0iYiIgd2lkdGg9IjEyNCIgaGVpZ2h0PSIxMjQiIHg9IjM4IiB5PSIxOCIgbWFza1VuaXRzPSJ1c2VyU3BhY2VPblVzZSIgc3R5bGU9Im1hc2stdHlwZTpsdW1pbmFuY2UiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik00Mi43IDUyLjRoLTQuM3Y0LjJoNC4zdi00LjJaTTQyLjcgNjAuOWgtNC4zVjY1aDQuM1Y2MVpNNDIuNyA2NS4xaC00LjN2NC4zaDQuM1Y2NVpNNDIuNyA5NC45aC00LjNWOTloNC4zVjk1Wk00NyA1Ni42aC00LjNWNjFoNC4ydi00LjNaTTQ3IDgyLjFoLTQuM3Y0LjNoNC4yVjgyWk00NyA4Ni40aC00LjN2NC4yaDQuMnYtNC4yWk00NyA5NC45aC00LjNWOTloNC4yVjk1Wk00NyAxMDMuNGgtNC4zdjQuMmg0LjJ2LTQuMlpNNTEuMiA1Mi40aC00LjN2NC4yaDQuM3YtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik01MS4yIDYwLjloLTQuM1Y2NWg0LjNWNjFaTTUxLjIgNzMuNmgtNC4zVjc4aDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik01MS4yIDc3LjloLTQuM1Y4Mmg0LjNWNzhaTTUxLjIgODIuMWgtNC4zdjQuM2g0LjNWODJaTTUxLjIgOTAuNmgtNC4zVjk1aDQuM3YtNC4zWk01MS4yIDk5LjFoLTQuM3Y0LjNoNC4zVjk5Wk01NS40IDUyLjRoLTQuMnY0LjJoNC4ydi00LjJaTTU1LjQgNjkuNGgtNC4ydjQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNNTUuNCA4Ni40aC00LjJ2NC4yaDQuMnYtNC4yWk01NS40IDk5LjFoLTQuMnY0LjNoNC4yVjk5Wk01NS40IDEwMy40aC00LjJ2NC4yaDQuMnYtNC4yWk01OS43IDY1LjFoLTQuM3Y0LjNoNC4zVjY1Wk01OS43IDY5LjRoLTQuM3Y0LjJoNC4zdi00LjJaTTU5LjcgODYuNGgtNC4zdjQuMmg0LjN2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNNTkuNyA5MC42aC00LjNWOTVoNC4zdi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTU5LjcgOTQuOWgtNC4zVjk5aDQuM1Y5NVpNNjMuOSA1Mi40aC00LjJ2NC4yaDQuMnYtNC4yWk02My45IDY1LjFoLTQuMnY0LjNoNC4yVjY1Wk02My45IDY5LjRoLTQuMnY0LjJoNC4ydi00LjJaTTYzLjkgODYuNGgtNC4ydjQuMmg0LjJ2LTQuMlpNNjMuOSA5OS4xaC00LjJ2NC4zaDQuMlY5OVpNNjguMSA1Mi40SDY0djQuMmg0LjJ2LTQuMlpNNjguMSA2MC45SDY0VjY1aDQuMlY2MVpNNjguMSA2OS40SDY0djQuMmg0LjJ2LTQuMlpNNjguMSA3Ny45SDY0VjgyaDQuMlY3OFpNNjguMSA4Ni40SDY0djQuMmg0LjJ2LTQuMlpNNjguMSA5NC45SDY0Vjk5aDQuMlY5NVpNNjguMSAxMDMuNEg2NHY0LjJoNC4ydi00LjJaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTcyLjQgNTIuNEg2OHY0LjJoNC4zdi00LjJaTTcyLjQgNjkuNEg2OHY0LjJoNC4zdi00LjJaTTcyLjQgNzcuOUg2OFY4Mmg0LjNWNzhaTTcyLjQgODIuMUg2OHY0LjNoNC4zVjgyWk03Mi40IDg2LjRINjh2NC4yaDQuM3YtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik03Mi40IDkwLjZINjhWOTVoNC4zdi00LjNaTTcyLjQgOTkuMUg2OHY0LjNoNC4zVjk5Wk03Mi40IDEwMy40SDY4djQuMmg0LjN2LTQuMlpNNzYuNiAyMi43aC00LjJ2NC4yaDQuMnYtNC4yWk03Ni42IDMxLjJoLTQuMnY0LjJoNC4ydi00LjJaTTc2LjYgMzkuN2gtNC4ydjQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNNzYuNiA0My45aC00LjJ2NC4yaDQuMlY0NFpNNzYuNiA0OC4xaC00LjJ2NC4zaDQuMnYtNC4zWk03Ni42IDUyLjRoLTQuMnY0LjJoNC4ydi00LjJaTTc2LjYgNTYuNmgtNC4yVjYxaDQuMnYtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik03Ni42IDYwLjloLTQuMlY2NWg0LjJWNjFaTTc2LjYgNjUuMWgtNC4ydjQuM2g0LjJWNjVaTTc2LjYgNjkuNGgtNC4ydjQuMmg0LjJ2LTQuMlpNNzYuNiA5NC45aC00LjJWOTloNC4yVjk1Wk03Ni42IDEwMy40aC00LjJ2NC4yaDQuMnYtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik03Ni42IDEwNy42aC00LjJ2NC4zaDQuMnYtNC4zWk03Ni42IDExMS45aC00LjJ2NC4yaDQuMnYtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik03Ni42IDExNi4xaC00LjJ2NC4yaDQuMnYtNC4yWk03Ni42IDEyNC42aC00LjJ2NC4yaDQuMnYtNC4yWk03Ni42IDEyOC44aC00LjJ2NC4zaDQuMnYtNC4zWk03Ni42IDEzNy4zaC00LjJ2NC4zaDQuMnYtNC4zWk04MC45IDE4LjRoLTQuM3Y0LjNIODF2LTQuM1pNODAuOSAyN2gtNC4zdjQuMkg4MXYtNC4zWk04MC45IDMxLjJoLTQuM3Y0LjJIODF2LTQuMlpNODAuOSAzNS40aC00LjN2NC4zSDgxdi00LjNaTTgwLjkgMzkuN2gtNC4zdjQuMkg4MXYtNC4yWk04MC45IDQ4LjFoLTQuM3Y0LjNIODF2LTQuM1pNODAuOSA2MC45aC00LjNWNjVIODFWNjFaTTgwLjkgNjUuMWgtNC4zdjQuM0g4MVY2NVpNODAuOSA3My42aC00LjNWNzhIODF2LTQuM1pNODAuOSA4Mi4xaC00LjN2NC4zSDgxVjgyWk04MC45IDk0LjloLTQuM1Y5OUg4MVY5NVpNODAuOSA5OS4xaC00LjN2NC4zSDgxVjk5Wk04MC45IDEwMy40aC00LjN2NC4ySDgxdi00LjJaTTgwLjkgMTExLjloLTQuM3Y0LjJIODF2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNODAuOSAxMTYuMWgtNC4zdjQuMkg4MXYtNC4yWk04MC45IDEyNC42aC00LjN2NC4ySDgxdi00LjJaTTgwLjkgMTM3LjNoLTQuM3Y0LjNIODF2LTQuM1pNODUuMSAxOC40SDgxdjQuM0g4NXYtNC4zWk04NS4xIDMxLjJIODF2NC4ySDg1di00LjJaTTg1LjEgMzUuNEg4MXY0LjNIODV2LTQuM1pNODUuMSA0My45SDgxdjQuMkg4NVY0NFpNODUuMSA1Ni42SDgxVjYxSDg1di00LjNaTTg1LjEgNzMuNkg4MVY3OEg4NXYtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik04NS4xIDc3LjlIODFWODJIODVWNzhaTTg1LjEgODIuMUg4MXY0LjNIODVWODJaTTg1LjEgODYuNEg4MXY0LjJIODV2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNODUuMSA5MC42SDgxVjk1SDg1di00LjNaTTg1LjEgOTkuMUg4MXY0LjNIODVWOTlaTTg1LjEgMTExLjlIODF2NC4ySDg1di00LjJaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTg1LjEgMTE2LjFIODF2NC4ySDg1di00LjJaTTg1LjEgMTMzSDgxdjQuM0g4NXYtNC4yWk04OS40IDIyLjdIODV2NC4yaDQuM3YtNC4yWk04OS40IDMxLjJIODV2NC4yaDQuM3YtNC4yWk04OS40IDM1LjRIODV2NC4zaDQuM3YtNC4zWk04OS40IDQ4LjFIODV2NC4zaDQuM3YtNC4zWk04OS40IDUyLjRIODV2NC4yaDQuM3YtNC4yWk04OS40IDU2LjZIODVWNjFoNC4zdi00LjNaTTg5LjQgNjUuMUg4NXY0LjNoNC4zVjY1Wk04OS40IDEwMy40SDg1djQuMmg0LjN2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNODkuNCAxMDcuNkg4NXY0LjNoNC4zdi00LjNaTTg5LjQgMTExLjlIODV2NC4yaDQuM3YtNC4yWk04OS40IDEyOC44SDg1djQuM2g0LjN2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNODkuNCAxMzNIODV2NC4zaDQuM3YtNC4yWk05My42IDE4LjRoLTQuMnY0LjNoNC4ydi00LjNaTTkzLjYgMjIuN2gtNC4ydjQuMmg0LjJ2LTQuMlpNOTMuNiAyN2gtNC4ydjQuMmg0LjJ2LTQuM1pNOTMuNiAzMS4yaC00LjJ2NC4yaDQuMnYtNC4yWk05My42IDQzLjloLTQuMnY0LjJoNC4yVjQ0Wk05My42IDU2LjZoLTQuMlY2MWg0LjJ2LTQuM1pNOTMuNiA2NS4xaC00LjJ2NC4zaDQuMlY2NVpNOTMuNiA2OS40aC00LjJ2NC4yaDQuMnYtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik05My42IDczLjZoLTQuMlY3OGg0LjJ2LTQuM1pNOTMuNiA4Mi4xaC00LjJ2NC4zaDQuMlY4MlpNOTMuNiA4Ni40aC00LjJ2NC4yaDQuMnYtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik05My42IDkwLjZoLTQuMlY5NWg0LjJ2LTQuM1pNOTMuNiAxMTEuOWgtNC4ydjQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNOTMuNiAxMTYuMWgtNC4ydjQuMmg0LjJ2LTQuMlpNOTcuOSAxOC40aC00LjN2NC4zSDk4di00LjNaTTk3LjkgMjIuN2gtNC4zdjQuMkg5OHYtNC4yWk05Ny45IDM1LjRoLTQuM3Y0LjNIOTh2LTQuM1pNOTcuOSA0OC4xaC00LjN2NC4zSDk4di00LjNaTTk3LjkgNTIuNGgtNC4zdjQuMkg5OHYtNC4yWk05Ny45IDYwLjloLTQuM1Y2NUg5OFY2MVpNOTcuOSA2NS4xaC00LjN2NC4zSDk4VjY1Wk05Ny45IDczLjZoLTQuM1Y3OEg5OHYtNC4zWk05Ny45IDgyLjFoLTQuM3Y0LjNIOThWODJaTTk3LjkgODYuNGgtNC4zdjQuMkg5OHYtNC4yWk05Ny45IDk0LjloLTQuM1Y5OUg5OFY5NVpNOTcuOSA5OS4xaC00LjN2NC4zSDk4Vjk5Wk05Ny45IDEwMy40aC00LjN2NC4ySDk4di00LjJaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTk3LjkgMTA3LjZoLTQuM3Y0LjNIOTh2LTQuM1pNOTcuOSAxMTYuMWgtNC4zdjQuMkg5OHYtNC4yWk05Ny45IDEyNC42aC00LjN2NC4ySDk4di00LjJaTTk3LjkgMTMzaC00LjN2NC4zSDk4di00LjJaTTEwMi4xIDIyLjdIOTh2NC4yaDQuMnYtNC4yWk0xMDIuMSA0My45SDk4djQuMmg0LjJWNDRaTTEwMi4xIDUyLjRIOTh2NC4yaDQuMnYtNC4yWk0xMDIuMSA2OS40SDk4djQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTAyLjEgNzMuNkg5OFY3OGg0LjJ2LTQuM1pNMTAyLjEgOTAuNkg5OFY5NWg0LjJ2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTAyLjEgOTQuOUg5OFY5OWg0LjJWOTVaTTEwMi4xIDEwMy40SDk4djQuMmg0LjJ2LTQuMlpNMTAyLjEgMTMzSDk4djQuM2g0LjJ2LTQuMlpNMTAyLjEgMTM3LjNIOTh2NC4zaDQuMnYtNC4zWk0xMDYuNCAxOC40SDEwMnY0LjNoNC4zdi00LjNaTTEwNi40IDIyLjdIMTAydjQuMmg0LjN2LTQuMlpNMTA2LjQgNDguMUgxMDJ2NC4zaDQuM3YtNC4zWk0xMDYuNCA2MC45SDEwMlY2NWg0LjNWNjFaTTEwNi40IDczLjZIMTAyVjc4aDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMDYuNCA3Ny45SDEwMlY4Mmg0LjNWNzhaTTEwNi40IDgyLjFIMTAydjQuM2g0LjNWODJaTTEwNi40IDg2LjRIMTAydjQuMmg0LjN2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTA2LjQgOTAuNkgxMDJWOTVoNC4zdi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTEwNi40IDk0LjlIMTAyVjk5aDQuM1Y5NVpNMTA2LjQgMTAzLjRIMTAydjQuMmg0LjN2LTQuMlpNMTA2LjQgMTE2LjFIMTAydjQuMmg0LjN2LTQuMlpNMTA2LjQgMTMzSDEwMnY0LjNoNC4zdi00LjJaTTEwNi40IDEzNy4zSDEwMnY0LjNoNC4zdi00LjNaTTExMC42IDI3aC00LjJ2NC4yaDQuMnYtNC4zWk0xMTAuNiAzOS43aC00LjJ2NC4yaDQuMnYtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMTAuNiA0My45aC00LjJ2NC4yaDQuMlY0NFpNMTEwLjYgNDguMWgtNC4ydjQuM2g0LjJ2LTQuM1pNMTEwLjYgNTIuNGgtNC4ydjQuMmg0LjJ2LTQuMlpNMTEwLjYgNTYuNmgtNC4yVjYxaDQuMnYtNC4zWk0xMTAuNiA5MC42aC00LjJWOTVoNC4ydi00LjNaTTExMC42IDk5LjFoLTQuMnY0LjNoNC4yVjk5Wk0xMTAuNiAxMDcuNmgtNC4ydjQuM2g0LjJ2LTQuM1pNMTEwLjYgMTE2LjFoLTQuMnY0LjJoNC4ydi00LjJaTTExMC42IDEyMC4zaC00LjJ2NC4zaDQuMnYtNC4zWk0xMTAuNiAxMzNoLTQuMnY0LjNoNC4ydi00LjJaTTExMC42IDEzNy4zaC00LjJ2NC4zaDQuMnYtNC4zWk0xMTQuOSAzMS4yaC00LjN2NC4yaDQuM3YtNC4yWk0xMTQuOSA0OC4xaC00LjN2NC4zaDQuM3YtNC4zWk0xMTQuOSA1Mi40aC00LjN2NC4yaDQuM3YtNC4yWk0xMTQuOSA2NS4xaC00LjN2NC4zaDQuM1Y2NVpNMTE0LjkgNzMuNmgtNC4zVjc4aDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMTQuOSA3Ny45aC00LjNWODJoNC4zVjc4Wk0xMTQuOSA4Mi4xaC00LjN2NC4zaDQuM1Y4MlpNMTE0LjkgODYuNGgtNC4zdjQuMmg0LjN2LTQuMlpNMTE0LjkgOTkuMWgtNC4zdjQuM2g0LjNWOTlaTTExNC45IDExMS45aC00LjN2NC4yaDQuM3YtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMTQuOSAxMTYuMWgtNC4zdjQuMmg0LjN2LTQuMlpNMTE0LjkgMTMzaC00LjN2NC4zaDQuM3YtNC4yWk0xMTQuOSAxMzcuM2gtNC4zdjQuM2g0LjN2LTQuM1pNMTE5LjEgMTguNEgxMTV2NC4zaDQuMnYtNC4zWk0xMTkuMSAzNS40SDExNXY0LjNoNC4ydi00LjNaTTExOS4xIDM5LjdIMTE1djQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTE5LjEgNDMuOUgxMTV2NC4yaDQuMlY0NFpNMTE5LjEgNjUuMUgxMTV2NC4zaDQuMlY2NVpNMTE5LjEgNjkuNEgxMTV2NC4yaDQuMnYtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMTkuMSA3My42SDExNVY3OGg0LjJ2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTE5LjEgNzcuOUgxMTVWODJoNC4yVjc4Wk0xMTkuMSA4Mi4xSDExNXY0LjNoNC4yVjgyWk0xMTkuMSA4Ni40SDExNXY0LjJoNC4ydi00LjJaTTExOS4xIDk0LjlIMTE1Vjk5aDQuMlY5NVpNMTE5LjEgMTAzLjRIMTE1djQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTE5LjEgMTA3LjZIMTE1djQuM2g0LjJ2LTQuM1pNMTE5LjEgMTE2LjFIMTE1djQuMmg0LjJ2LTQuMlpNMTE5LjEgMTI0LjZIMTE1djQuMmg0LjJ2LTQuMlpNMTE5LjEgMTMzSDExNXY0LjNoNC4ydi00LjJaTTExOS4xIDEzNy4zSDExNXY0LjNoNC4ydi00LjNaTTEyMy40IDMxLjJIMTE5djQuMmg0LjN2LTQuMlpNMTIzLjQgMzUuNEgxMTl2NC4zaDQuM3YtNC4zWk0xMjMuNCA1Ni42SDExOVY2MWg0LjN2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTIzLjQgNjAuOUgxMTlWNjVoNC4zVjYxWk0xMjMuNCA5MC42SDExOVY5NWg0LjN2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTIzLjQgOTQuOUgxMTlWOTloNC4zVjk1Wk0xMjMuNCA5OS4xSDExOXY0LjNoNC4zVjk5Wk0xMjMuNCAxMjAuM0gxMTl2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMjMuNCAxMjQuNkgxMTl2NC4yaDQuM3YtNC4yWk0xMjcuNiAxOC40aC00LjJ2NC4zaDQuMnYtNC4zWk0xMjcuNiAyN2gtNC4ydjQuMmg0LjJ2LTQuM1pNMTI3LjYgMzEuMmgtNC4ydjQuMmg0LjJ2LTQuMlpNMTI3LjYgMzkuN2gtNC4ydjQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTI3LjYgNDMuOWgtNC4ydjQuMmg0LjJWNDRaTTEyNy42IDQ4LjFoLTQuMnY0LjNoNC4ydi00LjNaTTEyNy42IDU2LjZoLTQuMlY2MWg0LjJ2LTQuM1pNMTI3LjYgNjUuMWgtNC4ydjQuM2g0LjJWNjVaTTEyNy42IDY5LjRoLTQuMnY0LjJoNC4ydi00LjJaTTEyNy42IDc3LjloLTQuMlY4Mmg0LjJWNzhaTTEyNy42IDgyLjFoLTQuMnY0LjNoNC4yVjgyWk0xMjcuNiA5OS4xaC00LjJ2NC4zaDQuMlY5OVpNMTI3LjYgMTAzLjRoLTQuMnY0LjJoNC4ydi00LjJaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTEyNy42IDEwNy42aC00LjJ2NC4zaDQuMnYtNC4zWk0xMjcuNiAxMTEuOWgtNC4ydjQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTI3LjYgMTE2LjFoLTQuMnY0LjJoNC4ydi00LjJaTTEyNy42IDEyMC4zaC00LjJ2NC4zaDQuMnYtNC4zWk0xMjcuNiAxMjguOGgtNC4ydjQuM2g0LjJ2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTI3LjYgMTMzaC00LjJ2NC4zaDQuMnYtNC4yWk0xMzEuOSA1Mi40aC00LjN2NC4yaDQuM3YtNC4yWk0xMzEuOSA2NS4xaC00LjN2NC4zaDQuM1Y2NVpNMTMxLjkgNzcuOWgtNC4zVjgyaDQuM1Y3OFpNMTMxLjkgODYuNGgtNC4zdjQuMmg0LjN2LTQuMlpNMTMxLjkgOTQuOWgtNC4zVjk5aDQuM1Y5NVpNMTMxLjkgMTAzLjRoLTQuM3Y0LjJoNC4zdi00LjJaTTEzMS45IDEyMC4zaC00LjN2NC4zaDQuM3YtNC4zWk0xMzEuOSAxMzNoLTQuM3Y0LjNoNC4zdi00LjJaTTEzMS45IDEzNy4zaC00LjN2NC4zaDQuM3YtNC4zWk0xMzYuMSA1Mi40aC00LjJ2NC4yaDQuMnYtNC4yWk0xMzYuMSA1Ni42aC00LjJWNjFoNC4ydi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTEzNi4xIDYwLjloLTQuMlY2NWg0LjJWNjFaTTEzNi4xIDY1LjFoLTQuMnY0LjNoNC4yVjY1Wk0xMzYuMSA2OS40aC00LjJ2NC4yaDQuMnYtNC4yWk0xMzYuMSA5NC45aC00LjJWOTloNC4yVjk1Wk0xMzYuMSAxMDMuNGgtNC4ydjQuMmg0LjJ2LTQuMlpNMTM2LjEgMTExLjloLTQuMnY0LjJoNC4ydi00LjJaTTEzNi4xIDEyMC4zaC00LjJ2NC4zaDQuMnYtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMzYuMSAxMjQuNmgtNC4ydjQuMmg0LjJ2LTQuMlpNMTM2LjEgMTI4LjhoLTQuMnY0LjNoNC4ydi00LjNaTTE0MC4zIDUyLjRoLTQuMnY0LjJoNC4ydi00LjJaTTE0MC4zIDU2LjZoLTQuMlY2MWg0LjJ2LTQuM1pNMTQwLjMgNjUuMWgtNC4ydjQuM2g0LjJWNjVaTTE0MC4zIDczLjZoLTQuMlY3OGg0LjJ2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTQwLjMgNzcuOWgtNC4yVjgyaDQuMlY3OFpNMTQwLjMgODYuNGgtNC4ydjQuMmg0LjJ2LTQuMlpNMTQwLjMgOTQuOWgtNC4yVjk5aDQuMlY5NVpNMTQwLjMgMTAzLjRoLTQuMnY0LjJoNC4ydi00LjJaTTE0MC4zIDEyMC4zaC00LjJ2NC4zaDQuMnYtNC4zWk0xNDAuMyAxMjguOGgtNC4ydjQuM2g0LjJ2LTQuM1pNMTQ0LjYgNjAuOWgtNC4zVjY1aDQuM1Y2MVoiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTQ0LjYgODYuNGgtNC4zdjQuMmg0LjN2LTQuMlpNMTQ0LjYgMTAzLjRoLTQuM3Y0LjJoNC4zdi00LjJaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE0NC42IDEwNy42aC00LjN2NC4zaDQuM3YtNC4zWk0xNDQuNiAxMTEuOWgtNC4zdjQuMmg0LjN2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTQ0LjYgMTE2LjFoLTQuM3Y0LjJoNC4zdi00LjJaTTE0NC42IDEyMC4zaC00LjN2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xNDQuNiAxMjQuNmgtNC4zdjQuMmg0LjN2LTQuMlpNMTQ4LjggNTIuNGgtNC4ydjQuMmg0LjJ2LTQuMlpNMTQ4LjggNjAuOWgtNC4yVjY1aDQuMlY2MVpNMTQ4LjggNzMuNmgtNC4yVjc4aDQuMnYtNC4zWk0xNDguOCA4Mi4xaC00LjJ2NC4zaDQuMlY4MlpNMTQ4LjggODYuNGgtNC4ydjQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTQ4LjggOTAuNmgtNC4yVjk1aDQuMnYtNC4zWk0xNDguOCA5OS4xaC00LjJ2NC4zaDQuMlY5OVpNMTQ4LjggMTAzLjRoLTQuMnY0LjJoNC4ydi00LjJaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE0OC44IDEwNy42aC00LjJ2NC4zaDQuMnYtNC4zWk0xNDguOCAxMTEuOWgtNC4ydjQuMmg0LjJ2LTQuMlpNMTQ4LjggMTIwLjNoLTQuMnY0LjNoNC4ydi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE0OC44IDEyNC42aC00LjJ2NC4yaDQuMnYtNC4yWk0xNDguOCAxMjguOGgtNC4ydjQuM2g0LjJ2LTQuM1pNMTQ4LjggMTM3LjNoLTQuMnY0LjNoNC4ydi00LjNaTTE1MyA1Ni42aC00LjJWNjFoNC4zdi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE1MyA2MC45aC00LjJWNjVoNC4zVjYxWk0xNTMgNjUuMWgtNC4ydjQuM2g0LjNWNjVaTTE1MyA5NC45aC00LjJWOTloNC4zVjk1Wk0xNTMgOTkuMWgtNC4ydjQuM2g0LjNWOTlaTTE1MyAxMDMuNGgtNC4ydjQuMmg0LjN2LTQuMlpNMTUzIDEyMC4zaC00LjJ2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xNTMgMTI0LjZoLTQuMnY0LjJoNC4zdi00LjJaTTE1MyAxMzcuM2gtNC4ydjQuM2g0LjN2LTQuM1pNMTU3LjMgNTIuNGgtNC4ydjQuMmg0LjJ2LTQuMlpNMTU3LjMgNTYuNmgtNC4yVjYxaDQuMnYtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xNTcuMyA2MC45aC00LjJWNjVoNC4yVjYxWk0xNTcuMyA2NS4xaC00LjJ2NC4zaDQuMlY2NVpNMTU3LjMgOTAuNmgtNC4yVjk1aDQuMnYtNC4zWk0xNTcuMyAxMTYuMWgtNC4ydjQuMmg0LjJ2LTQuMlpNMTU3LjMgMTI0LjZoLTQuMnY0LjJoNC4ydi00LjJaTTE1Ny4zIDEzM2gtNC4ydjQuM2g0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTYxLjYgNTYuNmgtNC4zVjYxaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xNjEuNiA2MC45aC00LjNWNjVoNC4zVjYxWk0xNjEuNiA2NS4xaC00LjN2NC4zaDQuM1Y2NVpNMTYxLjYgNzMuNmgtNC4zVjc4aDQuM3YtNC4zWk0xNjEuNiA5MC42aC00LjNWOTVoNC4zdi00LjNaTTE2MS42IDEwMy40aC00LjN2NC4yaDQuM3YtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xNjEuNiAxMDcuNmgtNC4zdjQuM2g0LjN2LTQuM1pNMTYxLjYgMTExLjloLTQuM3Y0LjJoNC4zdi00LjJaTTE2MS42IDEyMC4zaC00LjN2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xNjEuNiAxMjQuNmgtNC4zdjQuMmg0LjN2LTQuMlpNMTYxLjYgMTMzaC00LjN2NC4zaDQuM3YtNC4yWiIvPjwvbWFzaz48ZyBtYXNrPSJ1cmwoI2IpIj48cGF0aCBmaWxsPSIjMDAwIiBkPSJNMTYxLjYgMTguNEgzOC40djEyMy4yaDEyMy4yVjE4LjRaIi8+PC9nPjxtYXNrIGlkPSJjIiB3aWR0aD0iMzEiIGhlaWdodD0iMzEiIHg9IjM4IiB5PSIxOCIgbWFza1VuaXRzPSJ1c2VyU3BhY2VPblVzZSIgc3R5bGU9Im1hc2stdHlwZTpsdW1pbmFuY2UiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik00Mi43IDE4LjRoLTQuM3Y0LjNoNC4zdi00LjNaTTQyLjcgMjIuN2gtNC4zdjQuMmg0LjN2LTQuMlpNNDIuNyAyN2gtNC4zdjQuMmg0LjN2LTQuM1pNNDIuNyAzMS4yaC00LjN2NC4yaDQuM3YtNC4yWk00Mi43IDM1LjRoLTQuM3Y0LjNoNC4zdi00LjNaTTQyLjcgMzkuN2gtNC4zdjQuMmg0LjN2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNNDIuNyA0My45aC00LjN2NC4yaDQuM1Y0NFpNNDcgMTguNGgtNC4zdjQuM2g0LjJ2LTQuM1pNNDcgNDMuOWgtNC4zdjQuMmg0LjJWNDRaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTUxLjIgMTguNGgtNC4zdjQuM2g0LjN2LTQuM1pNNTEuMiA0My45aC00LjN2NC4yaDQuM1Y0NFoiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNNTUuNCAxOC40aC00LjJ2NC4zaDQuMnYtNC4zWk01NS40IDQzLjloLTQuMnY0LjJoNC4yVjQ0Wk01OS43IDE4LjRoLTQuM3Y0LjNoNC4zdi00LjNaTTU5LjcgNDMuOWgtNC4zdjQuMmg0LjNWNDRaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTYzLjkgMTguNGgtNC4ydjQuM2g0LjJ2LTQuM1pNNjMuOSA0My45aC00LjJ2NC4yaDQuMlY0NFpNNjguMSAxOC40SDY0djQuM2g0LjJ2LTQuM1pNNjguMSAyMi43SDY0djQuMmg0LjJ2LTQuMlpNNjguMSAyN0g2NHY0LjJoNC4ydi00LjNaTTY4LjEgMzEuMkg2NHY0LjJoNC4ydi00LjJaTTY4LjEgMzUuNEg2NHY0LjNoNC4ydi00LjNaTTY4LjEgMzkuN0g2NHY0LjJoNC4ydi00LjJaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTY4LjEgNDMuOUg2NHY0LjJoNC4yVjQ0WiIvPjwvbWFzaz48ZyBtYXNrPSJ1cmwoI2MpIj48cGF0aCBmaWxsPSIjMDAwIiBkPSJNNjguMSAxOC40SDM4LjR2MjkuN2gyOS43VjE4LjRaIi8+PC9nPjxtYXNrIGlkPSJkIiB3aWR0aD0iMTQiIGhlaWdodD0iMTQiIHg9IjQ2IiB5PSIyNiIgbWFza1VuaXRzPSJ1c2VyU3BhY2VPblVzZSIgc3R5bGU9Im1hc2stdHlwZTpsdW1pbmFuY2UiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik01MS4yIDI3aC00LjN2NC4yaDQuM3YtNC4zWk01MS4yIDMxLjJoLTQuM3Y0LjJoNC4zdi00LjJaTTUxLjIgMzUuNGgtNC4zdjQuM2g0LjN2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNNTUuNCAyN2gtNC4ydjQuMmg0LjJ2LTQuM1pNNTUuNCAzMS4yaC00LjJ2NC4yaDQuMnYtNC4yWk01NS40IDM1LjRoLTQuMnY0LjNoNC4ydi00LjNaTTU5LjcgMjdoLTQuM3Y0LjJoNC4zdi00LjNaTTU5LjcgMzEuMmgtNC4zdjQuMmg0LjN2LTQuMlpNNTkuNyAzNS40aC00LjN2NC4zaDQuM3YtNC4zWiIvPjwvbWFzaz48ZyBtYXNrPSJ1cmwoI2QpIj48cGF0aCBmaWxsPSIjMDAwIiBkPSJNNTkuNyAyN0g0Ni45djEyLjdoMTIuOFYyNi45WiIvPjwvZz48bWFzayBpZD0iZSIgd2lkdGg9IjMxIiBoZWlnaHQ9IjMxIiB4PSIxMzEiIHk9IjE4IiBtYXNrVW5pdHM9InVzZXJTcGFjZU9uVXNlIiBzdHlsZT0ibWFzay10eXBlOmx1bWluYW5jZSI+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTEzNi4xIDE4LjRoLTQuMnY0LjNoNC4ydi00LjNaTTEzNi4xIDIyLjdoLTQuMnY0LjJoNC4ydi00LjJaTTEzNi4xIDI3aC00LjJ2NC4yaDQuMnYtNC4zWk0xMzYuMSAzMS4yaC00LjJ2NC4yaDQuMnYtNC4yWk0xMzYuMSAzNS40aC00LjJ2NC4zaDQuMnYtNC4zWk0xMzYuMSAzOS43aC00LjJ2NC4yaDQuMnYtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMzYuMSA0My45aC00LjJ2NC4yaDQuMlY0NFpNMTQwLjMgMTguNGgtNC4ydjQuM2g0LjJ2LTQuM1pNMTQwLjMgNDMuOWgtNC4ydjQuMmg0LjJWNDRaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE0NC42IDE4LjRoLTQuM3Y0LjNoNC4zdi00LjNaTTE0NC42IDQzLjloLTQuM3Y0LjJoNC4zVjQ0Wk0xNDguOCAxOC40aC00LjJ2NC4zaDQuMnYtNC4zWk0xNDguOCA0My45aC00LjJ2NC4yaDQuMlY0NFpNMTUzIDE4LjRoLTQuMnY0LjNoNC4zdi00LjNaTTE1MyA0My45aC00LjJ2NC4yaDQuM1Y0NFpNMTU3LjMgMTguNGgtNC4ydjQuM2g0LjJ2LTQuM1pNMTU3LjMgNDMuOWgtNC4ydjQuMmg0LjJWNDRaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE2MS42IDE4LjRoLTQuM3Y0LjNoNC4zdi00LjNaTTE2MS42IDIyLjdoLTQuM3Y0LjJoNC4zdi00LjJaTTE2MS42IDI3aC00LjN2NC4yaDQuM3YtNC4zWk0xNjEuNiAzMS4yaC00LjN2NC4yaDQuM3YtNC4yWk0xNjEuNiAzNS40aC00LjN2NC4zaDQuM3YtNC4zWk0xNjEuNiAzOS43aC00LjN2NC4yaDQuM3YtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xNjEuNiA0My45aC00LjN2NC4yaDQuM1Y0NFoiLz48L21hc2s+PGcgbWFzaz0idXJsKCNlKSI+PHBhdGggZmlsbD0iIzAwMCIgZD0iTTE2MS42IDE4LjRoLTI5Ljd2MjkuN2gyOS43VjE4LjRaIi8+PC9nPjxtYXNrIGlkPSJmIiB3aWR0aD0iMTQiIGhlaWdodD0iMTQiIHg9IjE0MCIgeT0iMjYiIG1hc2tVbml0cz0idXNlclNwYWNlT25Vc2UiIHN0eWxlPSJtYXNrLXR5cGU6bHVtaW5hbmNlIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTQ0LjYgMjdoLTQuM3Y0LjJoNC4zdi00LjNaTTE0NC42IDMxLjJoLTQuM3Y0LjJoNC4zdi00LjJaTTE0NC42IDM1LjRoLTQuM3Y0LjNoNC4zdi00LjNaTTE0OC44IDI3aC00LjJ2NC4yaDQuMnYtNC4zWk0xNDguOCAzMS4yaC00LjJ2NC4yaDQuMnYtNC4yWk0xNDguOCAzNS40aC00LjJ2NC4zaDQuMnYtNC4zWk0xNTMgMjdoLTQuMnY0LjJoNC4zdi00LjNaTTE1MyAzMS4yaC00LjJ2NC4yaDQuM3YtNC4yWk0xNTMgMzUuNGgtNC4ydjQuM2g0LjN2LTQuM1oiLz48L21hc2s+PGcgbWFzaz0idXJsKCNmKSI+PHBhdGggZmlsbD0iIzAwMCIgZD0iTTE1MyAyN2gtMTIuN3YxMi43aDEyLjhWMjYuOVoiLz48L2c+PG1hc2sgaWQ9ImciIHdpZHRoPSIzMSIgaGVpZ2h0PSIzMSIgeD0iMzgiIHk9IjExMSIgbWFza1VuaXRzPSJ1c2VyU3BhY2VPblVzZSIgc3R5bGU9Im1hc2stdHlwZTpsdW1pbmFuY2UiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik00Mi43IDExMS45aC00LjN2NC4yaDQuM3YtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik00Mi43IDExNi4xaC00LjN2NC4yaDQuM3YtNC4yWk00Mi43IDEyMC4zaC00LjN2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik00Mi43IDEyNC42aC00LjN2NC4yaDQuM3YtNC4yWk00Mi43IDEyOC44aC00LjN2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik00Mi43IDEzM2gtNC4zdjQuM2g0LjN2LTQuMlpNNDIuNyAxMzcuM2gtNC4zdjQuM2g0LjN2LTQuM1pNNDcgMTExLjloLTQuM3Y0LjJoNC4ydi00LjJaTTQ3IDEzNy4zaC00LjN2NC4zaDQuMnYtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik01MS4yIDExMS45aC00LjN2NC4yaDQuM3YtNC4yWk01MS4yIDEzNy4zaC00LjN2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik01NS40IDExMS45aC00LjJ2NC4yaDQuMnYtNC4yWk01NS40IDEzNy4zaC00LjJ2NC4zaDQuMnYtNC4zWk01OS43IDExMS45aC00LjN2NC4yaDQuM3YtNC4yWk01OS43IDEzNy4zaC00LjN2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik02My45IDExMS45aC00LjJ2NC4yaDQuMnYtNC4yWk02My45IDEzNy4zaC00LjJ2NC4zaDQuMnYtNC4zWk02OC4xIDExMS45SDY0djQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNNjguMSAxMTYuMUg2NHY0LjJoNC4ydi00LjJaTTY4LjEgMTIwLjNINjR2NC4zaDQuMnYtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik02OC4xIDEyNC42SDY0djQuMmg0LjJ2LTQuMlpNNjguMSAxMjguOEg2NHY0LjNoNC4ydi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTY4LjEgMTMzSDY0djQuM2g0LjJ2LTQuMlpNNjguMSAxMzcuM0g2NHY0LjNoNC4ydi00LjNaIi8+PC9tYXNrPjxnIG1hc2s9InVybCgjZykiPjxwYXRoIGZpbGw9IiMwMDAiIGQ9Ik02OC4xIDExMS45SDM4LjR2MjkuN2gyOS43di0yOS43WiIvPjwvZz48bWFzayBpZD0iaCIgd2lkdGg9IjE0IiBoZWlnaHQ9IjE0IiB4PSI0NiIgeT0iMTIwIiBtYXNrVW5pdHM9InVzZXJTcGFjZU9uVXNlIiBzdHlsZT0ibWFzay10eXBlOmx1bWluYW5jZSI+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTUxLjIgMTIwLjNoLTQuM3Y0LjNoNC4zdi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTUxLjIgMTI0LjZoLTQuM3Y0LjJoNC4zdi00LjJaTTUxLjIgMTI4LjhoLTQuM3Y0LjNoNC4zdi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTU1LjQgMTIwLjNoLTQuMnY0LjNoNC4ydi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTU1LjQgMTI0LjZoLTQuMnY0LjJoNC4ydi00LjJaTTU1LjQgMTI4LjhoLTQuMnY0LjNoNC4ydi00LjNaTTU5LjcgMTIwLjNoLTQuM3Y0LjNoNC4zdi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTU5LjcgMTI0LjZoLTQuM3Y0LjJoNC4zdi00LjJaTTU5LjcgMTI4LjhoLTQuM3Y0LjNoNC4zdi00LjNaIi8+PC9tYXNrPjxnIG1hc2s9InVybCgjaCkiPjxwYXRoIGZpbGw9IiMwMDAiIGQ9Ik01OS43IDEyMC4zSDQ2Ljl2MTIuOGgxMi44di0xMi44WiIvPjwvZz48L2c+PGRlZnM+PGNsaXBQYXRoIGlkPSJhIj48cmVjdCB3aWR0aD0iMTM3IiBoZWlnaHQ9IjEzNyIgeD0iMzEuNSIgeT0iMTEuNSIgZmlsbD0iI2ZmZiIgcng9IjguMiIvPjwvY2xpcFBhdGg+PC9kZWZzPjwvc3ZnPg==", - "description": "Enables users to open the ThingsBoard mobile app by scanning a QR code from Home page.", - "descriptor": { - "type": "static", - "sizeX": 6, - "sizeY": 3, - "resources": [], - "templateHtml": "\n", - "templateCss": "", - "controllerScript": "self.onInit = function() {\n}", - "settingsSchema": "", - "dataKeySettingsSchema": "", - "settingsDirective": "", - "defaultConfig": "{\"showTitle\":false,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Home page mobile app QR code\",\"dropShadow\":true}" - }, - "tags": [ - "ios", - "android", - "qr code", - "mobile application", - "home page" - ] -} \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_types/mobile_app_qr_code.json b/application/src/main/data/json/system/widget_types/mobile_app_qr_code.json index ccb9bea73f..37f7df0ae6 100644 --- a/application/src/main/data/json/system/widget_types/mobile_app_qr_code.json +++ b/application/src/main/data/json/system/widget_types/mobile_app_qr_code.json @@ -1,5 +1,5 @@ { - "fqn": "cards.mobile_app_qr_code", + "fqn": "mobile_app_qr_code", "name": "Mobile app QR code", "deprecated": false, "image": "tb-image:bW9iaWxlX2FwcF9xcl9jb2RlX3dpZGdldC5zdmc=:bW9iaWxlX2FwcF9xcl9jb2RlX3dpZGdldC5zdmc=;data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMDAiIGhlaWdodD0iMTYwIiBmaWxsPSJub25lIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE2MCIgZmlsbD0iI2ZmZiIgcng9IjQiLz48cmVjdCB3aWR0aD0iMTk5IiBoZWlnaHQ9IjE1OSIgeD0iLjUiIHk9Ii41IiBzdHJva2U9IiMwMDAiIHN0cm9rZS1vcGFjaXR5PSIuMSIgcng9IjMuNSIvPjxnIGNsaXAtcGF0aD0idXJsKCNhKSI+PHJlY3Qgd2lkdGg9IjEzNyIgaGVpZ2h0PSIxMzciIHg9IjMxLjUiIHk9IjExLjUiIGZpbGw9IiNmZmYiIHJ4PSI4LjIiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTY4LjUgMTEuNWgtMTM3djEzN2gxMzd2LTEzN1oiLz48bWFzayBpZD0iYiIgd2lkdGg9IjEyNCIgaGVpZ2h0PSIxMjQiIHg9IjM4IiB5PSIxOCIgbWFza1VuaXRzPSJ1c2VyU3BhY2VPblVzZSIgc3R5bGU9Im1hc2stdHlwZTpsdW1pbmFuY2UiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik00Mi43IDUyLjRoLTQuM3Y0LjJoNC4zdi00LjJaTTQyLjcgNjAuOWgtNC4zVjY1aDQuM1Y2MVpNNDIuNyA2NS4xaC00LjN2NC4zaDQuM1Y2NVpNNDIuNyA5NC45aC00LjNWOTloNC4zVjk1Wk00NyA1Ni42aC00LjNWNjFoNC4ydi00LjNaTTQ3IDgyLjFoLTQuM3Y0LjNoNC4yVjgyWk00NyA4Ni40aC00LjN2NC4yaDQuMnYtNC4yWk00NyA5NC45aC00LjNWOTloNC4yVjk1Wk00NyAxMDMuNGgtNC4zdjQuMmg0LjJ2LTQuMlpNNTEuMiA1Mi40aC00LjN2NC4yaDQuM3YtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik01MS4yIDYwLjloLTQuM1Y2NWg0LjNWNjFaTTUxLjIgNzMuNmgtNC4zVjc4aDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik01MS4yIDc3LjloLTQuM1Y4Mmg0LjNWNzhaTTUxLjIgODIuMWgtNC4zdjQuM2g0LjNWODJaTTUxLjIgOTAuNmgtNC4zVjk1aDQuM3YtNC4zWk01MS4yIDk5LjFoLTQuM3Y0LjNoNC4zVjk5Wk01NS40IDUyLjRoLTQuMnY0LjJoNC4ydi00LjJaTTU1LjQgNjkuNGgtNC4ydjQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNNTUuNCA4Ni40aC00LjJ2NC4yaDQuMnYtNC4yWk01NS40IDk5LjFoLTQuMnY0LjNoNC4yVjk5Wk01NS40IDEwMy40aC00LjJ2NC4yaDQuMnYtNC4yWk01OS43IDY1LjFoLTQuM3Y0LjNoNC4zVjY1Wk01OS43IDY5LjRoLTQuM3Y0LjJoNC4zdi00LjJaTTU5LjcgODYuNGgtNC4zdjQuMmg0LjN2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNNTkuNyA5MC42aC00LjNWOTVoNC4zdi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTU5LjcgOTQuOWgtNC4zVjk5aDQuM1Y5NVpNNjMuOSA1Mi40aC00LjJ2NC4yaDQuMnYtNC4yWk02My45IDY1LjFoLTQuMnY0LjNoNC4yVjY1Wk02My45IDY5LjRoLTQuMnY0LjJoNC4ydi00LjJaTTYzLjkgODYuNGgtNC4ydjQuMmg0LjJ2LTQuMlpNNjMuOSA5OS4xaC00LjJ2NC4zaDQuMlY5OVpNNjguMSA1Mi40SDY0djQuMmg0LjJ2LTQuMlpNNjguMSA2MC45SDY0VjY1aDQuMlY2MVpNNjguMSA2OS40SDY0djQuMmg0LjJ2LTQuMlpNNjguMSA3Ny45SDY0VjgyaDQuMlY3OFpNNjguMSA4Ni40SDY0djQuMmg0LjJ2LTQuMlpNNjguMSA5NC45SDY0Vjk5aDQuMlY5NVpNNjguMSAxMDMuNEg2NHY0LjJoNC4ydi00LjJaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTcyLjQgNTIuNEg2OHY0LjJoNC4zdi00LjJaTTcyLjQgNjkuNEg2OHY0LjJoNC4zdi00LjJaTTcyLjQgNzcuOUg2OFY4Mmg0LjNWNzhaTTcyLjQgODIuMUg2OHY0LjNoNC4zVjgyWk03Mi40IDg2LjRINjh2NC4yaDQuM3YtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik03Mi40IDkwLjZINjhWOTVoNC4zdi00LjNaTTcyLjQgOTkuMUg2OHY0LjNoNC4zVjk5Wk03Mi40IDEwMy40SDY4djQuMmg0LjN2LTQuMlpNNzYuNiAyMi43aC00LjJ2NC4yaDQuMnYtNC4yWk03Ni42IDMxLjJoLTQuMnY0LjJoNC4ydi00LjJaTTc2LjYgMzkuN2gtNC4ydjQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNNzYuNiA0My45aC00LjJ2NC4yaDQuMlY0NFpNNzYuNiA0OC4xaC00LjJ2NC4zaDQuMnYtNC4zWk03Ni42IDUyLjRoLTQuMnY0LjJoNC4ydi00LjJaTTc2LjYgNTYuNmgtNC4yVjYxaDQuMnYtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik03Ni42IDYwLjloLTQuMlY2NWg0LjJWNjFaTTc2LjYgNjUuMWgtNC4ydjQuM2g0LjJWNjVaTTc2LjYgNjkuNGgtNC4ydjQuMmg0LjJ2LTQuMlpNNzYuNiA5NC45aC00LjJWOTloNC4yVjk1Wk03Ni42IDEwMy40aC00LjJ2NC4yaDQuMnYtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik03Ni42IDEwNy42aC00LjJ2NC4zaDQuMnYtNC4zWk03Ni42IDExMS45aC00LjJ2NC4yaDQuMnYtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik03Ni42IDExNi4xaC00LjJ2NC4yaDQuMnYtNC4yWk03Ni42IDEyNC42aC00LjJ2NC4yaDQuMnYtNC4yWk03Ni42IDEyOC44aC00LjJ2NC4zaDQuMnYtNC4zWk03Ni42IDEzNy4zaC00LjJ2NC4zaDQuMnYtNC4zWk04MC45IDE4LjRoLTQuM3Y0LjNIODF2LTQuM1pNODAuOSAyN2gtNC4zdjQuMkg4MXYtNC4zWk04MC45IDMxLjJoLTQuM3Y0LjJIODF2LTQuMlpNODAuOSAzNS40aC00LjN2NC4zSDgxdi00LjNaTTgwLjkgMzkuN2gtNC4zdjQuMkg4MXYtNC4yWk04MC45IDQ4LjFoLTQuM3Y0LjNIODF2LTQuM1pNODAuOSA2MC45aC00LjNWNjVIODFWNjFaTTgwLjkgNjUuMWgtNC4zdjQuM0g4MVY2NVpNODAuOSA3My42aC00LjNWNzhIODF2LTQuM1pNODAuOSA4Mi4xaC00LjN2NC4zSDgxVjgyWk04MC45IDk0LjloLTQuM1Y5OUg4MVY5NVpNODAuOSA5OS4xaC00LjN2NC4zSDgxVjk5Wk04MC45IDEwMy40aC00LjN2NC4ySDgxdi00LjJaTTgwLjkgMTExLjloLTQuM3Y0LjJIODF2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNODAuOSAxMTYuMWgtNC4zdjQuMkg4MXYtNC4yWk04MC45IDEyNC42aC00LjN2NC4ySDgxdi00LjJaTTgwLjkgMTM3LjNoLTQuM3Y0LjNIODF2LTQuM1pNODUuMSAxOC40SDgxdjQuM0g4NXYtNC4zWk04NS4xIDMxLjJIODF2NC4ySDg1di00LjJaTTg1LjEgMzUuNEg4MXY0LjNIODV2LTQuM1pNODUuMSA0My45SDgxdjQuMkg4NVY0NFpNODUuMSA1Ni42SDgxVjYxSDg1di00LjNaTTg1LjEgNzMuNkg4MVY3OEg4NXYtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik04NS4xIDc3LjlIODFWODJIODVWNzhaTTg1LjEgODIuMUg4MXY0LjNIODVWODJaTTg1LjEgODYuNEg4MXY0LjJIODV2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNODUuMSA5MC42SDgxVjk1SDg1di00LjNaTTg1LjEgOTkuMUg4MXY0LjNIODVWOTlaTTg1LjEgMTExLjlIODF2NC4ySDg1di00LjJaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTg1LjEgMTE2LjFIODF2NC4ySDg1di00LjJaTTg1LjEgMTMzSDgxdjQuM0g4NXYtNC4yWk04OS40IDIyLjdIODV2NC4yaDQuM3YtNC4yWk04OS40IDMxLjJIODV2NC4yaDQuM3YtNC4yWk04OS40IDM1LjRIODV2NC4zaDQuM3YtNC4zWk04OS40IDQ4LjFIODV2NC4zaDQuM3YtNC4zWk04OS40IDUyLjRIODV2NC4yaDQuM3YtNC4yWk04OS40IDU2LjZIODVWNjFoNC4zdi00LjNaTTg5LjQgNjUuMUg4NXY0LjNoNC4zVjY1Wk04OS40IDEwMy40SDg1djQuMmg0LjN2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNODkuNCAxMDcuNkg4NXY0LjNoNC4zdi00LjNaTTg5LjQgMTExLjlIODV2NC4yaDQuM3YtNC4yWk04OS40IDEyOC44SDg1djQuM2g0LjN2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNODkuNCAxMzNIODV2NC4zaDQuM3YtNC4yWk05My42IDE4LjRoLTQuMnY0LjNoNC4ydi00LjNaTTkzLjYgMjIuN2gtNC4ydjQuMmg0LjJ2LTQuMlpNOTMuNiAyN2gtNC4ydjQuMmg0LjJ2LTQuM1pNOTMuNiAzMS4yaC00LjJ2NC4yaDQuMnYtNC4yWk05My42IDQzLjloLTQuMnY0LjJoNC4yVjQ0Wk05My42IDU2LjZoLTQuMlY2MWg0LjJ2LTQuM1pNOTMuNiA2NS4xaC00LjJ2NC4zaDQuMlY2NVpNOTMuNiA2OS40aC00LjJ2NC4yaDQuMnYtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik05My42IDczLjZoLTQuMlY3OGg0LjJ2LTQuM1pNOTMuNiA4Mi4xaC00LjJ2NC4zaDQuMlY4MlpNOTMuNiA4Ni40aC00LjJ2NC4yaDQuMnYtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik05My42IDkwLjZoLTQuMlY5NWg0LjJ2LTQuM1pNOTMuNiAxMTEuOWgtNC4ydjQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNOTMuNiAxMTYuMWgtNC4ydjQuMmg0LjJ2LTQuMlpNOTcuOSAxOC40aC00LjN2NC4zSDk4di00LjNaTTk3LjkgMjIuN2gtNC4zdjQuMkg5OHYtNC4yWk05Ny45IDM1LjRoLTQuM3Y0LjNIOTh2LTQuM1pNOTcuOSA0OC4xaC00LjN2NC4zSDk4di00LjNaTTk3LjkgNTIuNGgtNC4zdjQuMkg5OHYtNC4yWk05Ny45IDYwLjloLTQuM1Y2NUg5OFY2MVpNOTcuOSA2NS4xaC00LjN2NC4zSDk4VjY1Wk05Ny45IDczLjZoLTQuM1Y3OEg5OHYtNC4zWk05Ny45IDgyLjFoLTQuM3Y0LjNIOThWODJaTTk3LjkgODYuNGgtNC4zdjQuMkg5OHYtNC4yWk05Ny45IDk0LjloLTQuM1Y5OUg5OFY5NVpNOTcuOSA5OS4xaC00LjN2NC4zSDk4Vjk5Wk05Ny45IDEwMy40aC00LjN2NC4ySDk4di00LjJaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTk3LjkgMTA3LjZoLTQuM3Y0LjNIOTh2LTQuM1pNOTcuOSAxMTYuMWgtNC4zdjQuMkg5OHYtNC4yWk05Ny45IDEyNC42aC00LjN2NC4ySDk4di00LjJaTTk3LjkgMTMzaC00LjN2NC4zSDk4di00LjJaTTEwMi4xIDIyLjdIOTh2NC4yaDQuMnYtNC4yWk0xMDIuMSA0My45SDk4djQuMmg0LjJWNDRaTTEwMi4xIDUyLjRIOTh2NC4yaDQuMnYtNC4yWk0xMDIuMSA2OS40SDk4djQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTAyLjEgNzMuNkg5OFY3OGg0LjJ2LTQuM1pNMTAyLjEgOTAuNkg5OFY5NWg0LjJ2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTAyLjEgOTQuOUg5OFY5OWg0LjJWOTVaTTEwMi4xIDEwMy40SDk4djQuMmg0LjJ2LTQuMlpNMTAyLjEgMTMzSDk4djQuM2g0LjJ2LTQuMlpNMTAyLjEgMTM3LjNIOTh2NC4zaDQuMnYtNC4zWk0xMDYuNCAxOC40SDEwMnY0LjNoNC4zdi00LjNaTTEwNi40IDIyLjdIMTAydjQuMmg0LjN2LTQuMlpNMTA2LjQgNDguMUgxMDJ2NC4zaDQuM3YtNC4zWk0xMDYuNCA2MC45SDEwMlY2NWg0LjNWNjFaTTEwNi40IDczLjZIMTAyVjc4aDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMDYuNCA3Ny45SDEwMlY4Mmg0LjNWNzhaTTEwNi40IDgyLjFIMTAydjQuM2g0LjNWODJaTTEwNi40IDg2LjRIMTAydjQuMmg0LjN2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTA2LjQgOTAuNkgxMDJWOTVoNC4zdi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTEwNi40IDk0LjlIMTAyVjk5aDQuM1Y5NVpNMTA2LjQgMTAzLjRIMTAydjQuMmg0LjN2LTQuMlpNMTA2LjQgMTE2LjFIMTAydjQuMmg0LjN2LTQuMlpNMTA2LjQgMTMzSDEwMnY0LjNoNC4zdi00LjJaTTEwNi40IDEzNy4zSDEwMnY0LjNoNC4zdi00LjNaTTExMC42IDI3aC00LjJ2NC4yaDQuMnYtNC4zWk0xMTAuNiAzOS43aC00LjJ2NC4yaDQuMnYtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMTAuNiA0My45aC00LjJ2NC4yaDQuMlY0NFpNMTEwLjYgNDguMWgtNC4ydjQuM2g0LjJ2LTQuM1pNMTEwLjYgNTIuNGgtNC4ydjQuMmg0LjJ2LTQuMlpNMTEwLjYgNTYuNmgtNC4yVjYxaDQuMnYtNC4zWk0xMTAuNiA5MC42aC00LjJWOTVoNC4ydi00LjNaTTExMC42IDk5LjFoLTQuMnY0LjNoNC4yVjk5Wk0xMTAuNiAxMDcuNmgtNC4ydjQuM2g0LjJ2LTQuM1pNMTEwLjYgMTE2LjFoLTQuMnY0LjJoNC4ydi00LjJaTTExMC42IDEyMC4zaC00LjJ2NC4zaDQuMnYtNC4zWk0xMTAuNiAxMzNoLTQuMnY0LjNoNC4ydi00LjJaTTExMC42IDEzNy4zaC00LjJ2NC4zaDQuMnYtNC4zWk0xMTQuOSAzMS4yaC00LjN2NC4yaDQuM3YtNC4yWk0xMTQuOSA0OC4xaC00LjN2NC4zaDQuM3YtNC4zWk0xMTQuOSA1Mi40aC00LjN2NC4yaDQuM3YtNC4yWk0xMTQuOSA2NS4xaC00LjN2NC4zaDQuM1Y2NVpNMTE0LjkgNzMuNmgtNC4zVjc4aDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMTQuOSA3Ny45aC00LjNWODJoNC4zVjc4Wk0xMTQuOSA4Mi4xaC00LjN2NC4zaDQuM1Y4MlpNMTE0LjkgODYuNGgtNC4zdjQuMmg0LjN2LTQuMlpNMTE0LjkgOTkuMWgtNC4zdjQuM2g0LjNWOTlaTTExNC45IDExMS45aC00LjN2NC4yaDQuM3YtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMTQuOSAxMTYuMWgtNC4zdjQuMmg0LjN2LTQuMlpNMTE0LjkgMTMzaC00LjN2NC4zaDQuM3YtNC4yWk0xMTQuOSAxMzcuM2gtNC4zdjQuM2g0LjN2LTQuM1pNMTE5LjEgMTguNEgxMTV2NC4zaDQuMnYtNC4zWk0xMTkuMSAzNS40SDExNXY0LjNoNC4ydi00LjNaTTExOS4xIDM5LjdIMTE1djQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTE5LjEgNDMuOUgxMTV2NC4yaDQuMlY0NFpNMTE5LjEgNjUuMUgxMTV2NC4zaDQuMlY2NVpNMTE5LjEgNjkuNEgxMTV2NC4yaDQuMnYtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMTkuMSA3My42SDExNVY3OGg0LjJ2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTE5LjEgNzcuOUgxMTVWODJoNC4yVjc4Wk0xMTkuMSA4Mi4xSDExNXY0LjNoNC4yVjgyWk0xMTkuMSA4Ni40SDExNXY0LjJoNC4ydi00LjJaTTExOS4xIDk0LjlIMTE1Vjk5aDQuMlY5NVpNMTE5LjEgMTAzLjRIMTE1djQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTE5LjEgMTA3LjZIMTE1djQuM2g0LjJ2LTQuM1pNMTE5LjEgMTE2LjFIMTE1djQuMmg0LjJ2LTQuMlpNMTE5LjEgMTI0LjZIMTE1djQuMmg0LjJ2LTQuMlpNMTE5LjEgMTMzSDExNXY0LjNoNC4ydi00LjJaTTExOS4xIDEzNy4zSDExNXY0LjNoNC4ydi00LjNaTTEyMy40IDMxLjJIMTE5djQuMmg0LjN2LTQuMlpNMTIzLjQgMzUuNEgxMTl2NC4zaDQuM3YtNC4zWk0xMjMuNCA1Ni42SDExOVY2MWg0LjN2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTIzLjQgNjAuOUgxMTlWNjVoNC4zVjYxWk0xMjMuNCA5MC42SDExOVY5NWg0LjN2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTIzLjQgOTQuOUgxMTlWOTloNC4zVjk1Wk0xMjMuNCA5OS4xSDExOXY0LjNoNC4zVjk5Wk0xMjMuNCAxMjAuM0gxMTl2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMjMuNCAxMjQuNkgxMTl2NC4yaDQuM3YtNC4yWk0xMjcuNiAxOC40aC00LjJ2NC4zaDQuMnYtNC4zWk0xMjcuNiAyN2gtNC4ydjQuMmg0LjJ2LTQuM1pNMTI3LjYgMzEuMmgtNC4ydjQuMmg0LjJ2LTQuMlpNMTI3LjYgMzkuN2gtNC4ydjQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTI3LjYgNDMuOWgtNC4ydjQuMmg0LjJWNDRaTTEyNy42IDQ4LjFoLTQuMnY0LjNoNC4ydi00LjNaTTEyNy42IDU2LjZoLTQuMlY2MWg0LjJ2LTQuM1pNMTI3LjYgNjUuMWgtNC4ydjQuM2g0LjJWNjVaTTEyNy42IDY5LjRoLTQuMnY0LjJoNC4ydi00LjJaTTEyNy42IDc3LjloLTQuMlY4Mmg0LjJWNzhaTTEyNy42IDgyLjFoLTQuMnY0LjNoNC4yVjgyWk0xMjcuNiA5OS4xaC00LjJ2NC4zaDQuMlY5OVpNMTI3LjYgMTAzLjRoLTQuMnY0LjJoNC4ydi00LjJaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTEyNy42IDEwNy42aC00LjJ2NC4zaDQuMnYtNC4zWk0xMjcuNiAxMTEuOWgtNC4ydjQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTI3LjYgMTE2LjFoLTQuMnY0LjJoNC4ydi00LjJaTTEyNy42IDEyMC4zaC00LjJ2NC4zaDQuMnYtNC4zWk0xMjcuNiAxMjguOGgtNC4ydjQuM2g0LjJ2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTI3LjYgMTMzaC00LjJ2NC4zaDQuMnYtNC4yWk0xMzEuOSA1Mi40aC00LjN2NC4yaDQuM3YtNC4yWk0xMzEuOSA2NS4xaC00LjN2NC4zaDQuM1Y2NVpNMTMxLjkgNzcuOWgtNC4zVjgyaDQuM1Y3OFpNMTMxLjkgODYuNGgtNC4zdjQuMmg0LjN2LTQuMlpNMTMxLjkgOTQuOWgtNC4zVjk5aDQuM1Y5NVpNMTMxLjkgMTAzLjRoLTQuM3Y0LjJoNC4zdi00LjJaTTEzMS45IDEyMC4zaC00LjN2NC4zaDQuM3YtNC4zWk0xMzEuOSAxMzNoLTQuM3Y0LjNoNC4zdi00LjJaTTEzMS45IDEzNy4zaC00LjN2NC4zaDQuM3YtNC4zWk0xMzYuMSA1Mi40aC00LjJ2NC4yaDQuMnYtNC4yWk0xMzYuMSA1Ni42aC00LjJWNjFoNC4ydi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTEzNi4xIDYwLjloLTQuMlY2NWg0LjJWNjFaTTEzNi4xIDY1LjFoLTQuMnY0LjNoNC4yVjY1Wk0xMzYuMSA2OS40aC00LjJ2NC4yaDQuMnYtNC4yWk0xMzYuMSA5NC45aC00LjJWOTloNC4yVjk1Wk0xMzYuMSAxMDMuNGgtNC4ydjQuMmg0LjJ2LTQuMlpNMTM2LjEgMTExLjloLTQuMnY0LjJoNC4ydi00LjJaTTEzNi4xIDEyMC4zaC00LjJ2NC4zaDQuMnYtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMzYuMSAxMjQuNmgtNC4ydjQuMmg0LjJ2LTQuMlpNMTM2LjEgMTI4LjhoLTQuMnY0LjNoNC4ydi00LjNaTTE0MC4zIDUyLjRoLTQuMnY0LjJoNC4ydi00LjJaTTE0MC4zIDU2LjZoLTQuMlY2MWg0LjJ2LTQuM1pNMTQwLjMgNjUuMWgtNC4ydjQuM2g0LjJWNjVaTTE0MC4zIDczLjZoLTQuMlY3OGg0LjJ2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTQwLjMgNzcuOWgtNC4yVjgyaDQuMlY3OFpNMTQwLjMgODYuNGgtNC4ydjQuMmg0LjJ2LTQuMlpNMTQwLjMgOTQuOWgtNC4yVjk5aDQuMlY5NVpNMTQwLjMgMTAzLjRoLTQuMnY0LjJoNC4ydi00LjJaTTE0MC4zIDEyMC4zaC00LjJ2NC4zaDQuMnYtNC4zWk0xNDAuMyAxMjguOGgtNC4ydjQuM2g0LjJ2LTQuM1pNMTQ0LjYgNjAuOWgtNC4zVjY1aDQuM1Y2MVoiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTQ0LjYgODYuNGgtNC4zdjQuMmg0LjN2LTQuMlpNMTQ0LjYgMTAzLjRoLTQuM3Y0LjJoNC4zdi00LjJaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE0NC42IDEwNy42aC00LjN2NC4zaDQuM3YtNC4zWk0xNDQuNiAxMTEuOWgtNC4zdjQuMmg0LjN2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTQ0LjYgMTE2LjFoLTQuM3Y0LjJoNC4zdi00LjJaTTE0NC42IDEyMC4zaC00LjN2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xNDQuNiAxMjQuNmgtNC4zdjQuMmg0LjN2LTQuMlpNMTQ4LjggNTIuNGgtNC4ydjQuMmg0LjJ2LTQuMlpNMTQ4LjggNjAuOWgtNC4yVjY1aDQuMlY2MVpNMTQ4LjggNzMuNmgtNC4yVjc4aDQuMnYtNC4zWk0xNDguOCA4Mi4xaC00LjJ2NC4zaDQuMlY4MlpNMTQ4LjggODYuNGgtNC4ydjQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTQ4LjggOTAuNmgtNC4yVjk1aDQuMnYtNC4zWk0xNDguOCA5OS4xaC00LjJ2NC4zaDQuMlY5OVpNMTQ4LjggMTAzLjRoLTQuMnY0LjJoNC4ydi00LjJaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE0OC44IDEwNy42aC00LjJ2NC4zaDQuMnYtNC4zWk0xNDguOCAxMTEuOWgtNC4ydjQuMmg0LjJ2LTQuMlpNMTQ4LjggMTIwLjNoLTQuMnY0LjNoNC4ydi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE0OC44IDEyNC42aC00LjJ2NC4yaDQuMnYtNC4yWk0xNDguOCAxMjguOGgtNC4ydjQuM2g0LjJ2LTQuM1pNMTQ4LjggMTM3LjNoLTQuMnY0LjNoNC4ydi00LjNaTTE1MyA1Ni42aC00LjJWNjFoNC4zdi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE1MyA2MC45aC00LjJWNjVoNC4zVjYxWk0xNTMgNjUuMWgtNC4ydjQuM2g0LjNWNjVaTTE1MyA5NC45aC00LjJWOTloNC4zVjk1Wk0xNTMgOTkuMWgtNC4ydjQuM2g0LjNWOTlaTTE1MyAxMDMuNGgtNC4ydjQuMmg0LjN2LTQuMlpNMTUzIDEyMC4zaC00LjJ2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xNTMgMTI0LjZoLTQuMnY0LjJoNC4zdi00LjJaTTE1MyAxMzcuM2gtNC4ydjQuM2g0LjN2LTQuM1pNMTU3LjMgNTIuNGgtNC4ydjQuMmg0LjJ2LTQuMlpNMTU3LjMgNTYuNmgtNC4yVjYxaDQuMnYtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xNTcuMyA2MC45aC00LjJWNjVoNC4yVjYxWk0xNTcuMyA2NS4xaC00LjJ2NC4zaDQuMlY2NVpNMTU3LjMgOTAuNmgtNC4yVjk1aDQuMnYtNC4zWk0xNTcuMyAxMTYuMWgtNC4ydjQuMmg0LjJ2LTQuMlpNMTU3LjMgMTI0LjZoLTQuMnY0LjJoNC4ydi00LjJaTTE1Ny4zIDEzM2gtNC4ydjQuM2g0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTYxLjYgNTYuNmgtNC4zVjYxaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xNjEuNiA2MC45aC00LjNWNjVoNC4zVjYxWk0xNjEuNiA2NS4xaC00LjN2NC4zaDQuM1Y2NVpNMTYxLjYgNzMuNmgtNC4zVjc4aDQuM3YtNC4zWk0xNjEuNiA5MC42aC00LjNWOTVoNC4zdi00LjNaTTE2MS42IDEwMy40aC00LjN2NC4yaDQuM3YtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xNjEuNiAxMDcuNmgtNC4zdjQuM2g0LjN2LTQuM1pNMTYxLjYgMTExLjloLTQuM3Y0LjJoNC4zdi00LjJaTTE2MS42IDEyMC4zaC00LjN2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xNjEuNiAxMjQuNmgtNC4zdjQuMmg0LjN2LTQuMlpNMTYxLjYgMTMzaC00LjN2NC4zaDQuM3YtNC4yWiIvPjwvbWFzaz48ZyBtYXNrPSJ1cmwoI2IpIj48cGF0aCBmaWxsPSIjMDAwIiBkPSJNMTYxLjYgMTguNEgzOC40djEyMy4yaDEyMy4yVjE4LjRaIi8+PC9nPjxtYXNrIGlkPSJjIiB3aWR0aD0iMzEiIGhlaWdodD0iMzEiIHg9IjM4IiB5PSIxOCIgbWFza1VuaXRzPSJ1c2VyU3BhY2VPblVzZSIgc3R5bGU9Im1hc2stdHlwZTpsdW1pbmFuY2UiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik00Mi43IDE4LjRoLTQuM3Y0LjNoNC4zdi00LjNaTTQyLjcgMjIuN2gtNC4zdjQuMmg0LjN2LTQuMlpNNDIuNyAyN2gtNC4zdjQuMmg0LjN2LTQuM1pNNDIuNyAzMS4yaC00LjN2NC4yaDQuM3YtNC4yWk00Mi43IDM1LjRoLTQuM3Y0LjNoNC4zdi00LjNaTTQyLjcgMzkuN2gtNC4zdjQuMmg0LjN2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNNDIuNyA0My45aC00LjN2NC4yaDQuM1Y0NFpNNDcgMTguNGgtNC4zdjQuM2g0LjJ2LTQuM1pNNDcgNDMuOWgtNC4zdjQuMmg0LjJWNDRaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTUxLjIgMTguNGgtNC4zdjQuM2g0LjN2LTQuM1pNNTEuMiA0My45aC00LjN2NC4yaDQuM1Y0NFoiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNNTUuNCAxOC40aC00LjJ2NC4zaDQuMnYtNC4zWk01NS40IDQzLjloLTQuMnY0LjJoNC4yVjQ0Wk01OS43IDE4LjRoLTQuM3Y0LjNoNC4zdi00LjNaTTU5LjcgNDMuOWgtNC4zdjQuMmg0LjNWNDRaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTYzLjkgMTguNGgtNC4ydjQuM2g0LjJ2LTQuM1pNNjMuOSA0My45aC00LjJ2NC4yaDQuMlY0NFpNNjguMSAxOC40SDY0djQuM2g0LjJ2LTQuM1pNNjguMSAyMi43SDY0djQuMmg0LjJ2LTQuMlpNNjguMSAyN0g2NHY0LjJoNC4ydi00LjNaTTY4LjEgMzEuMkg2NHY0LjJoNC4ydi00LjJaTTY4LjEgMzUuNEg2NHY0LjNoNC4ydi00LjNaTTY4LjEgMzkuN0g2NHY0LjJoNC4ydi00LjJaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTY4LjEgNDMuOUg2NHY0LjJoNC4yVjQ0WiIvPjwvbWFzaz48ZyBtYXNrPSJ1cmwoI2MpIj48cGF0aCBmaWxsPSIjMDAwIiBkPSJNNjguMSAxOC40SDM4LjR2MjkuN2gyOS43VjE4LjRaIi8+PC9nPjxtYXNrIGlkPSJkIiB3aWR0aD0iMTQiIGhlaWdodD0iMTQiIHg9IjQ2IiB5PSIyNiIgbWFza1VuaXRzPSJ1c2VyU3BhY2VPblVzZSIgc3R5bGU9Im1hc2stdHlwZTpsdW1pbmFuY2UiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik01MS4yIDI3aC00LjN2NC4yaDQuM3YtNC4zWk01MS4yIDMxLjJoLTQuM3Y0LjJoNC4zdi00LjJaTTUxLjIgMzUuNGgtNC4zdjQuM2g0LjN2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNNTUuNCAyN2gtNC4ydjQuMmg0LjJ2LTQuM1pNNTUuNCAzMS4yaC00LjJ2NC4yaDQuMnYtNC4yWk01NS40IDM1LjRoLTQuMnY0LjNoNC4ydi00LjNaTTU5LjcgMjdoLTQuM3Y0LjJoNC4zdi00LjNaTTU5LjcgMzEuMmgtNC4zdjQuMmg0LjN2LTQuMlpNNTkuNyAzNS40aC00LjN2NC4zaDQuM3YtNC4zWiIvPjwvbWFzaz48ZyBtYXNrPSJ1cmwoI2QpIj48cGF0aCBmaWxsPSIjMDAwIiBkPSJNNTkuNyAyN0g0Ni45djEyLjdoMTIuOFYyNi45WiIvPjwvZz48bWFzayBpZD0iZSIgd2lkdGg9IjMxIiBoZWlnaHQ9IjMxIiB4PSIxMzEiIHk9IjE4IiBtYXNrVW5pdHM9InVzZXJTcGFjZU9uVXNlIiBzdHlsZT0ibWFzay10eXBlOmx1bWluYW5jZSI+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTEzNi4xIDE4LjRoLTQuMnY0LjNoNC4ydi00LjNaTTEzNi4xIDIyLjdoLTQuMnY0LjJoNC4ydi00LjJaTTEzNi4xIDI3aC00LjJ2NC4yaDQuMnYtNC4zWk0xMzYuMSAzMS4yaC00LjJ2NC4yaDQuMnYtNC4yWk0xMzYuMSAzNS40aC00LjJ2NC4zaDQuMnYtNC4zWk0xMzYuMSAzOS43aC00LjJ2NC4yaDQuMnYtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMzYuMSA0My45aC00LjJ2NC4yaDQuMlY0NFpNMTQwLjMgMTguNGgtNC4ydjQuM2g0LjJ2LTQuM1pNMTQwLjMgNDMuOWgtNC4ydjQuMmg0LjJWNDRaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE0NC42IDE4LjRoLTQuM3Y0LjNoNC4zdi00LjNaTTE0NC42IDQzLjloLTQuM3Y0LjJoNC4zVjQ0Wk0xNDguOCAxOC40aC00LjJ2NC4zaDQuMnYtNC4zWk0xNDguOCA0My45aC00LjJ2NC4yaDQuMlY0NFpNMTUzIDE4LjRoLTQuMnY0LjNoNC4zdi00LjNaTTE1MyA0My45aC00LjJ2NC4yaDQuM1Y0NFpNMTU3LjMgMTguNGgtNC4ydjQuM2g0LjJ2LTQuM1pNMTU3LjMgNDMuOWgtNC4ydjQuMmg0LjJWNDRaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE2MS42IDE4LjRoLTQuM3Y0LjNoNC4zdi00LjNaTTE2MS42IDIyLjdoLTQuM3Y0LjJoNC4zdi00LjJaTTE2MS42IDI3aC00LjN2NC4yaDQuM3YtNC4zWk0xNjEuNiAzMS4yaC00LjN2NC4yaDQuM3YtNC4yWk0xNjEuNiAzNS40aC00LjN2NC4zaDQuM3YtNC4zWk0xNjEuNiAzOS43aC00LjN2NC4yaDQuM3YtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xNjEuNiA0My45aC00LjN2NC4yaDQuM1Y0NFoiLz48L21hc2s+PGcgbWFzaz0idXJsKCNlKSI+PHBhdGggZmlsbD0iIzAwMCIgZD0iTTE2MS42IDE4LjRoLTI5Ljd2MjkuN2gyOS43VjE4LjRaIi8+PC9nPjxtYXNrIGlkPSJmIiB3aWR0aD0iMTQiIGhlaWdodD0iMTQiIHg9IjE0MCIgeT0iMjYiIG1hc2tVbml0cz0idXNlclNwYWNlT25Vc2UiIHN0eWxlPSJtYXNrLXR5cGU6bHVtaW5hbmNlIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTQ0LjYgMjdoLTQuM3Y0LjJoNC4zdi00LjNaTTE0NC42IDMxLjJoLTQuM3Y0LjJoNC4zdi00LjJaTTE0NC42IDM1LjRoLTQuM3Y0LjNoNC4zdi00LjNaTTE0OC44IDI3aC00LjJ2NC4yaDQuMnYtNC4zWk0xNDguOCAzMS4yaC00LjJ2NC4yaDQuMnYtNC4yWk0xNDguOCAzNS40aC00LjJ2NC4zaDQuMnYtNC4zWk0xNTMgMjdoLTQuMnY0LjJoNC4zdi00LjNaTTE1MyAzMS4yaC00LjJ2NC4yaDQuM3YtNC4yWk0xNTMgMzUuNGgtNC4ydjQuM2g0LjN2LTQuM1oiLz48L21hc2s+PGcgbWFzaz0idXJsKCNmKSI+PHBhdGggZmlsbD0iIzAwMCIgZD0iTTE1MyAyN2gtMTIuN3YxMi43aDEyLjhWMjYuOVoiLz48L2c+PG1hc2sgaWQ9ImciIHdpZHRoPSIzMSIgaGVpZ2h0PSIzMSIgeD0iMzgiIHk9IjExMSIgbWFza1VuaXRzPSJ1c2VyU3BhY2VPblVzZSIgc3R5bGU9Im1hc2stdHlwZTpsdW1pbmFuY2UiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik00Mi43IDExMS45aC00LjN2NC4yaDQuM3YtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik00Mi43IDExNi4xaC00LjN2NC4yaDQuM3YtNC4yWk00Mi43IDEyMC4zaC00LjN2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik00Mi43IDEyNC42aC00LjN2NC4yaDQuM3YtNC4yWk00Mi43IDEyOC44aC00LjN2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik00Mi43IDEzM2gtNC4zdjQuM2g0LjN2LTQuMlpNNDIuNyAxMzcuM2gtNC4zdjQuM2g0LjN2LTQuM1pNNDcgMTExLjloLTQuM3Y0LjJoNC4ydi00LjJaTTQ3IDEzNy4zaC00LjN2NC4zaDQuMnYtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik01MS4yIDExMS45aC00LjN2NC4yaDQuM3YtNC4yWk01MS4yIDEzNy4zaC00LjN2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik01NS40IDExMS45aC00LjJ2NC4yaDQuMnYtNC4yWk01NS40IDEzNy4zaC00LjJ2NC4zaDQuMnYtNC4zWk01OS43IDExMS45aC00LjN2NC4yaDQuM3YtNC4yWk01OS43IDEzNy4zaC00LjN2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik02My45IDExMS45aC00LjJ2NC4yaDQuMnYtNC4yWk02My45IDEzNy4zaC00LjJ2NC4zaDQuMnYtNC4zWk02OC4xIDExMS45SDY0djQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNNjguMSAxMTYuMUg2NHY0LjJoNC4ydi00LjJaTTY4LjEgMTIwLjNINjR2NC4zaDQuMnYtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik02OC4xIDEyNC42SDY0djQuMmg0LjJ2LTQuMlpNNjguMSAxMjguOEg2NHY0LjNoNC4ydi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTY4LjEgMTMzSDY0djQuM2g0LjJ2LTQuMlpNNjguMSAxMzcuM0g2NHY0LjNoNC4ydi00LjNaIi8+PC9tYXNrPjxnIG1hc2s9InVybCgjZykiPjxwYXRoIGZpbGw9IiMwMDAiIGQ9Ik02OC4xIDExMS45SDM4LjR2MjkuN2gyOS43di0yOS43WiIvPjwvZz48bWFzayBpZD0iaCIgd2lkdGg9IjE0IiBoZWlnaHQ9IjE0IiB4PSI0NiIgeT0iMTIwIiBtYXNrVW5pdHM9InVzZXJTcGFjZU9uVXNlIiBzdHlsZT0ibWFzay10eXBlOmx1bWluYW5jZSI+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTUxLjIgMTIwLjNoLTQuM3Y0LjNoNC4zdi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTUxLjIgMTI0LjZoLTQuM3Y0LjJoNC4zdi00LjJaTTUxLjIgMTI4LjhoLTQuM3Y0LjNoNC4zdi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTU1LjQgMTIwLjNoLTQuMnY0LjNoNC4ydi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTU1LjQgMTI0LjZoLTQuMnY0LjJoNC4ydi00LjJaTTU1LjQgMTI4LjhoLTQuMnY0LjNoNC4ydi00LjNaTTU5LjcgMTIwLjNoLTQuM3Y0LjNoNC4zdi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTU5LjcgMTI0LjZoLTQuM3Y0LjJoNC4zdi00LjJaTTU5LjcgMTI4LjhoLTQuM3Y0LjNoNC4zdi00LjNaIi8+PC9tYXNrPjxnIG1hc2s9InVybCgjaCkiPjxwYXRoIGZpbGw9IiMwMDAiIGQ9Ik01OS43IDEyMC4zSDQ2Ljl2MTIuOGgxMi44di0xMi44WiIvPjwvZz48L2c+PGRlZnM+PGNsaXBQYXRoIGlkPSJhIj48cmVjdCB3aWR0aD0iMTM3IiBoZWlnaHQ9IjEzNyIgeD0iMzEuNSIgeT0iMTEuNSIgZmlsbD0iI2ZmZiIgcng9IjguMiIvPjwvY2xpcFBhdGg+PC9kZWZzPjwvc3ZnPg==", @@ -15,7 +15,7 @@ "settingsSchema": "", "dataKeySettingsSchema": "", "settingsDirective": "tb-mobile-app-qr-code-widget-settings", - "defaultConfig": "{\n \"showTitle\": true,\n \"backgroundColor\": \"rgb(255, 255, 255)\",\n \"color\": \"rgba(0, 0, 0, 0.87)\",\n \"padding\": \"8px\",\n \"settings\": {\n \"useDefaultApp\": true,\n \"androidConfig\": {\n \"enabled\": true,\n \"appPackage\": \"\",\n \"sha256CertFingerprints\": \"\"\n },\n \"iosConfig\": {\n \"enabled\": true,\n \"appId\": \"\"\n },\n \"qrCodeConfig\": {\n \"badgeEnabled\": true,\n \"badgeStyle\": \"ORIGINAL\",\n \"badgePosition\": \"RIGHT\",\n \"qrCodeLabelEnabled\": true,\n \"qrCodeLabel\": \"Scan to connect or download mobile app\"\n }\n },\n \"title\": \"Mobile app QR code\",\n \"dropShadow\": true,\n \"enableFullscreen\": false,\n \"widgetStyle\": {},\n \"widgetCss\": \"\",\n \"pageSize\": 1024,\n \"noDataDisplayMessage\": \"\",\n \"showTitleIcon\": false,\n \"titleTooltip\": \"\",\n \"titleStyle\": {\n \"fontSize\": \"16px\",\n \"fontWeight\": 400\n }\n}" + "defaultConfig": "{\n \"showTitle\": true,\n \"backgroundColor\": \"rgb(255, 255, 255)\",\n \"color\": \"rgba(0, 0, 0, 0.87)\",\n \"padding\": \"8px\",\n \"settings\": {\n \"useSystemSettings\": true,\n \"androidConfig\": {\n \"enabled\": true\n },\n \"iosConfig\": {\n \"enabled\": true\n },\n \"qrCodeConfig\": {\n \"badgeEnabled\": true,\n \"badgeStyle\": \"ORIGINAL\",\n \"badgePosition\": \"RIGHT\",\n \"qrCodeLabelEnabled\": true,\n \"qrCodeLabel\": \"Scan to connect or download mobile app\"\n }\n },\n \"title\": \"Mobile app QR code\",\n \"dropShadow\": true,\n \"enableFullscreen\": false,\n \"widgetStyle\": {},\n \"widgetCss\": \"\",\n \"pageSize\": 1024,\n \"noDataDisplayMessage\": \"\",\n \"showTitleIcon\": false,\n \"titleTooltip\": \"\",\n \"titleStyle\": {\n \"fontSize\": \"16px\",\n \"fontWeight\": 400\n }\n}\n" }, "tags": [ "ios", diff --git a/ui-ngx/src/app/core/auth/auth.selectors.ts b/ui-ngx/src/app/core/auth/auth.selectors.ts index 0a97ef5665..2d84955936 100644 --- a/ui-ngx/src/app/core/auth/auth.selectors.ts +++ b/ui-ngx/src/app/core/auth/auth.selectors.ts @@ -83,7 +83,7 @@ export const selectMobileQrEnabled = createSelector( (state: AuthState) => state.mobileQrEnabled ); -export const selectPersistDeviceStateToTelemetryAndMobileQrEnabled = createSelector( +export const selectHomeDashboardParams = createSelector( selectPersistDeviceStateToTelemetry, selectMobileQrEnabled, (persistDeviceStateToTelemetry, mobileQrEnabled) => ({persistDeviceStateToTelemetry, mobileQrEnabled}) diff --git a/ui-ngx/src/app/core/http/mobile-app.service.ts b/ui-ngx/src/app/core/http/mobile-app.service.ts index bb4dc6c863..7b108e487c 100644 --- a/ui-ngx/src/app/core/http/mobile-app.service.ts +++ b/ui-ngx/src/app/core/http/mobile-app.service.ts @@ -18,7 +18,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils'; import { Observable } from 'rxjs'; -import { MobileAppQRCodeSettings } from '@shared/models/mobile-app.models'; +import { MobileAppSettings } from '@shared/models/mobile-app.models'; @Injectable({ providedIn: 'root' @@ -30,15 +30,15 @@ export class MobileAppService { ) { } - public getMobileAppSettings(config?: RequestConfig): Observable { - return this.http.get(`/api/mobile/app/settings`, defaultHttpOptionsFromConfig(config)); + public getMobileAppSettings(config?: RequestConfig): Observable { + return this.http.get(`/api/mobile/app/settings`, defaultHttpOptionsFromConfig(config)); } - public saveMobileAppSettings(mobileAppSettings: MobileAppQRCodeSettings, config?: RequestConfig): Observable { - return this.http.post(`/api/mobile/app/settings`, mobileAppSettings, defaultHttpOptionsFromConfig(config)); + public saveMobileAppSettings(mobileAppSettings: MobileAppSettings, config?: RequestConfig): Observable { + return this.http.post(`/api/mobile/app/settings`, mobileAppSettings, defaultHttpOptionsFromConfig(config)); } - public getMobileAppDeepLink( config?: RequestConfig): Observable { + public getMobileAppDeepLink(config?: RequestConfig): Observable { return this.http.get(`/api/mobile/deepLink`, defaultHttpOptionsFromConfig(config)); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/mobile-app-qr-code-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/cards/mobile-app-qr-code-widget.models.ts index 4027e0445b..1b280d68c1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/mobile-app-qr-code-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/mobile-app-qr-code-widget.models.ts @@ -22,23 +22,20 @@ import { QRCodeConfig } from '@shared/models/mobile-app.models'; -export type MobileAppQrCodeWidgetSettings = { - useDefaultApp: boolean; - androidConfig: AndroidConfig; - iosConfig: IosConfig; +export interface MobileAppQrCodeWidgetSettings { + useSystemSettings: boolean; + androidConfig: Pick; + iosConfig: Pick; qrCodeConfig: Omit; } export const mobileAppQrCodeWidgetDefaultSettings: MobileAppQrCodeWidgetSettings = { - useDefaultApp: true, + useSystemSettings: true, androidConfig: { - enabled: true, - appPackage: '', - sha256CertFingerprints: '' + enabled: true }, iosConfig: { - enabled: true, - appId: '' + enabled: true }, qrCodeConfig: { badgeEnabled: true, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.html index 35092807dc..2c2623cc0c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.html @@ -15,16 +15,12 @@ limitations under the License. --> -
admin.mobile-app.connect-mobile-app
- -
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.scss index d00baa119e..ddb8870d42 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.scss @@ -23,24 +23,6 @@ background-color: transparent; } -.tb-title { - padding-bottom: 12px; - align-self: start; - font-weight: 600; - font-size: 20px; - line-height: 24px; - letter-spacing: 0.1px; - color: rgba(0, 0, 0, 0.76); - - @media #{$mat-md-lg} { - padding-bottom: 0; - font-weight: 500; - font-size: 14px; - line-height: 20px; - letter-spacing: 0.25px; - } -} - .tb-qrcode-label { font-size: 14px; color: rgba(0, 0, 0, 0.54); @@ -56,8 +38,3 @@ border-radius: 6px; } -.tb-badge-container { - @media #{$mat-md} { - display: none; - } -} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.ts index 5085d43aec..0f450d47f7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.ts @@ -14,97 +14,108 @@ /// limitations under the License. /// -import { AfterViewInit, ChangeDetectorRef, Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { ChangeDetectorRef, Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { PageComponent } from '@shared/components/page.component'; import { AppState } from '@core/core.state'; import { Store } from '@ngrx/store'; -import { BadgePosition, BadgeStyle, badgeStyleURLMap, MobileAppQRCodeSettings } from '@shared/models/mobile-app.models'; +import { BadgePosition, BadgeStyle, badgeStyleURLMap, MobileAppSettings } from '@shared/models/mobile-app.models'; import { MobileAppService } from '@core/http/mobile-app.service'; import { WidgetContext } from '@home/models/widget-component.models'; import { UtilsService } from '@core/services/utils.service'; -import { interval, mergeMap, Observable, Subject, takeUntil } from 'rxjs'; +import { Subject } from 'rxjs'; import { MINUTE } from '@shared/models/time/time.models'; -import { coerceBoolean } from '@shared/decorators/coercion'; import { MobileAppQrCodeWidgetSettings } from '@home/components/widget/lib/cards/mobile-app-qr-code-widget.models'; +import { isDefinedAndNotNull } from '@core/utils'; +import { ResizeObserver } from '@juggle/resize-observer'; @Component({ selector: 'tb-mobile-app-qrcode-widget', templateUrl: './mobile-app-qrcode-widget.component.html', styleUrls: ['./mobile-app-qrcode-widget.component.scss'] }) -export class MobileAppQrcodeWidgetComponent extends PageComponent implements OnInit, AfterViewInit, OnDestroy { +export class MobileAppQrcodeWidgetComponent extends PageComponent implements OnInit, OnDestroy { @Input() ctx: WidgetContext; @Input() - @coerceBoolean() - previewMode: boolean; - - @Input() - set mobileAppSettings(settings: MobileAppQRCodeSettings | MobileAppQrCodeWidgetSettings) { + set mobileAppSettings(settings: MobileAppSettings | MobileAppQrCodeWidgetSettings) { if (settings) { this.mobileAppSettingsValue = settings; } }; - get mobileAppSettings() { + get mobileAppSettings(): MobileAppSettings | MobileAppQrCodeWidgetSettings { return this.mobileAppSettingsValue; } - @ViewChild('canvas', {static: false}) canvasRef: ElementRef; + @ViewChild('canvas', {static: true}) canvasRef: ElementRef; private readonly destroy$ = new Subject(); + private widgetResize$: ResizeObserver; badgeStyle = BadgeStyle; badgePosition = BadgePosition; badgeStyleURLMap = badgeStyleURLMap; + showBadgeContainer = true; - private mobileAppSettingsValue: MobileAppQRCodeSettings | MobileAppQrCodeWidgetSettings; + private mobileAppSettingsValue: MobileAppSettings | MobileAppQrCodeWidgetSettings; private deepLinkTTL: number; + private deepLinkTTLTimeoutID: NodeJS.Timeout; constructor(protected store: Store, protected cd: ChangeDetectorRef, private mobileAppService: MobileAppService, - private utilsService: UtilsService) { + private utilsService: UtilsService, + private elementRef: ElementRef) { super(store); } ngOnInit(): void { - if (!this.previewMode) { - if (this.ctx) { + if (!this.mobileAppSettings) { + if (isDefinedAndNotNull(this.ctx.settings.useSystemSettings) && !this.ctx.settings.useSystemSettings) { this.mobileAppSettings = this.ctx.settings; } else { this.mobileAppService.getMobileAppSettings().subscribe((settings => { this.mobileAppSettings = settings; - this.cd.detectChanges(); + this.cd.markForCheck(); })); } } - } - - ngAfterViewInit(): void { - this.getMobileAppDeepLink().subscribe(link => { - this.deepLinkTTL = Number(this.utilsService.getQueryParam('ttl', link)) * MINUTE; - this.updateQRCode(link); - interval(this.deepLinkTTL).pipe( - takeUntil(this.destroy$), - mergeMap(() => this.getMobileAppDeepLink()) - ).subscribe(link => this.updateQRCode(link)); + this.initMobileAppQRCode(); + this.widgetResize$ = new ResizeObserver(() => { + const showHideBadgeContainer = this.elementRef.nativeElement.offsetWidth > 250; + if (showHideBadgeContainer !== this.showBadgeContainer) { + this.showBadgeContainer = showHideBadgeContainer; + this.cd.markForCheck(); + } }); + this.widgetResize$.observe(this.elementRef.nativeElement); } ngOnDestroy() { + if (this.widgetResize$) { + this.widgetResize$.disconnect(); + } super.ngOnDestroy(); this.destroy$.next(); this.destroy$.complete(); + clearTimeout(this.deepLinkTTLTimeoutID); } - getMobileAppDeepLink(): Observable { - return this.mobileAppService.getMobileAppDeepLink(); + private initMobileAppQRCode() { + if (this.deepLinkTTLTimeoutID) { + clearTimeout(this.deepLinkTTLTimeoutID); + this.deepLinkTTLTimeoutID = null; + } + this.mobileAppService.getMobileAppDeepLink().subscribe(link => { + this.deepLinkTTL = Number(this.utilsService.getQueryParam('ttl', link)) * MINUTE; + this.updateQRCode(link); + this.deepLinkTTLTimeoutID = setTimeout(() => this.initMobileAppQRCode(), this.deepLinkTTL); + }); } - updateQRCode(link: string) { + private updateQRCode(link: string) { import('qrcode').then((QRCode) => { QRCode.toCanvas(this.canvasRef.nativeElement, link, { width: 100 }); }); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.html index d474714216..4d930347c8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.html @@ -15,88 +15,36 @@ limitations under the License. --> -
-
-
-
admin.mobile-app.applications
- - {{ 'admin.mobile-app.default' | translate }} - {{ 'admin.mobile-app.custom' | translate }} - +
+
+
+ + {{ 'admin.mobile-app.use-system-settings' | translate }} +
-
+
+
+
admin.mobile-app.applications
+
- + {{ 'admin.mobile-app.android' | translate }}
-
-
-
{{ 'admin.mobile-app.app-package-name' | translate }}
- - - - warning - - -
-
-
-
-
{{ 'admin.mobile-app.sha256-certificate-fingerprints' | translate }}
- - - - warning - - -
-
- + {{ 'admin.mobile-app.ios' | translate }}
-
-
-
{{ 'admin.mobile-app.app-id' | translate }}
- - - - warning - - -
-
-
+
+
admin.mobile-app.appearance
- {{ 'admin.mobile-app.badges' | translate }} @@ -118,7 +66,7 @@
- + {{ 'admin.mobile-app.label' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.scss deleted file mode 100644 index d0a2c9ac16..0000000000 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.scss +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Copyright © 2016-2024 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT 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-fixed-width { - min-width: 230px; -} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.ts index 664dbe3327..bcb70518f5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.ts @@ -25,7 +25,7 @@ import { mobileAppQrCodeWidgetDefaultSettings } from '@home/components/widget/li @Component({ selector: 'tb-mobile-app-qr-code-widget-settings', templateUrl: './mobile-app-qr-code-widget-settings.component.html', - styleUrls: ['/mobile-app-qr-code-widget-settings.component.scss', './../widget-settings.scss'] + styleUrls: ['./../widget-settings.scss'] }) export class MobileAppQrCodeWidgetSettingsComponent extends WidgetSettingsComponent { @@ -49,15 +49,12 @@ export class MobileAppQrCodeWidgetSettingsComponent extends WidgetSettingsCompon protected onSettingsSet(settings: WidgetSettings) { this.mobileAppQRCodeWidgetSettingsForm = this.fb.group({ - useDefaultApp: [settings.useDefaultApp], + useSystemSettings: [settings.useSystemSettings], androidConfig: this.fb.group({ enabled: [settings.androidConfig.enabled], - appPackage: [settings.androidConfig.appPackage, [Validators.required]], - sha256CertFingerprints: [settings.androidConfig.sha256CertFingerprints, [Validators.required]] }), iosConfig: this.fb.group({ enabled: [settings.iosConfig.enabled], - appId: [settings.iosConfig.appId, [Validators.required]] }), qrCodeConfig: this.fb.group({ badgeEnabled: [settings.qrCodeConfig.badgeEnabled], @@ -70,35 +67,16 @@ export class MobileAppQrCodeWidgetSettingsComponent extends WidgetSettingsCompon } protected validatorTriggers(): string[] { - return ['useDefaultApp', 'androidConfig.enabled', 'iosConfig.enabled', 'qrCodeConfig.badgeEnabled', 'qrCodeConfig.qrCodeLabelEnabled']; + return ['useSystemSettings', 'androidConfig.enabled', 'iosConfig.enabled', 'qrCodeConfig.badgeEnabled', 'qrCodeConfig.qrCodeLabelEnabled']; } protected updateValidators(emitEvent: boolean) { - const useDefaultApp = this.mobileAppQRCodeWidgetSettingsForm.get('useDefaultApp').value; + const useSystemSettings = this.mobileAppQRCodeWidgetSettingsForm.get('useSystemSettings').value; const androidEnabled = this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.enabled').value; const iosEnabled = this.mobileAppQRCodeWidgetSettingsForm.get('iosConfig.enabled').value; const badgeEnabled = this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeEnabled').value; const qrCodeLabelEnabled = this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.qrCodeLabelEnabled').value; - if (useDefaultApp) { - this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.appPackage').disable({emitEvent: false}); - this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.sha256CertFingerprints').disable({emitEvent: false}); - this.mobileAppQRCodeWidgetSettingsForm.get('iosConfig.appId').disable({emitEvent: false}); - } else { - if (androidEnabled) { - this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.appPackage').enable({emitEvent: false}); - this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.sha256CertFingerprints').enable({emitEvent: false}); - } else { - this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.appPackage').disable({emitEvent: false}); - this.mobileAppQRCodeWidgetSettingsForm.get('androidConfig.sha256CertFingerprints').disable({emitEvent: false}); - } - if (iosEnabled) { - this.mobileAppQRCodeWidgetSettingsForm.get('iosConfig.appId').enable({emitEvent: false}); - } else { - this.mobileAppQRCodeWidgetSettingsForm.get('iosConfig.appId').disable({emitEvent: false}); - } - } - if (!androidEnabled && !iosEnabled) { this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeEnabled').disable({emitEvent: false}); this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.badgeStyle').disable({emitEvent: false}); @@ -116,7 +94,7 @@ export class MobileAppQrCodeWidgetSettingsComponent extends WidgetSettingsCompon } } - if (qrCodeLabelEnabled) { + if (qrCodeLabelEnabled && !useSystemSettings) { this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.qrCodeLabel').enable({emitEvent: false}); } else { this.mobileAppQRCodeWidgetSettingsForm.get('qrCodeConfig.qrCodeLabel').disable({emitEvent: false}); diff --git a/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.html b/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.html index 88c6c287d5..0d5de08de0 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.html @@ -43,7 +43,7 @@
-
{{ 'admin.mobile-app.app-package-name' | translate }}
+
{{ 'admin.mobile-app.app-package-name' | translate }}
-
{{ 'admin.mobile-app.sha256-certificate-fingerprints' | translate }}
+
{{ 'admin.mobile-app.sha256-certificate-fingerprints' | translate }}
-
{{ 'admin.mobile-app.app-id' | translate }}
+
{{ 'admin.mobile-app.app-id' | translate }}
- {{ 'admin.mobile-app.badges' | translate }} @@ -135,7 +135,7 @@
- + {{ 'admin.mobile-app.label' | translate }} @@ -154,7 +154,7 @@
admin.mobile-app.preview
-
diff --git a/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.scss b/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.scss index 5f04c04e8f..40bffb8ca6 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.scss +++ b/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.scss @@ -14,10 +14,6 @@ * limitations under the License. */ -.tb-fixed-width { - min-width: 230px; -} - .tb-qrcode-preview { background-color: rgba(0, 0, 0, 0.04); box-shadow: none; diff --git a/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.ts b/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.ts index 96e8e89e6a..0423ce989f 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.ts @@ -27,7 +27,7 @@ import { badgePositionTranslationsMap, BadgeStyle, badgeStyleTranslationsMap, - MobileAppQRCodeSettings + MobileAppSettings } from '@shared/models/mobile-app.models'; @Component({ @@ -39,7 +39,7 @@ export class MobileAppSettingsComponent extends PageComponent implements HasConf mobileAppSettingsForm: FormGroup; - mobileAppSettings: MobileAppQRCodeSettings; + mobileAppSettings: MobileAppSettings; private readonly destroy$ = new Subject(); @@ -133,7 +133,7 @@ export class MobileAppSettingsComponent extends PageComponent implements HasConf }); } - private processMobileAppSettings(mobileAppSettings: MobileAppQRCodeSettings): void { + private processMobileAppSettings(mobileAppSettings: MobileAppSettings): void { this.mobileAppSettings = {...mobileAppSettings}; this.mobileAppSettingsForm.reset(this.mobileAppSettings); } 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 index 9c6adf91ca..b141600732 100644 --- 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 @@ -27,8 +27,9 @@ import { AppState } from '@core/core.state'; import { map } from 'rxjs/operators'; import { getCurrentAuthUser, + selectHomeDashboardParams, selectMobileQrEnabled, - selectPersistDeviceStateToTelemetryAndMobileQrEnabled + selectPersistDeviceStateToTelemetry } from '@core/auth/auth.selectors'; import { EntityKeyType } from '@shared/models/query/query.models'; import { ResourcesService } from '@core/services/resources.service'; @@ -37,9 +38,42 @@ const sysAdminHomePageJson = '/assets/dashboard/sys_admin_home_page.json'; const tenantAdminHomePageJson = '/assets/dashboard/tenant_admin_home_page.json'; const customerUserHomePageJson = '/assets/dashboard/customer_user_home_page.json'; -const updateDeviceActivityKeyFilterIfNeeded = (store: Store, - dashboard$: Observable): Observable => - store.pipe(select(selectPersistDeviceStateToTelemetryAndMobileQrEnabled)).pipe( +const getHomeDashboard = (store: Store, resourcesService: ResourcesService) => { + const authority = getCurrentAuthUser(store).authority; + switch (authority) { + case Authority.SYS_ADMIN: + return applySystemParametersToHomeDashboard(store, resourcesService.loadJsonResource(sysAdminHomePageJson), authority); + case Authority.TENANT_ADMIN: + return applySystemParametersToHomeDashboard(store, resourcesService.loadJsonResource(tenantAdminHomePageJson), authority); + case Authority.CUSTOMER_USER: + return applySystemParametersToHomeDashboard(store, resourcesService.loadJsonResource(customerUserHomePageJson), authority); + default: + return of(null); + } +}; + +const applySystemParametersToHomeDashboard = (store: Store, + dashboard$: Observable, + authority: Authority): Observable => { + let selectParams$: Observable<{persistDeviceStateToTelemetry?: boolean, mobileQrEnabled?: boolean}>; + switch (authority) { + case Authority.SYS_ADMIN: + selectParams$ = store.pipe( + select(selectMobileQrEnabled), + map(mobileQrEnabled => ({mobileQrEnabled})) + ); + break; + case Authority.TENANT_ADMIN: + selectParams$ = store.pipe(select(selectHomeDashboardParams)); + break; + case Authority.CUSTOMER_USER: + selectParams$ = store.pipe( + select(selectPersistDeviceStateToTelemetry), + map(persistDeviceStateToTelemetry => ({persistDeviceStateToTelemetry})) + ); + break; + } + return selectParams$.pipe( mergeMap((params) => dashboard$.pipe( map((dashboard) => { if (params.persistDeviceStateToTelemetry) { @@ -49,31 +83,21 @@ const updateDeviceActivityKeyFilterIfNeeded = (store: Store, } } } - return params.mobileQrEnabled ? toggleMobileQRCodeDisplay(dashboard) : dashboard; - }) - )) - ); - -const toggleMobileQRCodeDisplayIfNeeded = (store: Store, - dashboard$: Observable): Observable => - store.pipe(select(selectMobileQrEnabled)).pipe( - mergeMap((mobileQrEnabled) => dashboard$.pipe( - map((dashboard) => { - return mobileQrEnabled ? toggleMobileQRCodeDisplay(dashboard) : dashboard; + if (params.mobileQrEnabled) { + for (const widgetId of Object.keys(dashboard.configuration.widgets)) { + if (dashboard.configuration.widgets[widgetId].config.title === 'Select show mobile QR code') { + dashboard.configuration.widgets[widgetId].config.settings.markdownTextFunction = + (dashboard.configuration.widgets[widgetId].config.settings.markdownTextFunction as string) + .replace('\'${mobileQrEnabled}\'', String(true)); + } + } + } + dashboard.hideDashboardToolbar = true; + return dashboard; }) )) ); - -const toggleMobileQRCodeDisplay = (dashboard: HomeDashboard) => { - for (const widgetId of Object.keys(dashboard.configuration.widgets)) { - if (dashboard.configuration.widgets[widgetId].config.title === 'Select show mobile QR code') { - dashboard.configuration.widgets[widgetId].config.settings.markdownTextFunction = - (dashboard.configuration.widgets[widgetId].config.settings.markdownTextFunction as string) - .replace('\'${mobileQrEnabled}\'', String(true)); - } - } - return dashboard; -} +}; export const homeDashboardResolver: ResolveFn = ( route: ActivatedRouteSnapshot, @@ -85,27 +109,7 @@ export const homeDashboardResolver: ResolveFn = ( dashboardService.getHomeDashboard().pipe( mergeMap((dashboard) => { if (!dashboard) { - let dashboard$: Observable; - const authority = getCurrentAuthUser(store).authority; - switch (authority) { - case Authority.SYS_ADMIN: - dashboard$ = toggleMobileQRCodeDisplayIfNeeded(store, resourcesService.loadJsonResource(sysAdminHomePageJson)); - break; - case Authority.TENANT_ADMIN: - dashboard$ = updateDeviceActivityKeyFilterIfNeeded(store, resourcesService.loadJsonResource(tenantAdminHomePageJson)); - break; - case Authority.CUSTOMER_USER: - dashboard$ = updateDeviceActivityKeyFilterIfNeeded(store, resourcesService.loadJsonResource(customerUserHomePageJson)); - break; - } - if (dashboard$) { - return dashboard$.pipe( - map((homeDashboard) => { - homeDashboard.hideDashboardToolbar = true; - return homeDashboard; - }) - ); - } + return getHomeDashboard(store, resourcesService); } return of(dashboard); }) diff --git a/ui-ngx/src/app/shared/models/mobile-app.models.ts b/ui-ngx/src/app/shared/models/mobile-app.models.ts index 02139bd913..262cc34f9e 100644 --- a/ui-ngx/src/app/shared/models/mobile-app.models.ts +++ b/ui-ngx/src/app/shared/models/mobile-app.models.ts @@ -14,10 +14,9 @@ /// limitations under the License. /// -import { TenantId } from '@shared/models/id/tenant-id'; +import { HasTenantId } from '@shared/models/entity.models'; -export interface MobileAppQRCodeSettings { - tenantId: TenantId; +export interface MobileAppSettings extends HasTenantId { useDefaultApp: boolean; androidConfig: AndroidConfig; iosConfig: IosConfig; diff --git a/ui-ngx/src/assets/dashboard/sys_admin_home_page.json b/ui-ngx/src/assets/dashboard/sys_admin_home_page.json index 4984de6e29..24bac97279 100644 --- a/ui-ngx/src/assets/dashboard/sys_admin_home_page.json +++ b/ui-ngx/src/assets/dashboard/sys_admin_home_page.json @@ -2634,22 +2634,24 @@ "typeFullFqn": "system.home_page_widgets.getting_started" }, "53d5f2dd-d432-5362-745f-63bf4487dc96": { - "typeFullFqn": "system.home_page_widgets.home_page_mobile_app_qr_code", + "typeFullFqn": "system.mobile_app_qr_code", "type": "static", "sizeX": 6, "sizeY": 3, "config": { - "showTitle": false, + "showTitle": true, "backgroundColor": "rgb(255, 255, 255)", "color": "rgba(0, 0, 0, 0.87)", "padding": "8px", "settings": {}, - "title": "Mobile app QR code", + "title": "{i18n:admin.mobile-app.connect-mobile-app}", "dropShadow": false, "enableFullscreen": false, "widgetStyle": {}, - "widgetCss": "", - "noDataDisplayMessage": "" + "widgetCss": ".tb-widget-container > .tb-widget > .tb-widget-header > .tb-widget-title {\n padding: 0;\n}\n\n.tb-widget-container > .tb-widget > .tb-widget-header > .tb-widget-title > .title-row > .title {\n padding-bottom: 12px;\n font-weight: 600;\n font-size: 20px;\n line-height: 24px;\n letter-spacing: 0.1px;\n color: rgba(0, 0, 0, 0.76);\n}\n\n@media screen and (min-width: 960px) and (max-width: 1819px) {\n .tb-widget-container > .tb-widget > .tb-widget-header > .tb-widget-title > .title-row > .title {\n padding-bottom: 0;\n font-weight: 500;\n font-size: 14px;\n line-height: 20px;\n letter-spacing: 0.25px;\n }\n}\n", + "showTitleIcon": false, + "titleTooltip": "", + "titleStyle": null }, "row": 0, "col": 0, diff --git a/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json b/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json index 6c1bed741e..40792caf09 100644 --- a/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json +++ b/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json @@ -1177,19 +1177,24 @@ "typeFullFqn": "system.home_page_widgets.getting_started" }, "8c5207d8-7103-4dc2-db48-d61c0c8c61fd": { - "typeFullFqn": "system.home_page_widgets.home_page_mobile_app_qr_code", + "typeFullFqn": "system.mobile_app_qr_code", "type": "static", "sizeX": 6, "sizeY": 3, "config": { - "showTitle": false, + "showTitle": true, "backgroundColor": "rgb(255, 255, 255)", "color": "rgba(0, 0, 0, 0.87)", "padding": "8px", "settings": {}, - "title": "Mobile app QR code", + "title": "{i18n:admin.mobile-app.connect-mobile-app}", "dropShadow": false, - "enableFullscreen": false + "enableFullscreen": false, + "widgetStyle": {}, + "widgetCss": ".tb-widget-container > .tb-widget > .tb-widget-header > .tb-widget-title {\n padding: 0;\n}\n\n.tb-widget-container > .tb-widget > .tb-widget-header > .tb-widget-title > .title-row > .title {\n padding-bottom: 12px;\n font-weight: 600;\n font-size: 20px;\n line-height: 24px;\n letter-spacing: 0.1px;\n color: rgba(0, 0, 0, 0.76);\n}\n\n@media screen and (min-width: 960px) and (max-width: 1819px) {\n .tb-widget-container > .tb-widget > .tb-widget-header > .tb-widget-title > .title-row > .title {\n padding-bottom: 0;\n font-weight: 500;\n font-size: 14px;\n line-height: 20px;\n letter-spacing: 0.25px;\n }\n}\n", + "showTitleIcon": false, + "titleTooltip": "", + "titleStyle": null }, "row": 0, "col": 0, diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index d79b468a8e..c2fb5e2a1c 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -433,6 +433,7 @@ "ios": "iOS", "app-id": "App ID", "app-id-required": "App ID is required", + "appearance": "Appearance", "appearance-on-home-page": "Appearance on Home page", "enabled": "Enabled", "disabled": "Disabled", @@ -445,7 +446,8 @@ "left": "Left", "set": "Set", "preview": "Preview", - "connect-mobile-app": "Connect mobile app" + "connect-mobile-app": "Connect mobile app", + "use-system-settings": "Use system settings" }, "2fa": { "2fa": "Two-factor authentication", diff --git a/ui-ngx/src/form.scss b/ui-ngx/src/form.scss index df9211720c..f38d459b97 100644 --- a/ui-ngx/src/form.scss +++ b/ui-ngx/src/form.scss @@ -234,6 +234,12 @@ } .fixed-title-width { min-width: 200px; + + &-230 { + min-width: 230px; + } + } + [class^="fixed-title-width"] { @media #{$mat-xs} { min-width: fit-content; } From 4daf53bf25af545789b83ffbe8c98f0aa03e911b Mon Sep 17 00:00:00 2001 From: ViacheslavKlimov Date: Thu, 16 May 2024 11:52:41 +0300 Subject: [PATCH 52/84] Lazy Jpa bootstrap mode --- .../thingsboard/server/dao/JpaDaoConfig.java | 3 +- .../server/dao/SqlTimeseriesDaoConfig.java | 4 +- .../server/dao/SqlTsDaoConfig.java | 3 +- .../server/dao/SqlTsLatestDaoConfig.java | 3 +- .../server/dao/TimescaleDaoConfig.java | 3 +- .../dao/TimescaleTsLatestDaoConfig.java | 3 +- .../dao/service/JpaRepositoriesTest.java | 42 +++++++++++++++++++ 7 files changed, 54 insertions(+), 7 deletions(-) create mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/JpaRepositoriesTest.java diff --git a/dao/src/main/java/org/thingsboard/server/dao/JpaDaoConfig.java b/dao/src/main/java/org/thingsboard/server/dao/JpaDaoConfig.java index 05becc8bde..4dd0633420 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/JpaDaoConfig.java +++ b/dao/src/main/java/org/thingsboard/server/dao/JpaDaoConfig.java @@ -19,6 +19,7 @@ import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.data.repository.config.BootstrapMode; import org.springframework.transaction.annotation.EnableTransactionManagement; import org.thingsboard.server.dao.util.TbAutoConfiguration; @@ -28,7 +29,7 @@ import org.thingsboard.server.dao.util.TbAutoConfiguration; @Configuration @TbAutoConfiguration @ComponentScan({"org.thingsboard.server.dao.sql", "org.thingsboard.server.dao.attributes", "org.thingsboard.server.dao.cache", "org.thingsboard.server.cache"}) -@EnableJpaRepositories("org.thingsboard.server.dao.sql") +@EnableJpaRepositories(value = "org.thingsboard.server.dao.sql", bootstrapMode = BootstrapMode.LAZY) @EntityScan("org.thingsboard.server.dao.model.sql") @EnableTransactionManagement public class JpaDaoConfig { diff --git a/dao/src/main/java/org/thingsboard/server/dao/SqlTimeseriesDaoConfig.java b/dao/src/main/java/org/thingsboard/server/dao/SqlTimeseriesDaoConfig.java index 9cf9532aea..09553fb973 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/SqlTimeseriesDaoConfig.java +++ b/dao/src/main/java/org/thingsboard/server/dao/SqlTimeseriesDaoConfig.java @@ -19,14 +19,14 @@ import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.data.repository.config.BootstrapMode; import org.springframework.transaction.annotation.EnableTransactionManagement; -import org.thingsboard.server.dao.util.SqlTsOrTsLatestAnyDao; import org.thingsboard.server.dao.util.TbAutoConfiguration; @Configuration @TbAutoConfiguration @ComponentScan({"org.thingsboard.server.dao.sqlts.dictionary"}) -@EnableJpaRepositories({"org.thingsboard.server.dao.sqlts.dictionary"}) +@EnableJpaRepositories(value = {"org.thingsboard.server.dao.sqlts.dictionary"}, bootstrapMode = BootstrapMode.LAZY) @EntityScan({"org.thingsboard.server.dao.model.sqlts.dictionary"}) @EnableTransactionManagement public class SqlTimeseriesDaoConfig { diff --git a/dao/src/main/java/org/thingsboard/server/dao/SqlTsDaoConfig.java b/dao/src/main/java/org/thingsboard/server/dao/SqlTsDaoConfig.java index 6bdf7c2170..bbd31846db 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/SqlTsDaoConfig.java +++ b/dao/src/main/java/org/thingsboard/server/dao/SqlTsDaoConfig.java @@ -19,6 +19,7 @@ import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.data.repository.config.BootstrapMode; import org.springframework.transaction.annotation.EnableTransactionManagement; import org.thingsboard.server.dao.util.SqlTsDao; import org.thingsboard.server.dao.util.TbAutoConfiguration; @@ -26,7 +27,7 @@ import org.thingsboard.server.dao.util.TbAutoConfiguration; @Configuration @TbAutoConfiguration @ComponentScan({"org.thingsboard.server.dao.sqlts.sql", "org.thingsboard.server.dao.sqlts.insert.sql"}) -@EnableJpaRepositories({"org.thingsboard.server.dao.sqlts.ts", "org.thingsboard.server.dao.sqlts.insert.sql"}) +@EnableJpaRepositories(value = {"org.thingsboard.server.dao.sqlts.ts", "org.thingsboard.server.dao.sqlts.insert.sql"}, bootstrapMode = BootstrapMode.LAZY) @EntityScan({"org.thingsboard.server.dao.model.sqlts.ts"}) @EnableTransactionManagement @SqlTsDao diff --git a/dao/src/main/java/org/thingsboard/server/dao/SqlTsLatestDaoConfig.java b/dao/src/main/java/org/thingsboard/server/dao/SqlTsLatestDaoConfig.java index 1793c4ed76..6fcf21a0a8 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/SqlTsLatestDaoConfig.java +++ b/dao/src/main/java/org/thingsboard/server/dao/SqlTsLatestDaoConfig.java @@ -19,6 +19,7 @@ import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.data.repository.config.BootstrapMode; import org.springframework.transaction.annotation.EnableTransactionManagement; import org.thingsboard.server.dao.util.SqlTsLatestDao; import org.thingsboard.server.dao.util.TbAutoConfiguration; @@ -26,7 +27,7 @@ import org.thingsboard.server.dao.util.TbAutoConfiguration; @Configuration @TbAutoConfiguration @ComponentScan({"org.thingsboard.server.dao.sqlts.sql"}) -@EnableJpaRepositories({"org.thingsboard.server.dao.sqlts.insert.latest.sql", "org.thingsboard.server.dao.sqlts.latest"}) +@EnableJpaRepositories(value = {"org.thingsboard.server.dao.sqlts.insert.latest.sql", "org.thingsboard.server.dao.sqlts.latest"}, bootstrapMode = BootstrapMode.LAZY) @EntityScan({"org.thingsboard.server.dao.model.sqlts.latest"}) @EnableTransactionManagement @SqlTsLatestDao diff --git a/dao/src/main/java/org/thingsboard/server/dao/TimescaleDaoConfig.java b/dao/src/main/java/org/thingsboard/server/dao/TimescaleDaoConfig.java index 16617c12a8..c134b88897 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/TimescaleDaoConfig.java +++ b/dao/src/main/java/org/thingsboard/server/dao/TimescaleDaoConfig.java @@ -19,6 +19,7 @@ import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.data.repository.config.BootstrapMode; import org.springframework.transaction.annotation.EnableTransactionManagement; import org.thingsboard.server.dao.util.TbAutoConfiguration; import org.thingsboard.server.dao.util.TimescaleDBTsDao; @@ -26,7 +27,7 @@ import org.thingsboard.server.dao.util.TimescaleDBTsDao; @Configuration @TbAutoConfiguration @ComponentScan({"org.thingsboard.server.dao.sqlts.timescale"}) -@EnableJpaRepositories({"org.thingsboard.server.dao.sqlts.timescale", "org.thingsboard.server.dao.sqlts.insert.timescale"}) +@EnableJpaRepositories(value = {"org.thingsboard.server.dao.sqlts.timescale", "org.thingsboard.server.dao.sqlts.insert.timescale"}, bootstrapMode = BootstrapMode.LAZY) @EntityScan({"org.thingsboard.server.dao.model.sqlts.timescale"}) @EnableTransactionManagement @TimescaleDBTsDao diff --git a/dao/src/main/java/org/thingsboard/server/dao/TimescaleTsLatestDaoConfig.java b/dao/src/main/java/org/thingsboard/server/dao/TimescaleTsLatestDaoConfig.java index f0e6fc362b..f0e74f1c3d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/TimescaleTsLatestDaoConfig.java +++ b/dao/src/main/java/org/thingsboard/server/dao/TimescaleTsLatestDaoConfig.java @@ -19,6 +19,7 @@ import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.data.repository.config.BootstrapMode; import org.springframework.transaction.annotation.EnableTransactionManagement; import org.thingsboard.server.dao.util.TbAutoConfiguration; import org.thingsboard.server.dao.util.TimescaleDBTsLatestDao; @@ -26,7 +27,7 @@ import org.thingsboard.server.dao.util.TimescaleDBTsLatestDao; @Configuration @TbAutoConfiguration @ComponentScan({"org.thingsboard.server.dao.sqlts.timescale"}) -@EnableJpaRepositories({"org.thingsboard.server.dao.sqlts.insert.latest.sql", "org.thingsboard.server.dao.sqlts.latest"}) +@EnableJpaRepositories(value = {"org.thingsboard.server.dao.sqlts.insert.latest.sql", "org.thingsboard.server.dao.sqlts.latest"}, bootstrapMode = BootstrapMode.LAZY) @EntityScan({"org.thingsboard.server.dao.model.sqlts.latest"}) @EnableTransactionManagement @TimescaleDBTsLatestDao diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/JpaRepositoriesTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/JpaRepositoriesTest.java new file mode 100644 index 0000000000..a822401aeb --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/JpaRepositoriesTest.java @@ -0,0 +1,42 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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; + +import lombok.extern.slf4j.Slf4j; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +@Slf4j +@DaoSqlTest +public class JpaRepositoriesTest extends AbstractServiceTest { + + @Autowired + List> repositories; + + /* + * Verifying that all the repositories are successfully bootstrapped, when using Lazy Jpa bootstrap mode + * */ + @Test + public void testRepositories() { + for (var repository : repositories) { + repository.count(); + } + } + +} From 45672d99885220f69a65b61cd7e67589a6798db5 Mon Sep 17 00:00:00 2001 From: ViacheslavKlimov Date: Thu, 16 May 2024 11:57:28 +0300 Subject: [PATCH 53/84] Move repositories test to EntityDaoRegistryTest --- .../dao/service/EntityDaoRegistryTest.java | 15 +++++++ .../dao/service/JpaRepositoriesTest.java | 42 ------------------- 2 files changed, 15 insertions(+), 42 deletions(-) delete mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/JpaRepositoriesTest.java diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/EntityDaoRegistryTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/EntityDaoRegistryTest.java index 8524efc4a0..30110a2741 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/EntityDaoRegistryTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/EntityDaoRegistryTest.java @@ -19,11 +19,13 @@ import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.exception.ExceptionUtils; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.jpa.repository.JpaRepository; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.dao.Dao; import org.thingsboard.server.dao.entity.EntityDaoRegistry; +import java.util.List; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; @@ -37,6 +39,9 @@ public class EntityDaoRegistryTest extends AbstractServiceTest { @Autowired EntityDaoRegistry entityDaoRegistry; + @Autowired + List> repositories; + @Test public void givenAllEntityTypes_whenGetDao_thenAllPresent() { for (EntityType entityType : EntityType.values()) { @@ -73,4 +78,14 @@ public class EntityDaoRegistryTest extends AbstractServiceTest { } } + /* + * Verifying that all the repositories are successfully bootstrapped, when using Lazy Jpa bootstrap mode + * */ + @Test + public void testJpaRepositories() { + for (var repository : repositories) { + repository.count(); + } + } + } diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/JpaRepositoriesTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/JpaRepositoriesTest.java deleted file mode 100644 index a822401aeb..0000000000 --- a/dao/src/test/java/org/thingsboard/server/dao/service/JpaRepositoriesTest.java +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Copyright © 2016-2024 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF 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; - -import lombok.extern.slf4j.Slf4j; -import org.junit.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.List; - -@Slf4j -@DaoSqlTest -public class JpaRepositoriesTest extends AbstractServiceTest { - - @Autowired - List> repositories; - - /* - * Verifying that all the repositories are successfully bootstrapped, when using Lazy Jpa bootstrap mode - * */ - @Test - public void testRepositories() { - for (var repository : repositories) { - repository.count(); - } - } - -} From e8d14f37cb012243ebe5033dde274674c7d33435 Mon Sep 17 00:00:00 2001 From: ViacheslavKlimov Date: Thu, 16 May 2024 13:24:49 +0300 Subject: [PATCH 54/84] Improvements for Kafka admin creation --- .../server/queue/kafka/TbKafkaAdmin.java | 45 +++++++++++-------- .../kafka/TbKafkaConsumerStatsService.java | 12 ++--- .../server/queue/kafka/TbKafkaSettings.java | 35 ++++++++++++--- 3 files changed, 58 insertions(+), 34 deletions(-) diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaAdmin.java b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaAdmin.java index bf5555817d..a5a0427606 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaAdmin.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaAdmin.java @@ -16,7 +16,6 @@ package org.thingsboard.server.queue.kafka; import lombok.extern.slf4j.Slf4j; -import org.apache.kafka.clients.admin.AdminClient; import org.apache.kafka.clients.admin.CreateTopicsResult; import org.apache.kafka.clients.admin.NewTopic; import org.apache.kafka.common.errors.TopicExistsException; @@ -35,23 +34,17 @@ import java.util.concurrent.ExecutionException; @Slf4j public class TbKafkaAdmin implements TbQueueAdmin { - private final AdminClient client; + private final TbKafkaSettings settings; private final Map topicConfigs; - private final Set topics = ConcurrentHashMap.newKeySet(); private final int numPartitions; + private volatile Set topics; private final short replicationFactor; public TbKafkaAdmin(TbKafkaSettings settings, Map topicConfigs) { - client = AdminClient.create(settings.toAdminProps()); + this.settings = settings; this.topicConfigs = topicConfigs; - try { - topics.addAll(client.listTopics().names().get()); - } catch (InterruptedException | ExecutionException e) { - log.error("Failed to get all topics.", e); - } - String numPartitionsStr = topicConfigs.get(TbKafkaTopicConfigs.NUM_PARTITIONS_SETTING); if (numPartitionsStr != null) { numPartitions = Integer.parseInt(numPartitionsStr); @@ -64,6 +57,7 @@ public class TbKafkaAdmin implements TbQueueAdmin { @Override public void createTopicIfNotExists(String topic, String properties) { + Set topics = getTopics(); if (topics.contains(topic)) { return; } @@ -86,12 +80,13 @@ public class TbKafkaAdmin implements TbQueueAdmin { @Override public void deleteTopic(String topic) { + Set topics = getTopics(); if (topics.contains(topic)) { - client.deleteTopics(Collections.singletonList(topic)); + settings.getAdminClient().deleteTopics(Collections.singletonList(topic)); } else { try { - if (client.listTopics().names().get().contains(topic)) { - client.deleteTopics(Collections.singletonList(topic)); + if (settings.getAdminClient().listTopics().names().get().contains(topic)) { + settings.getAdminClient().deleteTopics(Collections.singletonList(topic)); } else { log.warn("Kafka topic [{}] does not exist.", topic); } @@ -101,14 +96,28 @@ public class TbKafkaAdmin implements TbQueueAdmin { } } - @Override - public void destroy() { - if (client != null) { - client.close(); + private Set getTopics() { + if (topics == null) { + synchronized (this) { + if (topics == null) { + topics = ConcurrentHashMap.newKeySet(); + try { + topics.addAll(settings.getAdminClient().listTopics().names().get()); + } catch (InterruptedException | ExecutionException e) { + log.error("Failed to get all topics.", e); + } + } + } } + return topics; } public CreateTopicsResult createTopic(NewTopic topic) { - return client.createTopics(Collections.singletonList(topic)); + return settings.getAdminClient().createTopics(Collections.singletonList(topic)); + } + + @Override + public void destroy() { } + } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaConsumerStatsService.java b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaConsumerStatsService.java index dbc1b7b7c4..56440cf4a5 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaConsumerStatsService.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaConsumerStatsService.java @@ -15,11 +15,12 @@ */ package org.thingsboard.server.queue.kafka; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.Builder; import lombok.Data; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.apache.kafka.clients.admin.AdminClient; import org.apache.kafka.clients.consumer.Consumer; import org.apache.kafka.clients.consumer.ConsumerConfig; import org.apache.kafka.clients.consumer.KafkaConsumer; @@ -35,8 +36,6 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.queue.discovery.PartitionService; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; import java.time.Duration; import java.util.ArrayList; import java.util.List; @@ -62,7 +61,6 @@ public class TbKafkaConsumerStatsService { @Autowired private PartitionService partitionService; - private AdminClient adminClient; private Consumer consumer; private ScheduledExecutorService statsPrintScheduler; @@ -71,7 +69,6 @@ public class TbKafkaConsumerStatsService { if (!statsConfig.getEnabled()) { return; } - this.adminClient = AdminClient.create(kafkaSettings.toAdminProps()); this.statsPrintScheduler = Executors.newSingleThreadScheduledExecutor(ThingsBoardThreadFactory.forName("kafka-consumer-stats")); Properties consumerProps = kafkaSettings.toConsumerProps(null); @@ -90,7 +87,7 @@ public class TbKafkaConsumerStatsService { } for (String groupId : monitoredGroups) { try { - Map groupOffsets = adminClient.listConsumerGroupOffsets(groupId).partitionsToOffsetAndMetadata() + Map groupOffsets = kafkaSettings.getAdminClient().listConsumerGroupOffsets(groupId).partitionsToOffsetAndMetadata() .get(statsConfig.getKafkaResponseTimeoutMs(), TimeUnit.MILLISECONDS); Map endOffsets = consumer.endOffsets(groupOffsets.keySet(), timeoutDuration); @@ -157,9 +154,6 @@ public class TbKafkaConsumerStatsService { if (statsPrintScheduler != null) { statsPrintScheduler.shutdownNow(); } - if (adminClient != null) { - adminClient.close(); - } if (consumer != null) { consumer.close(); } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaSettings.java b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaSettings.java index 96fa7afb7f..ebbc6fac7e 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaSettings.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaSettings.java @@ -19,6 +19,7 @@ import lombok.Getter; import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.CommonClientConfigs; +import org.apache.kafka.clients.admin.AdminClient; import org.apache.kafka.clients.admin.AdminClientConfig; import org.apache.kafka.clients.consumer.ConsumerConfig; import org.apache.kafka.clients.producer.ProducerConfig; @@ -34,6 +35,7 @@ import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.TbProperty; import org.thingsboard.server.queue.util.PropertyUtils; +import javax.annotation.PreDestroy; import java.util.Collections; import java.util.List; import java.util.Map; @@ -143,13 +145,7 @@ public class TbKafkaSettings { @Setter private Map> consumerPropertiesPerTopic = Collections.emptyMap(); - public Properties toAdminProps() { - Properties props = toProps(); - props.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, servers); - props.put(AdminClientConfig.RETRIES_CONFIG, retries); - - return props; - } + private volatile AdminClient adminClient; public Properties toConsumerProps(String topic) { Properties props = toProps(); @@ -221,4 +217,29 @@ public class TbKafkaSettings { } } + public AdminClient getAdminClient() { + if (adminClient == null) { + synchronized (this) { + if (adminClient == null) { + adminClient = AdminClient.create(toAdminProps()); + } + } + } + return adminClient; + } + + private Properties toAdminProps() { + Properties props = toProps(); + props.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, servers); + props.put(AdminClientConfig.RETRIES_CONFIG, retries); + return props; + } + + @PreDestroy + private void destroy() { + if (adminClient != null) { + adminClient.close(); + } + } + } From 5c6928558bb455e82d2663d7b30dfe92ce986b29 Mon Sep 17 00:00:00 2001 From: YevhenBondarenko Date: Thu, 16 May 2024 12:45:17 +0200 Subject: [PATCH 55/84] mqtt gateway refactoring due to OOM --- .../AbstractGatewaySessionHandler.java | 431 ++++++++++-------- .../session/SparkplugNodeSessionHandler.java | 77 ++-- 2 files changed, 264 insertions(+), 244 deletions(-) diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java index d033774e93..49f2f1cc63 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java @@ -33,6 +33,7 @@ import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelHandlerContext; import io.netty.handler.codec.mqtt.MqttMessage; import io.netty.handler.codec.mqtt.MqttPublishMessage; +import jakarta.annotation.Nullable; import lombok.Getter; import lombok.Setter; import lombok.extern.slf4j.Slf4j; @@ -62,7 +63,6 @@ import org.thingsboard.server.transport.mqtt.adaptors.ProtoMqttAdaptor; import org.thingsboard.server.transport.mqtt.util.ReturnCode; import org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugConnectionState; -import jakarta.annotation.Nullable; import java.util.ArrayList; import java.util.Collections; import java.util.Date; @@ -99,6 +99,7 @@ public abstract class AbstractGatewaySessionHandler deviceCreationLockMap; private final ConcurrentMap devices; @@ -193,10 +194,6 @@ public abstract class AbstractGatewaySessionHandler deviceEntry : jsonObj.entrySet()) { - String deviceName = deviceEntry.getKey(); + if (!json.isJsonObject()) { + throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); + } + + for (Map.Entry deviceEntry : json.getAsJsonObject().entrySet()) { + if (!deviceEntry.getValue().isJsonArray()) { + log.warn("{}[{}]", CAN_T_PARSE_VALUE, json); + continue; + } + + String deviceName = deviceEntry.getKey(); + T deviceCtx = devices.get(deviceName); + if (deviceCtx != null) { + processPostTelemetryMsg(deviceCtx, deviceEntry.getValue(), deviceName, msgId); + } else { Futures.addCallback(checkDeviceConnected(deviceName), new FutureCallback<>() { @Override public void onSuccess(@Nullable T deviceCtx) { - if (!deviceEntry.getValue().isJsonArray()) { - throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); - } - try { - TransportProtos.PostTelemetryMsg postTelemetryMsg = JsonConverter.convertToTelemetryProto(deviceEntry.getValue().getAsJsonArray()); - processPostTelemetryMsg(deviceCtx, postTelemetryMsg, deviceName, msgId); - } catch (Throwable e) { - log.warn("[{}][{}][{}] Failed to convert telemetry: [{}]", gateway.getTenantId(), gateway.getDeviceId(), deviceName, deviceEntry.getValue(), e); - channel.close(); - } + processPostTelemetryMsg(deviceCtx, deviceEntry.getValue(), deviceName, msgId); } @Override @@ -394,8 +393,16 @@ public abstract class AbstractGatewaySessionHandler deviceMsgList = telemetryMsgProto.getMsgList(); - if (!CollectionUtils.isEmpty(deviceMsgList)) { - deviceMsgList.forEach(telemetryMsg -> { - String deviceName = checkDeviceName(telemetryMsg.getDeviceName()); - Futures.addCallback(checkDeviceConnected(deviceName), - new FutureCallback<>() { - @Override - public void onSuccess(@Nullable T deviceCtx) { - TransportProtos.PostTelemetryMsg msg = telemetryMsg.getMsg(); - try { - TransportProtos.PostTelemetryMsg postTelemetryMsg = ProtoConverter.validatePostTelemetryMsg(msg.toByteArray()); - processPostTelemetryMsg(deviceCtx, postTelemetryMsg, deviceName, msgId); - } catch (Throwable e) { - log.warn("[{}][{}][{}] Failed to convert telemetry: [{}]", gateway.getTenantId(), gateway.getDeviceId(), deviceName, msg, e); - channel.close(); - } - } - - @Override - public void onFailure(Throwable t) { - log.debug("[{}][{}][{}] Failed to process device telemetry command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t); - } - }, context.getExecutor()); - }); - } else { + if (CollectionUtils.isEmpty(deviceMsgList)) { log.debug("[{}][{}][{}] Devices telemetry messages is empty", gateway.getTenantId(), gateway.getDeviceId(), sessionId); throw new IllegalArgumentException("[" + sessionId + "] Devices telemetry messages is empty for [" + gateway.getDeviceId() + "]"); } + + deviceMsgList.forEach(telemetryMsg -> { + String deviceName = checkDeviceName(telemetryMsg.getDeviceName()); + T deviceCtx = devices.get(deviceName); + if (deviceCtx != null) { + processPostTelemetryMsg(deviceCtx, telemetryMsg.getMsg(), deviceName, msgId); + } else { + Futures.addCallback(checkDeviceConnected(deviceName), new FutureCallback<>() { + @Override + public void onSuccess(@Nullable T deviceCtx) { + processPostTelemetryMsg(deviceCtx, telemetryMsg.getMsg(), deviceName, msgId); + } + + @Override + public void onFailure(Throwable t) { + log.debug("[{}][{}][{}] Failed to process device telemetry command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t); + } + }, context.getExecutor()); + } + }); } catch (RuntimeException | InvalidProtocolBufferException e) { throw new AdaptorException(e); } } - public void processPostTelemetryMsg(MqttDeviceAwareSessionContext deviceCtx, TransportProtos.PostTelemetryMsg postTelemetryMsg, String deviceName, int msgId) { - transportService.process(deviceCtx.getSessionInfo(), postTelemetryMsg, getPubAckCallback(channel, deviceName, msgId, postTelemetryMsg)); + protected void processPostTelemetryMsg(MqttDeviceAwareSessionContext deviceCtx, TransportProtos.PostTelemetryMsg msg, String deviceName, int msgId) { + try { + TransportProtos.PostTelemetryMsg postTelemetryMsg = ProtoConverter.validatePostTelemetryMsg(msg.toByteArray()); + transportService.process(deviceCtx.getSessionInfo(), postTelemetryMsg, getPubAckCallback(channel, deviceName, msgId, postTelemetryMsg)); + } catch (Throwable e) { + log.warn("[{}][{}][{}] Failed to convert telemetry: [{}]", gateway.getTenantId(), gateway.getDeviceId(), deviceName, msg, e); + channel.close(); + } } public TransportProtos.PostTelemetryMsg postTelemetryMsgCreated(TransportProtos.KeyValueProto keyValueProto, long ts) { @@ -452,34 +462,44 @@ public abstract class AbstractGatewaySessionHandler deviceEntry : jsonObj.entrySet()) { - String deviceName = deviceEntry.getKey(); - Futures.addCallback(checkDeviceConnected(deviceName), - new FutureCallback<>() { - @Override - public void onSuccess(@Nullable T deviceCtx) { - if (!deviceEntry.getValue().isJsonObject()) { - throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); - } - try { - DeviceId deviceId = deviceCtx.getDeviceId(); - TransportProtos.ClaimDeviceMsg claimDeviceMsg = JsonConverter.convertToClaimDeviceProto(deviceId, deviceEntry.getValue()); - processClaimDeviceMsg(deviceCtx, claimDeviceMsg, deviceName, msgId); - } catch (Throwable e) { - log.warn("[{}][{}][{}] Failed to convert claim message: [{}]", gateway.getTenantId(), gateway.getDeviceId(), deviceName, deviceEntry.getValue(), e); - } - } + if (!json.isJsonObject()) { + throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); + } + JsonObject jsonObj = json.getAsJsonObject(); + for (Map.Entry deviceEntry : jsonObj.entrySet()) { + if (!deviceEntry.getValue().isJsonObject()) { + log.warn("{}[{}]", CAN_T_PARSE_VALUE, json); + continue; + } - @Override - public void onFailure(Throwable t) { - log.debug("[{}][{}][{}] Failed to process device claiming command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t); - } - }, context.getExecutor()); + String deviceName = deviceEntry.getKey(); + T deviceCtx = devices.get(deviceEntry.getKey()); + + if (deviceCtx != null) { + processClaimDeviceMsg(deviceCtx, deviceEntry.getValue(), deviceName, msgId); + } else { + Futures.addCallback(checkDeviceConnected(deviceName), new FutureCallback<>() { + @Override + public void onSuccess(@Nullable T deviceCtx) { + processClaimDeviceMsg(deviceCtx, deviceEntry.getValue(), deviceName, msgId); + } + + @Override + public void onFailure(Throwable t) { + log.debug("[{}][{}][{}] Failed to process device claiming command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t); + } + }, context.getExecutor()); } - } else { - throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); + } + } + + private void processClaimDeviceMsg(MqttDeviceAwareSessionContext deviceCtx, JsonElement claimRequest, String deviceName, int msgId) { + try { + DeviceId deviceId = deviceCtx.getDeviceId(); + TransportProtos.ClaimDeviceMsg claimDeviceMsg = JsonConverter.convertToClaimDeviceProto(deviceId, claimRequest); + transportService.process(deviceCtx.getSessionInfo(), claimDeviceMsg, getPubAckCallback(channel, deviceName, msgId, claimDeviceMsg)); + } catch (Throwable e) { + log.warn("[{}][{}][{}] Failed to convert claim message: [{}]", gateway.getTenantId(), gateway.getDeviceId(), deviceName, claimRequest, e); } } @@ -487,113 +507,120 @@ public abstract class AbstractGatewaySessionHandler claimMsgList = claimMsgProto.getMsgList(); - if (!CollectionUtils.isEmpty(claimMsgList)) { - claimMsgList.forEach(claimDeviceMsg -> { - String deviceName = checkDeviceName(claimDeviceMsg.getDeviceName()); - Futures.addCallback(checkDeviceConnected(deviceName), - new FutureCallback<>() { - @Override - public void onSuccess(@Nullable T deviceCtx) { - TransportApiProtos.ClaimDevice claimRequest = claimDeviceMsg.getClaimRequest(); - if (claimRequest == null) { - throw new IllegalArgumentException("Claim request for device: " + deviceName + " is null!"); - } - try { - DeviceId deviceId = deviceCtx.getDeviceId(); - TransportProtos.ClaimDeviceMsg claimDeviceMsg = ProtoConverter.convertToClaimDeviceProto(deviceId, claimRequest.toByteArray()); - processClaimDeviceMsg(deviceCtx, claimDeviceMsg, deviceName, msgId); - } catch (Throwable e) { - log.warn("[{}][{}][{}] Failed to convert claim message: [{}]", gateway.getTenantId(), gateway.getDeviceId(), deviceName, claimRequest, e); - } - } - - @Override - public void onFailure(Throwable t) { - log.debug("[{}][{}][{}] Failed to process device claiming command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t); - } - }, context.getExecutor()); - }); - } else { + if (CollectionUtils.isEmpty(claimMsgList)) { log.debug("[{}][{}][{}] Devices claim messages is empty", gateway.getTenantId(), gateway.getDeviceId(), sessionId); throw new IllegalArgumentException("[" + sessionId + "] Devices claim messages is empty for [" + gateway.getDeviceId() + "]"); } + + claimMsgList.forEach(claimDeviceMsg -> { + String deviceName = checkDeviceName(claimDeviceMsg.getDeviceName()); + T deviceCtx = devices.get(deviceName); + if (deviceCtx != null) { + processClaimDeviceMsg(deviceCtx, claimDeviceMsg.getClaimRequest(), deviceName, msgId); + } else { + Futures.addCallback(checkDeviceConnected(deviceName), new FutureCallback<>() { + @Override + public void onSuccess(@Nullable T deviceCtx) { + processClaimDeviceMsg(deviceCtx, claimDeviceMsg.getClaimRequest(), deviceName, msgId); + } + + @Override + public void onFailure(Throwable t) { + log.debug("[{}][{}][{}] Failed to process device claiming command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t); + } + }, context.getExecutor()); + } + }); } catch (RuntimeException | InvalidProtocolBufferException e) { throw new AdaptorException(e); } } - private void processClaimDeviceMsg(MqttDeviceAwareSessionContext deviceCtx, TransportProtos.ClaimDeviceMsg claimDeviceMsg, String deviceName, int msgId) { - transportService.process(deviceCtx.getSessionInfo(), claimDeviceMsg, getPubAckCallback(channel, deviceName, msgId, claimDeviceMsg)); + private void processClaimDeviceMsg(MqttDeviceAwareSessionContext deviceCtx, TransportApiProtos.ClaimDevice claimRequest, String deviceName, int msgId) { + try { + DeviceId deviceId = deviceCtx.getDeviceId(); + TransportProtos.ClaimDeviceMsg claimDeviceMsg = ProtoConverter.convertToClaimDeviceProto(deviceId, claimRequest.toByteArray()); + transportService.process(deviceCtx.getSessionInfo(), claimDeviceMsg, getPubAckCallback(channel, deviceName, msgId, claimDeviceMsg)); + } catch (Throwable e) { + log.warn("[{}][{}][{}] Failed to convert claim message: [{}]", gateway.getTenantId(), gateway.getDeviceId(), deviceName, claimRequest, e); + } } private void onDeviceAttributesJson(int msgId, ByteBuf payload) throws AdaptorException { JsonElement json = JsonMqttAdaptor.validateJsonPayload(sessionId, payload); - if (json.isJsonObject()) { - JsonObject jsonObj = json.getAsJsonObject(); - for (Map.Entry deviceEntry : jsonObj.entrySet()) { - String deviceName = deviceEntry.getKey(); - Futures.addCallback(checkDeviceConnected(deviceName), - new FutureCallback<>() { - @Override - public void onSuccess(@Nullable T deviceCtx) { - if (!deviceEntry.getValue().isJsonObject()) { - throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); - } - TransportProtos.PostAttributeMsg postAttributeMsg = JsonConverter.convertToAttributesProto(deviceEntry.getValue().getAsJsonObject()); - processPostAttributesMsg(deviceCtx, postAttributeMsg, deviceName, msgId); - } - - @Override - public void onFailure(Throwable t) { - log.debug("[{}][{}][{}] Failed to process device attributes command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t); - } - }, context.getExecutor()); - } - } else { + if (!json.isJsonObject()) { throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); } + JsonObject jsonObj = json.getAsJsonObject(); + for (Map.Entry deviceEntry : jsonObj.entrySet()) { + if (!deviceEntry.getValue().isJsonObject()) { + log.warn("{}[{}]", CAN_T_PARSE_VALUE, json); + continue; + } + String deviceName = deviceEntry.getKey(); + Futures.addCallback(checkDeviceConnected(deviceName), new FutureCallback<>() { + @Override + public void onSuccess(@Nullable T deviceCtx) { + processPostAttributesMsg(deviceCtx, deviceEntry.getValue(), deviceName, msgId); + } + + @Override + public void onFailure(Throwable t) { + log.debug("[{}][{}][{}] Failed to process device attributes command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t); + } + }, context.getExecutor()); + } } private void onDeviceAttributesProto(int msgId, ByteBuf payload) throws AdaptorException { try { TransportApiProtos.GatewayAttributesMsg attributesMsgProto = TransportApiProtos.GatewayAttributesMsg.parseFrom(getBytes(payload)); List attributesMsgList = attributesMsgProto.getMsgList(); - if (!CollectionUtils.isEmpty(attributesMsgList)) { - attributesMsgList.forEach(attributesMsg -> { - String deviceName = checkDeviceName(attributesMsg.getDeviceName()); - Futures.addCallback(checkDeviceConnected(deviceName), - new FutureCallback<>() { - @Override - public void onSuccess(@Nullable T deviceCtx) { - TransportProtos.PostAttributeMsg kvListProto = attributesMsg.getMsg(); - if (kvListProto == null) { - throw new IllegalArgumentException("Attributes List for device: " + deviceName + " is empty!"); - } - try { - TransportProtos.PostAttributeMsg postAttributeMsg = ProtoConverter.validatePostAttributeMsg(kvListProto); - processPostAttributesMsg(deviceCtx, postAttributeMsg, deviceName, msgId); - } catch (Throwable e) { - log.warn("[{}][{}][{}] Failed to process device attributes command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), deviceName, kvListProto, e); - } - } - - @Override - public void onFailure(Throwable t) { - log.debug("[{}][{}][{}] Failed to process device attributes command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t); - } - }, context.getExecutor()); - }); - } else { + if (CollectionUtils.isEmpty(attributesMsgList)) { log.debug("[{}][{}][{}] Devices attributes keys list is empty", gateway.getTenantId(), gateway.getDeviceId(), sessionId); throw new IllegalArgumentException("[" + sessionId + "] Devices attributes keys list is empty for [" + gateway.getDeviceId() + "]"); } + + attributesMsgList.forEach(attributesMsg -> { + String deviceName = checkDeviceName(attributesMsg.getDeviceName()); + T deviceCtx = devices.get(deviceName); + if (deviceCtx != null) { + processPostAttributesMsg(deviceCtx, attributesMsg.getMsg(), deviceName, msgId); + } else { + Futures.addCallback(checkDeviceConnected(deviceName), new FutureCallback<>() { + @Override + public void onSuccess(@Nullable T deviceCtx) { + processPostAttributesMsg(deviceCtx, attributesMsg.getMsg(), deviceName, msgId); + } + + @Override + public void onFailure(Throwable t) { + log.debug("[{}][{}][{}] Failed to process device attributes command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t); + } + }, context.getExecutor()); + } + }); } catch (RuntimeException | InvalidProtocolBufferException e) { throw new AdaptorException(e); } } - protected void processPostAttributesMsg(MqttDeviceAwareSessionContext deviceCtx, TransportProtos.PostAttributeMsg postAttributeMsg, String deviceName, int msgId) { - transportService.process(deviceCtx.getSessionInfo(), postAttributeMsg, getPubAckCallback(channel, deviceName, msgId, postAttributeMsg)); + protected void processPostAttributesMsg(MqttDeviceAwareSessionContext deviceCtx, JsonElement msg, String deviceName, int msgId) { + try { + TransportProtos.PostAttributeMsg postAttributeMsg = JsonConverter.convertToAttributesProto(msg.getAsJsonObject()); + transportService.process(deviceCtx.getSessionInfo(), postAttributeMsg, getPubAckCallback(channel, deviceName, msgId, postAttributeMsg)); + } catch (Throwable e) { + log.warn("[{}][{}][{}] Failed to process device attributes command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), deviceName, msg, e); + } + } + + protected void processPostAttributesMsg(MqttDeviceAwareSessionContext deviceCtx, TransportProtos.PostAttributeMsg kvListProto, String deviceName, int msgId) { + try { + TransportProtos.PostAttributeMsg postAttributeMsg = ProtoConverter.validatePostAttributeMsg(kvListProto); + transportService.process(deviceCtx.getSessionInfo(), postAttributeMsg, getPubAckCallback(channel, deviceName, msgId, postAttributeMsg)); + } catch (Throwable e) { + log.warn("[{}][{}][{}] Failed to process device attributes command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), deviceName, kvListProto, e); + } } private void onDeviceAttributesRequestJson(MqttPublishMessage msg) throws AdaptorException { @@ -637,74 +664,76 @@ public abstract class AbstractGatewaySessionHandler() { - @Override - public void onSuccess(@Nullable T deviceCtx) { - Integer requestId = jsonObj.get("id").getAsInt(); - String data = jsonObj.get("data").toString(); - TransportProtos.ToDeviceRpcResponseMsg rpcResponseMsg = TransportProtos.ToDeviceRpcResponseMsg.newBuilder() - .setRequestId(requestId).setPayload(data).build(); - processRpcResponseMsg(deviceCtx, rpcResponseMsg, deviceName, msgId); - } - - @Override - public void onFailure(Throwable t) { - log.debug("[{}][{}][{}] Failed to process device Rpc response command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t); - } - }, context.getExecutor()); - } else { + if (!json.isJsonObject()) { throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); } + JsonObject jsonObj = json.getAsJsonObject(); + String deviceName = jsonObj.get(DEVICE_PROPERTY).getAsString(); + Integer requestId = jsonObj.get("id").getAsInt(); + String data = jsonObj.get("data").toString(); + onDeviceRpcResponse(requestId, data, deviceName, msgId); } private void onDeviceRpcResponseProto(int msgId, ByteBuf payload) throws AdaptorException { try { TransportApiProtos.GatewayRpcResponseMsg gatewayRpcResponseMsg = TransportApiProtos.GatewayRpcResponseMsg.parseFrom(getBytes(payload)); String deviceName = checkDeviceName(gatewayRpcResponseMsg.getDeviceName()); - Futures.addCallback(checkDeviceConnected(deviceName), - new FutureCallback<>() { - @Override - public void onSuccess(@Nullable T deviceCtx) { - Integer requestId = gatewayRpcResponseMsg.getId(); - String data = gatewayRpcResponseMsg.getData(); - TransportProtos.ToDeviceRpcResponseMsg rpcResponseMsg = TransportProtos.ToDeviceRpcResponseMsg.newBuilder() - .setRequestId(requestId).setPayload(data).build(); - processRpcResponseMsg(deviceCtx, rpcResponseMsg, deviceName, msgId); - } - - @Override - public void onFailure(Throwable t) { - log.debug("[{}][{}][{}] Failed to process device Rpc response command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t); - } - }, context.getExecutor()); + Integer requestId = gatewayRpcResponseMsg.getId(); + String data = gatewayRpcResponseMsg.getData(); + onDeviceRpcResponse(requestId, data, deviceName, msgId); } catch (RuntimeException | InvalidProtocolBufferException e) { throw new AdaptorException(e); } } - private void processRpcResponseMsg(MqttDeviceAwareSessionContext deviceCtx, TransportProtos.ToDeviceRpcResponseMsg rpcResponseMsg, String deviceName, int msgId) { + private void onDeviceRpcResponse(Integer requestId, String data, String deviceName, int msgId) { + T deviceCtx = devices.get(deviceName); + if (deviceCtx != null) { + processRpcResponseMsg(deviceCtx, requestId, data, deviceName, msgId); + } else { + Futures.addCallback(checkDeviceConnected(deviceName), new FutureCallback<>() { + @Override + public void onSuccess(@Nullable T deviceCtx) { + processRpcResponseMsg(deviceCtx, requestId, data, deviceName, msgId); + } + + @Override + public void onFailure(Throwable t) { + log.debug("[{}][{}][{}] Failed to process device Rpc response command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t); + } + }, context.getExecutor()); + } + } + + private void processRpcResponseMsg(MqttDeviceAwareSessionContext deviceCtx, Integer requestId, String data, String deviceName, int msgId) { + TransportProtos.ToDeviceRpcResponseMsg rpcResponseMsg = TransportProtos.ToDeviceRpcResponseMsg.newBuilder() + .setRequestId(requestId).setPayload(data).build(); transportService.process(deviceCtx.getSessionInfo(), rpcResponseMsg, getPubAckCallback(channel, deviceName, msgId, rpcResponseMsg)); } private void processGetAttributeRequestMessage(MqttPublishMessage mqttMsg, String deviceName, TransportProtos.GetAttributeRequestMsg requestMsg) { int msgId = getMsgId(mqttMsg); - Futures.addCallback(checkDeviceConnected(deviceName), - new FutureCallback<>() { - @Override - public void onSuccess(@Nullable T deviceCtx) { - transportService.process(deviceCtx.getSessionInfo(), requestMsg, getPubAckCallback(channel, deviceName, msgId, requestMsg)); - } + T deviceCtx = devices.get(deviceName); + if (deviceCtx != null) { + processGetAttributeRequestMessage(deviceCtx, requestMsg, deviceName, msgId); + } else { + Futures.addCallback(checkDeviceConnected(deviceName), new FutureCallback<>() { + @Override + public void onSuccess(@Nullable T deviceCtx) { + processGetAttributeRequestMessage(deviceCtx, requestMsg, deviceName, msgId); + } - @Override - public void onFailure(Throwable t) { - ack(mqttMsg, ReturnCode.IMPLEMENTATION_SPECIFIC); - log.debug("[{}][{}][{}] Failed to process device attributes request command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t); - } - }, context.getExecutor()); + @Override + public void onFailure(Throwable t) { + ack(mqttMsg, ReturnCode.IMPLEMENTATION_SPECIFIC); + log.debug("[{}][{}][{}] Failed to process device attributes request command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t); + } + }, context.getExecutor()); + } + } + + private void processGetAttributeRequestMessage(T deviceCtx, TransportProtos.GetAttributeRequestMsg requestMsg, String deviceName, int msgId) { + transportService.process(deviceCtx.getSessionInfo(), requestMsg, getPubAckCallback(channel, deviceName, msgId, requestMsg)); } private TransportProtos.GetAttributeRequestMsg toGetAttributeRequestMsg(int requestId, boolean clientScope, Set keys) { diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugNodeSessionHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugNodeSessionHandler.java index 1f389fced1..cbc515c1d0 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugNodeSessionHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugNodeSessionHandler.java @@ -26,6 +26,7 @@ import io.netty.handler.codec.mqtt.MqttMessage; import io.netty.handler.codec.mqtt.MqttPublishMessage; import io.netty.handler.codec.mqtt.MqttQoS; import io.netty.handler.codec.mqtt.MqttTopicSubscription; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.eclipse.leshan.core.ResponseCode; import org.springframework.util.CollectionUtils; @@ -44,6 +45,7 @@ import org.thingsboard.server.transport.mqtt.util.sparkplug.MetricDataType; import org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopic; import jakarta.annotation.Nullable; + import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -68,6 +70,7 @@ import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopi @Slf4j public class SparkplugNodeSessionHandler extends AbstractGatewaySessionHandler { + @Getter private final SparkplugTopic sparkplugTopicNode; private final Map nodeBirthMetrics; private final MqttTransportHandler parent; @@ -136,41 +139,36 @@ public class SparkplugNodeSessionHandler extends AbstractGatewaySessionHandler contextListenableFuture, int msgId, List postTelemetryMsgList, String deviceName) throws AdaptorException { - try { - int finalMsgId = msgId; - postTelemetryMsgList.forEach(telemetryMsg -> { - Futures.addCallback(contextListenableFuture, - new FutureCallback<>() { - @Override - public void onSuccess(@Nullable MqttDeviceAwareSessionContext deviceCtx) { - try { - processPostTelemetryMsg(deviceCtx, telemetryMsg, deviceName, finalMsgId); - } catch (Throwable e) { - log.warn("[{}][{}] Failed to convert telemetry: {}", gateway.getDeviceId(), deviceName, telemetryMsg, e); - channel.close(); - } - } + Futures.addCallback(contextListenableFuture, new FutureCallback<>() { + @Override + public void onSuccess(@Nullable MqttDeviceAwareSessionContext deviceCtx) { + for (TransportProtos.PostTelemetryMsg telemetryMsg : postTelemetryMsgList) { + try { + processPostTelemetryMsg(deviceCtx, telemetryMsg, deviceName, msgId); + } catch (Throwable e) { + log.warn("[{}][{}] Failed to convert telemetry: {}", gateway.getDeviceId(), deviceName, telemetryMsg, e); + channel.close(); + break; + } + } + } - @Override - public void onFailure(Throwable t) { - log.debug("[{}] Failed to process device telemetry command: {}", sessionId, deviceName, t); - } - }, context.getExecutor()); - }); - } catch (RuntimeException e) { - throw new AdaptorException(e); - } + @Override + public void onFailure(Throwable t) { + log.debug("[{}] Failed to process device telemetry command: {}", sessionId, deviceName, t); + } + }, context.getExecutor()); } private void onDeviceAttributesProto(ListenableFuture contextListenableFuture, int msgId, List attributesMsgList, String deviceName) throws AdaptorException { try { if (!CollectionUtils.isEmpty(attributesMsgList)) { - attributesMsgList.forEach(attributesMsg -> { - Futures.addCallback(contextListenableFuture, - new FutureCallback<>() { - @Override - public void onSuccess(@Nullable MqttDeviceAwareSessionContext deviceCtx) { + Futures.addCallback(contextListenableFuture, + new FutureCallback<>() { + @Override + public void onSuccess(@Nullable MqttDeviceAwareSessionContext deviceCtx) { + for (TransportApiProtos.AttributesMsg attributesMsg : attributesMsgList) { TransportProtos.PostAttributeMsg kvListProto = attributesMsg.getMsg(); try { TransportProtos.PostAttributeMsg postAttributeMsg = ProtoConverter.validatePostAttributeMsg(kvListProto); @@ -179,13 +177,13 @@ public class SparkplugNodeSessionHandler extends AbstractGatewaySessionHandler grantedQoSList, MqttTopicSubscription subscription, - MqttQoS reqQoS) throws ThingsboardException, AdaptorException, - ExecutionException, InterruptedException { - SparkplugTopic sparkplugTopic = parseTopicSubscribe(subscription.topicName()); + MqttQoS reqQoS) throws ThingsboardException { + SparkplugTopic sparkplugTopic = parseTopicSubscribe(subscription.topicFilter()); if (sparkplugTopic.getGroupId() == null) { // TODO SUBSCRIBE NameSpace } else if (sparkplugTopic.getType() == null) { @@ -304,10 +301,6 @@ public class SparkplugNodeSessionHandler extends AbstractGatewaySessionHandler createSparkplugMqttPublishMsg(TransportProtos.TsKvProto tsKvProto, String sparkplugTopic, SparkplugBProto.Payload.Metric metricBirth) { @@ -328,7 +321,6 @@ public class SparkplugNodeSessionHandler extends AbstractGatewaySessionHandler Date: Thu, 16 May 2024 14:34:50 +0300 Subject: [PATCH 56/84] Fix TbKafkaSettingsTest --- .../org/thingsboard/server/queue/kafka/TbKafkaSettings.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaSettings.java b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaSettings.java index ebbc6fac7e..760487c61e 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaSettings.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaSettings.java @@ -228,7 +228,7 @@ public class TbKafkaSettings { return adminClient; } - private Properties toAdminProps() { + protected Properties toAdminProps() { Properties props = toProps(); props.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, servers); props.put(AdminClientConfig.RETRIES_CONFIG, retries); From 5c6b4122d355c4bf16bf9dfc8d345231d1c1dcaa Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Thu, 16 May 2024 18:02:23 +0300 Subject: [PATCH 57/84] Add SSL support for Kafka queue connection in JS executor --- msa/js-executor/api/utils.ts | 4 +++ .../config/custom-environment-variables.yml | 11 +++++++ msa/js-executor/config/default.yml | 2 ++ msa/js-executor/queue/kafkaTemplate.ts | 32 ++++++++++++++++++- 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/msa/js-executor/api/utils.ts b/msa/js-executor/api/utils.ts index 963ea1ddae..c228d98315 100644 --- a/msa/js-executor/api/utils.ts +++ b/msa/js-executor/api/utils.ts @@ -62,3 +62,7 @@ export function parseJsErrorDetails(err: any): string | undefined { export function isNotUUID(candidate: string) { return candidate.length != 36 || !candidate.includes('-'); } + +export function isNotEmptyStr(value: any): boolean { + return typeof value === 'string' && value.trim().length > 0; +} diff --git a/msa/js-executor/config/custom-environment-variables.yml b/msa/js-executor/config/custom-environment-variables.yml index 1f5e5d711a..7c79eda0d1 100644 --- a/msa/js-executor/config/custom-environment-variables.yml +++ b/msa/js-executor/config/custom-environment-variables.yml @@ -38,6 +38,17 @@ kafka: topic_properties: "TB_QUEUE_KAFKA_JE_TOPIC_PROPERTIES" use_confluent_cloud: "TB_QUEUE_KAFKA_USE_CONFLUENT_CLOUD" client_id: "KAFKA_CLIENT_ID" #inject pod name to easy identify the client using /opt/kafka/bin/kafka-consumer-groups.sh + ssl: + # Enable or disable SSL for Kafka communication. + enabled: "TB_KAFKA_SSL_ENABLED" + # Path to the server certificate file. This file can hold the server certificate or a certificate chain and may include the server private key. + cert_file: "TB_KAFKA_SSL_PEM_CERTIFICATE" + # Optional: Path to the server certificate private key file. Required if the private key is not included in the server certificate file. + key_file: "TB_KAFKA_SSL_PEM_KEY" + # Optional: Password for the server certificate private key, if applicable. + key_password: "TB_KAFKA_SSL_PEM_KEY_PASSWORD" + # Optional: Path to a custom CA certificate file. Defaults to trusting well-known CAs curated by Mozilla. + ca_file: "TB_KAFKA_SSL_PEM_TRUSTS_CERTIFICATE" confluent: sasl: mechanism: "TB_QUEUE_KAFKA_CONFLUENT_SASL_MECHANISM" diff --git a/msa/js-executor/config/default.yml b/msa/js-executor/config/default.yml index b340425a31..b2e84d0d46 100644 --- a/msa/js-executor/config/default.yml +++ b/msa/js-executor/config/default.yml @@ -38,6 +38,8 @@ kafka: topic_properties: "retention.ms:604800000;segment.bytes:26214400;retention.bytes:104857600;partitions:100;min.insync.replicas:1" use_confluent_cloud: false client_id: "kafkajs" #inject pod name to easy identify the client using /opt/kafka/bin/kafka-consumer-groups.sh + ssl: + enabled: false confluent: sasl: mechanism: "PLAIN" diff --git a/msa/js-executor/queue/kafkaTemplate.ts b/msa/js-executor/queue/kafkaTemplate.ts index 4ba3a1aee1..659d1fb8b2 100644 --- a/msa/js-executor/queue/kafkaTemplate.ts +++ b/msa/js-executor/queue/kafkaTemplate.ts @@ -15,6 +15,7 @@ /// import config from 'config'; +import fs from 'node:fs'; import { _logger, KafkaJsWinstonLogCreator } from '../config/logger'; import { JsInvokeMessageProcessor } from '../api/jsInvokeMessageProcessor' import { IQueue } from './queue.models'; @@ -29,8 +30,10 @@ import { Producer, TopicMessages } from 'kafkajs'; +import { isNotEmptyStr } from '../api/utils'; +import { KeyObject } from 'tls'; -import process, { kill, exit } from 'process'; +import process, { exit, kill } from 'process'; export class KafkaTemplate implements IQueue { @@ -64,6 +67,7 @@ export class KafkaTemplate implements IQueue { const queuePrefix: string = config.get('queue_prefix'); const requestTopic: string = queuePrefix ? queuePrefix + "." + config.get('request_topic') : config.get('request_topic'); const useConfluent = config.get('kafka.use_confluent_cloud'); + const enabledSsl = Boolean(config.get('kafka.ssl.enabled')); const groupId:string = queuePrefix ? queuePrefix + ".js-executor-group" : "js-executor-group"; this.logger.info('Kafka Bootstrap Servers: %s', kafkaBootstrapServers); this.logger.info('Kafka Requests Topic: %s', requestTopic); @@ -93,6 +97,31 @@ export class KafkaTemplate implements IQueue { kafkaConfig['ssl'] = true; } + if (enabledSsl) { + const certFilePath: string = config.has('kafka.ssl.cert_file') ? config.get('kafka.ssl.cert_file') : ''; + const keyFilePath: string = config.has('kafka.ssl.key_file') ? config.get('kafka.ssl.key_file') : ''; + const keyPassword: string = config.has('kafka.ssl.key_password') ? config.get('kafka.ssl.key_password') : ''; + const caFilePath: string = config.has('kafka.ssl.ca_file') ? config.get('kafka.ssl.ca_file') : ''; + + kafkaConfig.ssl = {}; + + if (isNotEmptyStr(certFilePath)) { + kafkaConfig.ssl.cert = fs.readFileSync(certFilePath, 'utf-8'); + } + + if (isNotEmptyStr(keyFilePath)) { + const keyConfig: KeyObject = {pem: fs.readFileSync(keyFilePath, 'utf-8')}; + if (isNotEmptyStr(keyPassword)) { + keyConfig.passphrase = keyPassword; + } + kafkaConfig.ssl.key = [keyConfig]; + } + + if (isNotEmptyStr(caFilePath)) { + kafkaConfig.ssl.ca = fs.readFileSync(caFilePath, 'utf-8'); + } + } + this.parseTopicProperties(); this.kafkaClient = new Kafka(kafkaConfig); @@ -213,6 +242,7 @@ export class KafkaTemplate implements IQueue { private createTopic(topic: string, partitions: number): Promise { return this.kafkaAdmin.createTopics({ + timeout: this.requestTimeout, topics: [{ topic: topic, numPartitions: partitions, From b96a7fcbd22aa11c6d1822f1f7d2bae36bd9fdd4 Mon Sep 17 00:00:00 2001 From: YevhenBondarenko Date: Thu, 16 May 2024 18:45:50 +0200 Subject: [PATCH 58/84] added ackOrClose instead of chanel.close and refactoring --- .../AbstractGatewaySessionHandler.java | 54 ++++++++++++------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java index 49f2f1cc63..b4644ac3ac 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java @@ -32,7 +32,9 @@ import io.netty.buffer.ByteBuf; import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelHandlerContext; import io.netty.handler.codec.mqtt.MqttMessage; +import io.netty.handler.codec.mqtt.MqttMessageBuilders; import io.netty.handler.codec.mqtt.MqttPublishMessage; +import io.netty.handler.codec.mqtt.MqttVersion; import jakarta.annotation.Nullable; import lombok.Getter; import lombok.Setter; @@ -83,6 +85,7 @@ import static org.thingsboard.server.common.transport.service.DefaultTransportSe import static org.thingsboard.server.common.transport.service.DefaultTransportService.SESSION_EVENT_MSG_OPEN; import static org.thingsboard.server.common.transport.service.DefaultTransportService.SUBSCRIBE_TO_ATTRIBUTE_UPDATES_ASYNC_MSG; import static org.thingsboard.server.common.transport.service.DefaultTransportService.SUBSCRIBE_TO_RPC_ASYNC_MSG; +import static org.thingsboard.server.transport.mqtt.util.ReturnCode.PAYLOAD_FORMAT_INVALID; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugConnectionState.OFFLINE; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.STATE; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.messageName; @@ -365,10 +368,7 @@ public abstract class AbstractGatewaySessionHandler deviceEntry : json.getAsJsonObject().entrySet()) { if (!deviceEntry.getValue().isJsonArray()) { log.warn("{}[{}]", CAN_T_PARSE_VALUE, json); @@ -402,7 +402,7 @@ public abstract class AbstractGatewaySessionHandler deviceEntry : jsonObj.entrySet()) { + validateJsonObject(json); + for (Map.Entry deviceEntry : json.getAsJsonObject().entrySet()) { if (!deviceEntry.getValue().isJsonObject()) { log.warn("{}[{}]", CAN_T_PARSE_VALUE, json); continue; @@ -500,6 +497,7 @@ public abstract class AbstractGatewaySessionHandler deviceEntry : jsonObj.entrySet()) { + validateJsonObject(json); + for (Map.Entry deviceEntry : json.getAsJsonObject().entrySet()) { if (!deviceEntry.getValue().isJsonObject()) { log.warn("{}[{}]", CAN_T_PARSE_VALUE, json); continue; @@ -611,6 +607,7 @@ public abstract class AbstractGatewaySessionHandler 0) { writeAndFlush(MqttTransportHandler.createMqttPubAckMsg(deviceSessionCtx, msgId, returnCode)); } } + private void ackOrClose(int msgId) { + if (MqttVersion.MQTT_5.equals(deviceSessionCtx.getMqttVersion())) { + ack(msgId, PAYLOAD_FORMAT_INVALID); + } else { + channel.close(); + } + } + private void deregisterSession(String deviceName, MqttDeviceAwareSessionContext deviceSessionCtx) { if (this.deviceSessionCtx.isSparkplug()) { sendSparkplugStateOnTelemetry(deviceSessionCtx.getSessionInfo(), From 0fe3be153751c343e595fa2d88dc36cfd9d65444 Mon Sep 17 00:00:00 2001 From: YevhenBondarenko Date: Thu, 16 May 2024 20:16:04 +0200 Subject: [PATCH 59/84] Used DonAsynchron instead of addCallback --- .../AbstractGatewaySessionHandler.java | 245 +++++------------- .../session/SparkplugNodeSessionHandler.java | 84 +++--- 2 files changed, 95 insertions(+), 234 deletions(-) diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java index b4644ac3ac..1eaf1f38e8 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java @@ -15,9 +15,7 @@ */ package org.thingsboard.server.transport.mqtt.session; - 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 com.google.common.util.concurrent.SettableFuture; @@ -32,15 +30,15 @@ import io.netty.buffer.ByteBuf; import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelHandlerContext; import io.netty.handler.codec.mqtt.MqttMessage; -import io.netty.handler.codec.mqtt.MqttMessageBuilders; import io.netty.handler.codec.mqtt.MqttPublishMessage; import io.netty.handler.codec.mqtt.MqttVersion; -import jakarta.annotation.Nullable; import lombok.Getter; import lombok.Setter; +import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.springframework.util.CollectionUtils; import org.springframework.util.ConcurrentReferenceHashMap; +import org.thingsboard.common.util.DonAsynchron; import org.thingsboard.server.common.adaptor.AdaptorException; import org.thingsboard.server.common.adaptor.JsonConverter; import org.thingsboard.server.common.adaptor.ProtoConverter; @@ -78,6 +76,7 @@ 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.Consumer; import static org.springframework.util.ConcurrentReferenceHashMap.ReferenceType; import static org.thingsboard.server.common.data.DataConstants.DEFAULT_DEVICE_TYPE; @@ -224,18 +223,12 @@ public abstract class AbstractGatewaySessionHandler() { - @Override - public void onSuccess(@Nullable T result) { - ack(msg, ReturnCode.SUCCESS); - log.trace("[{}][{}][{}] onDeviceConnectOk: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName); - } - - @Override - public void onFailure(Throwable t) { - logDeviceCreationError(t, deviceName); - } - }, context.getExecutor()); + process(onDeviceConnect(deviceName, deviceType), + result -> { + ack(msg, ReturnCode.SUCCESS); + log.trace("[{}][{}][{}] onDeviceConnectOk: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName); + }, + t -> logDeviceCreationError(t, deviceName)); } public void onDeviceUpdate(TransportProtos.SessionInfoProto sessionInfo, Device device, Optional deviceProfileOpt) { @@ -374,25 +367,9 @@ public abstract class AbstractGatewaySessionHandler() { - @Override - public void onSuccess(@Nullable T deviceCtx) { - processPostTelemetryMsg(deviceCtx, deviceEntry.getValue(), deviceName, msgId); - } - - @Override - public void onFailure(Throwable t) { - log.debug("[{}][{}][{}] Failed to process device telemetry command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t); - } - }, context.getExecutor()); - } + process(deviceName, deviceCtx -> processPostTelemetryMsg(deviceCtx, deviceEntry.getValue(), deviceName, msgId), + t -> log.debug("[{}][{}][{}] Failed to process device telemetry command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t)); } } @@ -417,22 +394,8 @@ public abstract class AbstractGatewaySessionHandler { String deviceName = checkDeviceName(telemetryMsg.getDeviceName()); - T deviceCtx = devices.get(deviceName); - if (deviceCtx != null) { - processPostTelemetryMsg(deviceCtx, telemetryMsg.getMsg(), deviceName, msgId); - } else { - Futures.addCallback(checkDeviceConnected(deviceName), new FutureCallback<>() { - @Override - public void onSuccess(@Nullable T deviceCtx) { - processPostTelemetryMsg(deviceCtx, telemetryMsg.getMsg(), deviceName, msgId); - } - - @Override - public void onFailure(Throwable t) { - log.debug("[{}][{}][{}] Failed to process device telemetry command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t); - } - }, context.getExecutor()); - } + process(deviceName, deviceCtx -> processPostTelemetryMsg(deviceCtx, telemetryMsg.getMsg(), deviceName, msgId), + t -> log.debug("[{}][{}][{}] Failed to process device telemetry command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t)); }); } catch (RuntimeException | InvalidProtocolBufferException e) { throw new AdaptorException(e); @@ -470,23 +433,8 @@ public abstract class AbstractGatewaySessionHandler() { - @Override - public void onSuccess(@Nullable T deviceCtx) { - processClaimDeviceMsg(deviceCtx, deviceEntry.getValue(), deviceName, msgId); - } - - @Override - public void onFailure(Throwable t) { - log.debug("[{}][{}][{}] Failed to process device claiming command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t); - } - }, context.getExecutor()); - } + process(deviceName, deviceCtx -> processClaimDeviceMsg(deviceCtx, deviceEntry.getValue(), deviceName, msgId), + t -> log.debug("[{}][{}][{}] Failed to process device claiming command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t)); } } @@ -512,22 +460,8 @@ public abstract class AbstractGatewaySessionHandler { String deviceName = checkDeviceName(claimDeviceMsg.getDeviceName()); - T deviceCtx = devices.get(deviceName); - if (deviceCtx != null) { - processClaimDeviceMsg(deviceCtx, claimDeviceMsg.getClaimRequest(), deviceName, msgId); - } else { - Futures.addCallback(checkDeviceConnected(deviceName), new FutureCallback<>() { - @Override - public void onSuccess(@Nullable T deviceCtx) { - processClaimDeviceMsg(deviceCtx, claimDeviceMsg.getClaimRequest(), deviceName, msgId); - } - - @Override - public void onFailure(Throwable t) { - log.debug("[{}][{}][{}] Failed to process device claiming command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t); - } - }, context.getExecutor()); - } + process(deviceName, deviceCtx -> processClaimDeviceMsg(deviceCtx, claimDeviceMsg.getClaimRequest(), deviceName, msgId), + t -> log.debug("[{}][{}][{}] Failed to process device claiming command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t)); }); } catch (RuntimeException | InvalidProtocolBufferException e) { throw new AdaptorException(e); @@ -553,18 +487,20 @@ public abstract class AbstractGatewaySessionHandler() { - @Override - public void onSuccess(@Nullable T deviceCtx) { - processPostAttributesMsg(deviceCtx, deviceEntry.getValue(), deviceName, msgId); - } + process(deviceName, deviceCtx -> processPostAttributesMsg(deviceCtx, deviceEntry.getValue(), deviceName, msgId), + t -> log.debug("[{}][{}][{}] Failed to process device attributes command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t)); + } + } - @Override - public void onFailure(Throwable t) { - log.debug("[{}][{}][{}] Failed to process device attributes command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t); - } - }, context.getExecutor()); + private void processPostAttributesMsg(MqttDeviceAwareSessionContext deviceCtx, JsonElement msg, String deviceName, int msgId) { + try { + TransportProtos.PostAttributeMsg postAttributeMsg = JsonConverter.convertToAttributesProto(msg.getAsJsonObject()); + transportService.process(deviceCtx.getSessionInfo(), postAttributeMsg, getPubAckCallback(channel, deviceName, msgId, postAttributeMsg)); + } catch (Throwable e) { + log.warn("[{}][{}][{}] Failed to process device attributes command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), deviceName, msg, e); + ackOrClose(msgId); } } @@ -579,38 +515,14 @@ public abstract class AbstractGatewaySessionHandler { String deviceName = checkDeviceName(attributesMsg.getDeviceName()); - T deviceCtx = devices.get(deviceName); - if (deviceCtx != null) { - processPostAttributesMsg(deviceCtx, attributesMsg.getMsg(), deviceName, msgId); - } else { - Futures.addCallback(checkDeviceConnected(deviceName), new FutureCallback<>() { - @Override - public void onSuccess(@Nullable T deviceCtx) { - processPostAttributesMsg(deviceCtx, attributesMsg.getMsg(), deviceName, msgId); - } - - @Override - public void onFailure(Throwable t) { - log.debug("[{}][{}][{}] Failed to process device attributes command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t); - } - }, context.getExecutor()); - } + process(deviceName, deviceCtx -> processPostAttributesMsg(deviceCtx, attributesMsg.getMsg(), deviceName, msgId), + t -> log.debug("[{}][{}][{}] Failed to process device attributes command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t)); }); } catch (RuntimeException | InvalidProtocolBufferException e) { throw new AdaptorException(e); } } - protected void processPostAttributesMsg(MqttDeviceAwareSessionContext deviceCtx, JsonElement msg, String deviceName, int msgId) { - try { - TransportProtos.PostAttributeMsg postAttributeMsg = JsonConverter.convertToAttributesProto(msg.getAsJsonObject()); - transportService.process(deviceCtx.getSessionInfo(), postAttributeMsg, getPubAckCallback(channel, deviceName, msgId, postAttributeMsg)); - } catch (Throwable e) { - log.warn("[{}][{}][{}] Failed to process device attributes command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), deviceName, msg, e); - ackOrClose(msgId); - } - } - protected void processPostAttributesMsg(MqttDeviceAwareSessionContext deviceCtx, TransportProtos.PostAttributeMsg kvListProto, String deviceName, int msgId) { try { TransportProtos.PostAttributeMsg postAttributeMsg = ProtoConverter.validatePostAttributeMsg(kvListProto); @@ -623,26 +535,23 @@ public abstract class AbstractGatewaySessionHandler keys; - if (jsonObj.has("key")) { - keys = Collections.singleton(jsonObj.get("key").getAsString()); - } else { - JsonArray keysArray = jsonObj.get("keys").getAsJsonArray(); - keys = new HashSet<>(); - for (JsonElement keyObj : keysArray) { - keys.add(keyObj.getAsString()); - } - } - TransportProtos.GetAttributeRequestMsg requestMsg = toGetAttributeRequestMsg(requestId, clientScope, keys); - processGetAttributeRequestMessage(msg, deviceName, requestMsg); + validateJsonObject(json); + JsonObject jsonObj = json.getAsJsonObject(); + int requestId = jsonObj.get("id").getAsInt(); + String deviceName = jsonObj.get(DEVICE_PROPERTY).getAsString(); + boolean clientScope = jsonObj.get("client").getAsBoolean(); + Set keys; + if (jsonObj.has("key")) { + keys = Collections.singleton(jsonObj.get("key").getAsString()); } else { - throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); + JsonArray keysArray = jsonObj.get("keys").getAsJsonArray(); + keys = new HashSet<>(); + for (JsonElement keyObj : keysArray) { + keys.add(keyObj.getAsString()); + } } + TransportProtos.GetAttributeRequestMsg requestMsg = toGetAttributeRequestMsg(requestId, clientScope, keys); + processGetAttributeRequestMessage(msg, deviceName, requestMsg); } private void onDeviceAttributesRequestProto(MqttPublishMessage mqttMsg) throws AdaptorException { @@ -689,22 +598,8 @@ public abstract class AbstractGatewaySessionHandler() { - @Override - public void onSuccess(@Nullable T deviceCtx) { - processRpcResponseMsg(deviceCtx, requestId, data, deviceName, msgId); - } - - @Override - public void onFailure(Throwable t) { - log.debug("[{}][{}][{}] Failed to process device Rpc response command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t); - } - }, context.getExecutor()); - } + process(deviceName, deviceCtx -> processRpcResponseMsg(deviceCtx, requestId, data, deviceName, msgId), + t -> log.debug("[{}][{}][{}] Failed to process device Rpc response command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t)); } private void processRpcResponseMsg(MqttDeviceAwareSessionContext deviceCtx, Integer requestId, String data, String deviceName, int msgId) { @@ -715,23 +610,11 @@ public abstract class AbstractGatewaySessionHandler() { - @Override - public void onSuccess(@Nullable T deviceCtx) { - processGetAttributeRequestMessage(deviceCtx, requestMsg, deviceName, msgId); - } - - @Override - public void onFailure(Throwable t) { + process(deviceName, deviceCtx -> processGetAttributeRequestMessage(deviceCtx, requestMsg, deviceName, msgId), + t -> { ack(msgId, ReturnCode.IMPLEMENTATION_SPECIFIC); log.debug("[{}][{}][{}] Failed to process device attributes request command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t); - } - }, context.getExecutor()); - } + }); } private void processGetAttributeRequestMessage(T deviceCtx, TransportProtos.GetAttributeRequestMsg requestMsg, String deviceName, int msgId) { @@ -750,16 +633,6 @@ public abstract class AbstractGatewaySessionHandler checkDeviceConnected(String deviceName) { - T ctx = devices.get(deviceName); - if (ctx == null) { - log.debug("[{}][{}][{}] Missing device [{}] for the gateway session", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName); - return onDeviceConnect(deviceName, DEFAULT_DEVICE_TYPE); - } else { - return Futures.immediateFuture(ctx); - } - } - protected String checkDeviceName(String deviceName) { if (StringUtils.isEmpty(deviceName)) { throw new RuntimeException("Device name is empty!"); @@ -796,7 +669,7 @@ public abstract class AbstractGatewaySessionHandler onSuccess, Consumer onFailure) { + ListenableFuture deviceCtxFuture = onDeviceConnect(deviceName, DEFAULT_DEVICE_TYPE); + process(deviceCtxFuture, onSuccess, onFailure); + } + + @SneakyThrows + protected void process(ListenableFuture deviceCtxFuture, Consumer onSuccess, Consumer onFailure) { + if (deviceCtxFuture.isDone()) { + onSuccess.accept(deviceCtxFuture.get()); + } else { + DonAsynchron.withCallback(deviceCtxFuture, onSuccess, onFailure, context.getExecutor()); + } + } + } diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugNodeSessionHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugNodeSessionHandler.java index cbc515c1d0..e06d954678 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugNodeSessionHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugNodeSessionHandler.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.transport.mqtt.session; -import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; @@ -30,12 +29,12 @@ import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.eclipse.leshan.core.ResponseCode; import org.springframework.util.CollectionUtils; -import org.thingsboard.server.common.data.device.profile.MqttDeviceProfileTransportConfiguration; -import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; -import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.adaptor.AdaptorException; import org.thingsboard.server.common.adaptor.JsonConverter; import org.thingsboard.server.common.adaptor.ProtoConverter; +import org.thingsboard.server.common.data.device.profile.MqttDeviceProfileTransportConfiguration; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.transport.auth.GetOrCreateDeviceFromGatewayResponse; import org.thingsboard.server.gen.transport.TransportApiProtos; import org.thingsboard.server.gen.transport.TransportProtos; @@ -44,8 +43,6 @@ import org.thingsboard.server.transport.mqtt.MqttTransportHandler; import org.thingsboard.server.transport.mqtt.util.sparkplug.MetricDataType; import org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopic; -import jakarta.annotation.Nullable; - import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -53,7 +50,6 @@ import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugConnectionState.ONLINE; @@ -72,6 +68,7 @@ public class SparkplugNodeSessionHandler extends AbstractGatewaySessionHandler nodeBirthMetrics; private final MqttTransportHandler parent; @@ -88,10 +85,6 @@ public class SparkplugNodeSessionHandler extends AbstractGatewaySessionHandler metric))); } - public Map getNodeBirthMetrics() { - return this.nodeBirthMetrics; - } - public TransportProtos.PostTelemetryMsg convertToPostTelemetry(MqttDeviceAwareSessionContext ctx, MqttPublishMessage inbound) throws AdaptorException { DeviceSessionCtx deviceSessionCtx = (DeviceSessionCtx) ctx; byte[] bytes = getBytes(inbound.payload()); @@ -138,55 +131,38 @@ public class SparkplugNodeSessionHandler extends AbstractGatewaySessionHandler contextListenableFuture, - int msgId, List postTelemetryMsgList, String deviceName) throws AdaptorException { - Futures.addCallback(contextListenableFuture, new FutureCallback<>() { - @Override - public void onSuccess(@Nullable MqttDeviceAwareSessionContext deviceCtx) { - for (TransportProtos.PostTelemetryMsg telemetryMsg : postTelemetryMsgList) { - try { - processPostTelemetryMsg(deviceCtx, telemetryMsg, deviceName, msgId); - } catch (Throwable e) { - log.warn("[{}][{}] Failed to convert telemetry: {}", gateway.getDeviceId(), deviceName, telemetryMsg, e); - channel.close(); - break; + int msgId, List postTelemetryMsgList, String deviceName) { + process(contextListenableFuture, deviceCtx -> { + for (TransportProtos.PostTelemetryMsg telemetryMsg : postTelemetryMsgList) { + try { + processPostTelemetryMsg(deviceCtx, telemetryMsg, deviceName, msgId); + } catch (Throwable e) { + log.warn("[{}][{}] Failed to convert telemetry: {}", gateway.getDeviceId(), deviceName, telemetryMsg, e); + ackOrClose(msgId); + } } - } - } - - @Override - public void onFailure(Throwable t) { - log.debug("[{}] Failed to process device telemetry command: {}", sessionId, deviceName, t); - } - }, context.getExecutor()); + }, + t -> log.debug("[{}] Failed to process device telemetry command: {}", sessionId, deviceName, t)); } private void onDeviceAttributesProto(ListenableFuture contextListenableFuture, int msgId, List attributesMsgList, String deviceName) throws AdaptorException { try { - if (!CollectionUtils.isEmpty(attributesMsgList)) { - Futures.addCallback(contextListenableFuture, - new FutureCallback<>() { - @Override - public void onSuccess(@Nullable MqttDeviceAwareSessionContext deviceCtx) { - for (TransportApiProtos.AttributesMsg attributesMsg : attributesMsgList) { - TransportProtos.PostAttributeMsg kvListProto = attributesMsg.getMsg(); - try { - TransportProtos.PostAttributeMsg postAttributeMsg = ProtoConverter.validatePostAttributeMsg(kvListProto); - processPostAttributesMsg(deviceCtx, postAttributeMsg, deviceName, msgId); - } catch (Throwable e) { - log.warn("[{}][{}] Failed to process device attributes command: {}", gateway.getDeviceId(), deviceName, kvListProto, e); - } - } - } - - @Override - public void onFailure(Throwable t) { - log.debug("[{}] Failed to process device attributes command: {}", sessionId, deviceName, t); - } - }, context.getExecutor()); - } else { + if (CollectionUtils.isEmpty(attributesMsgList)) { log.debug("[{}] Devices attributes keys list is empty for: [{}]", sessionId, gateway.getDeviceId()); } + process(contextListenableFuture, deviceCtx -> { + for (TransportApiProtos.AttributesMsg attributesMsg : attributesMsgList) { + TransportProtos.PostAttributeMsg kvListProto = attributesMsg.getMsg(); + try { + TransportProtos.PostAttributeMsg postAttributeMsg = ProtoConverter.validatePostAttributeMsg(kvListProto); + processPostAttributesMsg(deviceCtx, postAttributeMsg, deviceName, msgId); + } catch (Throwable e) { + log.warn("[{}][{}] Failed to process device attributes command: {}", gateway.getDeviceId(), deviceName, kvListProto, e); + } + } + }, + t -> log.debug("[{}] Failed to process device attributes command: {}", sessionId, deviceName, t)); } catch (RuntimeException e) { throw new AdaptorException(e); } @@ -235,9 +211,7 @@ public class SparkplugNodeSessionHandler extends AbstractGatewaySessionHandler keyValueProtoOpt = fromSparkplugBMetricToKeyValueProto(key, protoMetric); - if (keyValueProtoOpt.isPresent()) { - msgs.add(postTelemetryMsgCreated(keyValueProtoOpt.get(), ts)); - } + keyValueProtoOpt.ifPresent(kvProto -> msgs.add(postTelemetryMsgCreated(kvProto, ts))); } } From 70a424d9c4217ccf8c2c5d9da3c33f9eb8269831 Mon Sep 17 00:00:00 2001 From: YevhenBondarenko Date: Thu, 16 May 2024 20:35:54 +0200 Subject: [PATCH 60/84] refactored logs --- .../AbstractGatewaySessionHandler.java | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java index 1eaf1f38e8..61d868f08e 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java @@ -97,6 +97,11 @@ public abstract class AbstractGatewaySessionHandler processPostTelemetryMsg(deviceCtx, deviceEntry.getValue(), deviceName, msgId), - t -> log.debug("[{}][{}][{}] Failed to process device telemetry command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t)); + t -> failedToProcessLog(deviceName, TELEMETRY, t)); } } @@ -395,7 +400,7 @@ public abstract class AbstractGatewaySessionHandler { String deviceName = checkDeviceName(telemetryMsg.getDeviceName()); process(deviceName, deviceCtx -> processPostTelemetryMsg(deviceCtx, telemetryMsg.getMsg(), deviceName, msgId), - t -> log.debug("[{}][{}][{}] Failed to process device telemetry command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t)); + t -> failedToProcessLog(deviceName, TELEMETRY, t)); }); } catch (RuntimeException | InvalidProtocolBufferException e) { throw new AdaptorException(e); @@ -434,7 +439,7 @@ public abstract class AbstractGatewaySessionHandler processClaimDeviceMsg(deviceCtx, deviceEntry.getValue(), deviceName, msgId), - t -> log.debug("[{}][{}][{}] Failed to process device claiming command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t)); + t -> failedToProcessLog(deviceName, CLAIMING, t)); } } @@ -461,7 +466,7 @@ public abstract class AbstractGatewaySessionHandler { String deviceName = checkDeviceName(claimDeviceMsg.getDeviceName()); process(deviceName, deviceCtx -> processClaimDeviceMsg(deviceCtx, claimDeviceMsg.getClaimRequest(), deviceName, msgId), - t -> log.debug("[{}][{}][{}] Failed to process device claiming command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t)); + t -> failedToProcessLog(deviceName, CLAIMING, t)); }); } catch (RuntimeException | InvalidProtocolBufferException e) { throw new AdaptorException(e); @@ -490,7 +495,7 @@ public abstract class AbstractGatewaySessionHandler processPostAttributesMsg(deviceCtx, deviceEntry.getValue(), deviceName, msgId), - t -> log.debug("[{}][{}][{}] Failed to process device attributes command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t)); + t -> failedToProcessLog(deviceName, ATTRIBUTE, t)); } } @@ -516,7 +521,7 @@ public abstract class AbstractGatewaySessionHandler { String deviceName = checkDeviceName(attributesMsg.getDeviceName()); process(deviceName, deviceCtx -> processPostAttributesMsg(deviceCtx, attributesMsg.getMsg(), deviceName, msgId), - t -> log.debug("[{}][{}][{}] Failed to process device attributes command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t)); + t -> failedToProcessLog(deviceName, ATTRIBUTE, t)); }); } catch (RuntimeException | InvalidProtocolBufferException e) { throw new AdaptorException(e); @@ -599,7 +604,7 @@ public abstract class AbstractGatewaySessionHandler processRpcResponseMsg(deviceCtx, requestId, data, deviceName, msgId), - t -> log.debug("[{}][{}][{}] Failed to process device Rpc response command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t)); + t -> failedToProcessLog(deviceName, RPC_RESPONSE, t)); } private void processRpcResponseMsg(MqttDeviceAwareSessionContext deviceCtx, Integer requestId, String data, String deviceName, int msgId) { @@ -612,8 +617,8 @@ public abstract class AbstractGatewaySessionHandler processGetAttributeRequestMessage(deviceCtx, requestMsg, deviceName, msgId), t -> { + failedToProcessLog(deviceName, ATTRIBUTES_REQUEST, t); ack(msgId, ReturnCode.IMPLEMENTATION_SPECIFIC); - log.debug("[{}][{}][{}] Failed to process device attributes request command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t); }); } @@ -728,4 +733,8 @@ public abstract class AbstractGatewaySessionHandler Date: Fri, 17 May 2024 11:08:35 +0300 Subject: [PATCH 61/84] UI: added default widget name to widget export file name --- .../home/components/dashboard/dashboard.component.ts | 9 +++++++-- .../modules/home/models/dashboard-component.models.ts | 5 ++--- .../app/shared/import-export/import-export.service.ts | 8 +++++--- ui-ngx/src/app/shared/models/widget.models.ts | 1 - 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts index 6cc1ec7dbe..5bfad36151 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts @@ -54,7 +54,7 @@ import { IAliasController, IStateController } from '@app/core/api/widget-api.mod import { Widget, WidgetPosition } from '@app/shared/models/widget.models'; import { MatMenuTrigger } from '@angular/material/menu'; import { SafeStyle } from '@angular/platform-browser'; -import { distinct } from 'rxjs/operators'; +import { distinct, take } from 'rxjs/operators'; import { ResizeObserver } from '@juggle/resize-observer'; import { UtilsService } from '@core/services/utils.service'; import { WidgetComponentAction, WidgetComponentActionType } from '@home/components/widget/widget-container.component'; @@ -436,7 +436,12 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo $event.stopPropagation(); } if (this.isExportActionEnabled && this.callbacks && this.callbacks.onExportWidget) { - this.callbacks.onExportWidget($event, widget.widget); + widget.title$.pipe( + take(1) + ).subscribe((title) => { + widget.widget.config.exportTitle = title; + this.callbacks.onExportWidget($event, widget.widget); + }); } } diff --git a/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts b/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts index 81a5a7c834..b7ccbd76bf 100644 --- a/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts @@ -26,7 +26,7 @@ import { import { WidgetLayout, WidgetLayouts } from '@app/shared/models/dashboard.models'; import { IDashboardWidget, WidgetAction, WidgetContext, WidgetHeaderAction } from './widget-component.models'; import { Timewindow } from '@shared/models/time/time.models'; -import { Observable, of, Subject, tap } from 'rxjs'; +import { Observable, of, Subject } from 'rxjs'; import { convertKeysToCamelCase, formattedDataFormDatasourceData, @@ -440,8 +440,7 @@ export class DashboardWidget implements GridsterItem, IDashboardWidget { const title = isDefined(this.widgetContext.widgetTitle) && this.widgetContext.widgetTitle.length ? this.widgetContext.widgetTitle : this.widget.config.title; - this.title$ = this.widgetContext.registerLabelPattern(title, this.title$).pipe( - tap(value => this.widget.config.titleByPattern = value)); + this.title$ = this.widgetContext.registerLabelPattern(title, this.title$); this.titleTooltip = isDefined(this.widgetContext.widgetTitleTooltip) && this.widgetContext.widgetTitleTooltip.length ? this.widgetContext.widgetTitleTooltip : this.widget.config.titleTooltip; this.titleTooltip = this.dashboard.utils.customTranslation(this.titleTooltip, this.titleTooltip); diff --git a/ui-ngx/src/app/shared/import-export/import-export.service.ts b/ui-ngx/src/app/shared/import-export/import-export.service.ts index ea4fd0892c..3071f38f74 100644 --- a/ui-ngx/src/app/shared/import-export/import-export.service.ts +++ b/ui-ngx/src/app/shared/import-export/import-export.service.ts @@ -200,9 +200,11 @@ export class ImportExportService { public exportWidget(dashboard: Dashboard, sourceState: string, sourceLayout: DashboardLayoutId, widget: Widget) { const widgetItem = this.itembuffer.prepareWidgetItem(dashboard, sourceState, sourceLayout, widget); - let name = widgetItem.widget.config.titleByPattern; - name = name.toLowerCase().replace(/\W/g, '_'); - this.exportToPc(this.prepareExport(widgetItem), name); + const widgetDefaultName = this.widgetService.getWidgetInfoFromCache(widget.typeFullFqn)?.widgetName || + widget.typeFullFqn.split('.').pop(); + let fileName = widgetDefaultName + (isNotEmptyStr(widget.config.exportTitle) ? '_' + widget.config.exportTitle : ''); + fileName = fileName.toLowerCase().replace(/\W/g, '_'); + this.exportToPc(this.prepareExport(widgetItem), fileName); } public importWidget(dashboard: Dashboard, targetState: string, diff --git a/ui-ngx/src/app/shared/models/widget.models.ts b/ui-ngx/src/app/shared/models/widget.models.ts index b76c2322e3..c736ed7404 100644 --- a/ui-ngx/src/app/shared/models/widget.models.ts +++ b/ui-ngx/src/app/shared/models/widget.models.ts @@ -726,7 +726,6 @@ export enum WidgetConfigMode { export interface WidgetConfig { configMode?: WidgetConfigMode; title?: string; - titleByPattern?: string; titleFont?: Font; titleColor?: string; titleIcon?: string; From a4e5b940f73598ebef88d14eabba47efa2a9bbcb Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Fri, 17 May 2024 12:34:51 +0300 Subject: [PATCH 62/84] Update home-links-routing.module.ts --- .../modules/home/pages/home-links/home-links-routing.module.ts | 1 + 1 file changed, 1 insertion(+) 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 index b141600732..a455bac64d 100644 --- 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 @@ -89,6 +89,7 @@ const applySystemParametersToHomeDashboard = (store: Store, dashboard.configuration.widgets[widgetId].config.settings.markdownTextFunction = (dashboard.configuration.widgets[widgetId].config.settings.markdownTextFunction as string) .replace('\'${mobileQrEnabled}\'', String(true)); + break; } } } From 9a5e3c81fc0ea5f7b9cfa34f23ffa098917eeceb Mon Sep 17 00:00:00 2001 From: ViacheslavKlimov Date: Fri, 17 May 2024 13:11:33 +0300 Subject: [PATCH 63/84] Log startup time --- .../server/ThingsboardServerApplication.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/application/src/main/java/org/thingsboard/server/ThingsboardServerApplication.java b/application/src/main/java/org/thingsboard/server/ThingsboardServerApplication.java index 60f69cd8a7..6ec00b5661 100644 --- a/application/src/main/java/org/thingsboard/server/ThingsboardServerApplication.java +++ b/application/src/main/java/org/thingsboard/server/ThingsboardServerApplication.java @@ -15,24 +15,32 @@ */ package org.thingsboard.server; +import lombok.extern.slf4j.Slf4j; import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringBootConfiguration; import org.springframework.context.annotation.ComponentScan; +import org.springframework.core.Ordered; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; +import org.thingsboard.server.queue.util.AfterStartUp; import java.util.Arrays; +import java.util.concurrent.TimeUnit; @SpringBootConfiguration @EnableAsync @EnableScheduling @ComponentScan({"org.thingsboard.server", "org.thingsboard.script"}) +@Slf4j 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"; + private static long startTs; + public static void main(String[] args) { + startTs = System.currentTimeMillis(); SpringApplication.run(ThingsboardServerApplication.class, updateArguments(args)); } @@ -45,4 +53,11 @@ public class ThingsboardServerApplication { } return args; } + + @AfterStartUp(order = Ordered.LOWEST_PRECEDENCE) + public void afterStartUp() { + long startupTimeMs = System.currentTimeMillis() - startTs; + log.info("Started ThingsBoard in {} seconds", TimeUnit.MILLISECONDS.toSeconds(startupTimeMs)); + } + } From 0434a0b1677ff3b3ac6bb43fae90b44c38efa53d Mon Sep 17 00:00:00 2001 From: rusikv Date: Fri, 17 May 2024 13:43:55 +0300 Subject: [PATCH 64/84] UI: widget export title refactoring --- .../components/dashboard-page/dashboard-page.component.ts | 4 ++-- .../components/dashboard-page/dashboard-page.models.ts | 2 +- .../dashboard-page/layout/dashboard-layout.component.ts | 4 ++-- .../home/components/dashboard/dashboard.component.ts | 5 ++--- .../app/modules/home/models/dashboard-component.models.ts | 2 +- .../src/app/shared/import-export/import-export.service.ts | 7 +++---- 6 files changed, 11 insertions(+), 13 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts index f3d0e2ee15..2fa8ce480d 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts @@ -1343,9 +1343,9 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC }); } - exportWidget($event: Event, layoutCtx: DashboardPageLayoutContext, widget: Widget) { + exportWidget($event: Event, layoutCtx: DashboardPageLayoutContext, widget: Widget, widgetExportTitle) { $event.stopPropagation(); - this.importExport.exportWidget(this.dashboard, this.dashboardCtx.state, layoutCtx.id, widget); + this.importExport.exportWidget(this.dashboard, this.dashboardCtx.state, layoutCtx.id, widget, widgetExportTitle); } widgetClicked($event: Event, layoutCtx: DashboardPageLayoutContext, widget: Widget) { diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.models.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.models.ts index 2f18de2e6e..42c023a3ca 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.models.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.models.ts @@ -49,7 +49,7 @@ export interface IDashboardController { openDashboardState(stateId: string, openRightLayout: boolean); addWidget($event: Event, layoutCtx: DashboardPageLayoutContext); editWidget($event: Event, layoutCtx: DashboardPageLayoutContext, widget: Widget); - exportWidget($event: Event, layoutCtx: DashboardPageLayoutContext, widget: Widget); + exportWidget($event: Event, layoutCtx: DashboardPageLayoutContext, widget: Widget, widgetExportTitle: string); removeWidget($event: Event, layoutCtx: DashboardPageLayoutContext, widget: Widget); widgetMouseDown($event: Event, layoutCtx: DashboardPageLayoutContext, widget: Widget); widgetClicked($event: Event, layoutCtx: DashboardPageLayoutContext, widget: Widget); diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.ts index 9b4aee95e5..7a980c0f73 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.ts @@ -226,8 +226,8 @@ export class DashboardLayoutComponent extends PageComponent implements ILayoutCo this.layoutCtx.dashboardCtrl.editWidget($event, this.layoutCtx, widget); } - onExportWidget($event: Event, widget: Widget): void { - this.layoutCtx.dashboardCtrl.exportWidget($event, this.layoutCtx, widget); + onExportWidget($event: Event, widget: Widget, widgetExportTitle: string): void { + this.layoutCtx.dashboardCtrl.exportWidget($event, this.layoutCtx, widget, widgetExportTitle); } onRemoveWidget($event: Event, widget: Widget): void { diff --git a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts index 5bfad36151..ad248fb0ce 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts @@ -438,9 +438,8 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo if (this.isExportActionEnabled && this.callbacks && this.callbacks.onExportWidget) { widget.title$.pipe( take(1) - ).subscribe((title) => { - widget.widget.config.exportTitle = title; - this.callbacks.onExportWidget($event, widget.widget); + ).subscribe((widgetExportTitle) => { + this.callbacks.onExportWidget($event, widget.widget, widgetExportTitle); }); } } diff --git a/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts b/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts index b7ccbd76bf..4f3b9c4724 100644 --- a/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts @@ -65,7 +65,7 @@ export interface WidgetContextMenuItem extends ContextMenuItem { export interface DashboardCallbacks { onEditWidget?: ($event: Event, widget: Widget) => void; - onExportWidget?: ($event: Event, widget: Widget) => void; + onExportWidget?: ($event: Event, widget: Widget, widgetExportTitle: string) => void; onRemoveWidget?: ($event: Event, widget: Widget) => void; onWidgetMouseDown?: ($event: Event, widget: Widget) => void; onWidgetClicked?: ($event: Event, widget: Widget) => void; diff --git a/ui-ngx/src/app/shared/import-export/import-export.service.ts b/ui-ngx/src/app/shared/import-export/import-export.service.ts index 3071f38f74..3017ba0fca 100644 --- a/ui-ngx/src/app/shared/import-export/import-export.service.ts +++ b/ui-ngx/src/app/shared/import-export/import-export.service.ts @@ -198,11 +198,10 @@ export class ImportExportService { ); } - public exportWidget(dashboard: Dashboard, sourceState: string, sourceLayout: DashboardLayoutId, widget: Widget) { + public exportWidget(dashboard: Dashboard, sourceState: string, sourceLayout: DashboardLayoutId, widget: Widget, widgetExportTitle: string) { const widgetItem = this.itembuffer.prepareWidgetItem(dashboard, sourceState, sourceLayout, widget); - const widgetDefaultName = this.widgetService.getWidgetInfoFromCache(widget.typeFullFqn)?.widgetName || - widget.typeFullFqn.split('.').pop(); - let fileName = widgetDefaultName + (isNotEmptyStr(widget.config.exportTitle) ? '_' + widget.config.exportTitle : ''); + const widgetDefaultName = this.widgetService.getWidgetInfoFromCache(widget.typeFullFqn).widgetName; + let fileName = widgetDefaultName + (isNotEmptyStr(widgetExportTitle) ? '_' + widgetExportTitle : ''); fileName = fileName.toLowerCase().replace(/\W/g, '_'); this.exportToPc(this.prepareExport(widgetItem), fileName); } From 5467903c0cdc2b3e331cede6ad77ae203a2945e8 Mon Sep 17 00:00:00 2001 From: ViacheslavKlimov Date: Fri, 17 May 2024 13:59:00 +0300 Subject: [PATCH 65/84] Fix MobileSecretRedisCache --- .../server/service/mobile/secret/MobileSecretRedisCache.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileSecretRedisCache.java b/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileSecretRedisCache.java index 81fa8672f8..1ff6be61a3 100644 --- a/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileSecretRedisCache.java +++ b/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileSecretRedisCache.java @@ -23,12 +23,11 @@ import org.thingsboard.server.cache.RedisTbTransactionalCache; import org.thingsboard.server.cache.TBRedisCacheConfiguration; import org.thingsboard.server.cache.TbJsonRedisSerializer; import org.thingsboard.server.common.data.CacheConstants; -import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.model.JwtPair; @ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "redis") @Service("MobileSecretCache") -public class MobileSecretRedisCache extends RedisTbTransactionalCache { +public class MobileSecretRedisCache extends RedisTbTransactionalCache { public MobileSecretRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) { super(CacheConstants.MOBILE_SECRET_KEY_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbJsonRedisSerializer<>(JwtPair.class)); From c962ee4ff34608ac215fd5aba2c0d6677648969d Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Fri, 17 May 2024 14:39:50 +0300 Subject: [PATCH 66/84] UI: Rename variable and added type --- .../components/dashboard-page/dashboard-page.component.ts | 4 ++-- .../home/components/dashboard-page/dashboard-page.models.ts | 2 +- .../dashboard-page/layout/dashboard-layout.component.ts | 4 ++-- .../modules/home/components/dashboard/dashboard.component.ts | 4 ++-- .../src/app/modules/home/models/dashboard-component.models.ts | 2 +- ui-ngx/src/app/shared/import-export/import-export.service.ts | 4 ++-- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts index 39cfde6908..70022fff1b 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts @@ -1350,9 +1350,9 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC }); } - exportWidget($event: Event, layoutCtx: DashboardPageLayoutContext, widget: Widget, widgetExportTitle) { + exportWidget($event: Event, layoutCtx: DashboardPageLayoutContext, widget: Widget, widgetTitle: string) { $event.stopPropagation(); - this.importExport.exportWidget(this.dashboard, this.dashboardCtx.state, layoutCtx.id, widget, widgetExportTitle); + this.importExport.exportWidget(this.dashboard, this.dashboardCtx.state, layoutCtx.id, widget, widgetTitle); } widgetClicked($event: Event, layoutCtx: DashboardPageLayoutContext, widget: Widget) { diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.models.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.models.ts index 42c023a3ca..b2ed609c29 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.models.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.models.ts @@ -49,7 +49,7 @@ export interface IDashboardController { openDashboardState(stateId: string, openRightLayout: boolean); addWidget($event: Event, layoutCtx: DashboardPageLayoutContext); editWidget($event: Event, layoutCtx: DashboardPageLayoutContext, widget: Widget); - exportWidget($event: Event, layoutCtx: DashboardPageLayoutContext, widget: Widget, widgetExportTitle: string); + exportWidget($event: Event, layoutCtx: DashboardPageLayoutContext, widget: Widget, widgetTitle: string); removeWidget($event: Event, layoutCtx: DashboardPageLayoutContext, widget: Widget); widgetMouseDown($event: Event, layoutCtx: DashboardPageLayoutContext, widget: Widget); widgetClicked($event: Event, layoutCtx: DashboardPageLayoutContext, widget: Widget); diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.ts index 7a980c0f73..36fb4d2427 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.ts @@ -226,8 +226,8 @@ export class DashboardLayoutComponent extends PageComponent implements ILayoutCo this.layoutCtx.dashboardCtrl.editWidget($event, this.layoutCtx, widget); } - onExportWidget($event: Event, widget: Widget, widgetExportTitle: string): void { - this.layoutCtx.dashboardCtrl.exportWidget($event, this.layoutCtx, widget, widgetExportTitle); + onExportWidget($event: Event, widget: Widget, widgetTitle: string): void { + this.layoutCtx.dashboardCtrl.exportWidget($event, this.layoutCtx, widget, widgetTitle); } onRemoveWidget($event: Event, widget: Widget): void { diff --git a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts index ad248fb0ce..ce968d0e47 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts @@ -438,8 +438,8 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo if (this.isExportActionEnabled && this.callbacks && this.callbacks.onExportWidget) { widget.title$.pipe( take(1) - ).subscribe((widgetExportTitle) => { - this.callbacks.onExportWidget($event, widget.widget, widgetExportTitle); + ).subscribe((widgetTitle) => { + this.callbacks.onExportWidget($event, widget.widget, widgetTitle); }); } } diff --git a/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts b/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts index 4f3b9c4724..d74373f734 100644 --- a/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts @@ -65,7 +65,7 @@ export interface WidgetContextMenuItem extends ContextMenuItem { export interface DashboardCallbacks { onEditWidget?: ($event: Event, widget: Widget) => void; - onExportWidget?: ($event: Event, widget: Widget, widgetExportTitle: string) => void; + onExportWidget?: ($event: Event, widget: Widget, widgeTitle: string) => void; onRemoveWidget?: ($event: Event, widget: Widget) => void; onWidgetMouseDown?: ($event: Event, widget: Widget) => void; onWidgetClicked?: ($event: Event, widget: Widget) => void; diff --git a/ui-ngx/src/app/shared/import-export/import-export.service.ts b/ui-ngx/src/app/shared/import-export/import-export.service.ts index 3017ba0fca..9902177ebd 100644 --- a/ui-ngx/src/app/shared/import-export/import-export.service.ts +++ b/ui-ngx/src/app/shared/import-export/import-export.service.ts @@ -198,10 +198,10 @@ export class ImportExportService { ); } - public exportWidget(dashboard: Dashboard, sourceState: string, sourceLayout: DashboardLayoutId, widget: Widget, widgetExportTitle: string) { + public exportWidget(dashboard: Dashboard, sourceState: string, sourceLayout: DashboardLayoutId, widget: Widget, widgetTitle: string) { const widgetItem = this.itembuffer.prepareWidgetItem(dashboard, sourceState, sourceLayout, widget); const widgetDefaultName = this.widgetService.getWidgetInfoFromCache(widget.typeFullFqn).widgetName; - let fileName = widgetDefaultName + (isNotEmptyStr(widgetExportTitle) ? '_' + widgetExportTitle : ''); + let fileName = widgetDefaultName + (isNotEmptyStr(widgetTitle) ? `_${widgetTitle}` : ''); fileName = fileName.toLowerCase().replace(/\W/g, '_'); this.exportToPc(this.prepareExport(widgetItem), fileName); } From 9d19847ace09ed88dcf6b0610cac25cd865cc298 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Fri, 17 May 2024 14:55:34 +0300 Subject: [PATCH 67/84] UI: Rename translate variable --- .../basic/chart/time-series-chart-basic-config.component.html | 2 +- ui-ngx/src/assets/locale/locale.constant-en_US.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html index 34177676aa..51ef353a10 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html @@ -27,7 +27,7 @@
-
+
{{ 'widgets.chart.series' | translate }}
Date: Fri, 17 May 2024 19:04:39 +0300 Subject: [PATCH 68/84] UI: fixed widget action dialog column index field blocks save --- .../widget/action/widget-action-dialog.component.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts index 230811fbd1..7a8d14e561 100644 --- a/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts @@ -118,7 +118,7 @@ export class WidgetActionDialogComponent extends DialogComponent { if (this.action?.actionSourceId === 'cellClick' && isDefinedAndNotNull(this.action.columnIndex) && this.widgetActionFormGroup.get('columnIndex').value === null) { + this.widgetActionFormGroup.get('columnIndex').setValidators([Validators.required]); + this.widgetActionFormGroup.get('columnIndex').updateValueAndValidity(); this.columnIndexPlaceholderText = `${this.action.columnIndex} (${this.translate.instant('widget-config.not-set')})`; this.columnIndexSelect.focus(); } From 161e10a686e03ab05b8f7a19dbba7cf57127bef8 Mon Sep 17 00:00:00 2001 From: YevhenBondarenko Date: Sun, 19 May 2024 22:35:21 +0200 Subject: [PATCH 69/84] added rate limits for the gateway --- .../controller/TenantProfileController.java | 3 + .../server/common/data/limit/LimitedApi.java | 1 + .../DefaultTenantProfileConfiguration.java | 3 + .../server/common/data/util/TbPair.java | 4 + common/proto/src/main/proto/queue.proto | 2 + .../AbstractGatewayDeviceSessionContext.java | 2 + .../DefaultTransportRateLimitService.java | 90 ++++++++++++++----- .../transport/limits/TransportLimitsType.java | 20 +++++ .../limits/TransportRateLimitService.java | 3 +- .../service/DefaultTransportService.java | 16 +++- ...enant-profile-configuration.component.html | 18 ++++ ...-tenant-profile-configuration.component.ts | 3 + .../tenant/rate-limits/rate-limits.models.ts | 9 ++ .../assets/locale/locale.constant-en_US.json | 9 ++ 14 files changed, 155 insertions(+), 28 deletions(-) create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportLimitsType.java diff --git a/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java b/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java index 8796b8c8a2..097aec6306 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java @@ -138,6 +138,9 @@ public class TenantProfileController extends BaseController { " \"transportDeviceMsgRateLimit\": \"20:1,600:60\",\n" + " \"transportDeviceTelemetryMsgRateLimit\": \"20:1,600:60\",\n" + " \"transportDeviceTelemetryDataPointsRateLimit\": \"20:1,600:60\",\n" + + " \"transportGatewayMsgRateLimit\": \"20:1,600:60\",\n" + + " \"transportGatewayTelemetryMsgRateLimit\": \"20:1,600:60\",\n" + + " \"transportGatewayTelemetryDataPointsRateLimit\": \"20:1,600:60\",\n" + " \"maxTransportMessages\": 10000000,\n" + " \"maxTransportDataPoints\": 10000000,\n" + " \"maxREExecutions\": 4000000,\n" + diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/limit/LimitedApi.java b/common/data/src/main/java/org/thingsboard/server/common/data/limit/LimitedApi.java index 4afead3435..11702100d5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/limit/LimitedApi.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/limit/LimitedApi.java @@ -40,6 +40,7 @@ public enum LimitedApi { TWO_FA_VERIFICATION_CODE_CHECK(false, true), TRANSPORT_MESSAGES_PER_TENANT("transport messages", true), TRANSPORT_MESSAGES_PER_DEVICE("transport messages per device", false), + TRANSPORT_MESSAGES_PER_GATEWAY("transport messages per gateway", false), EMAILS("emails sending", true); private Function configExtractor; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java index a0cfbfe1e1..d990c300da 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java @@ -47,6 +47,9 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura private String transportDeviceMsgRateLimit; private String transportDeviceTelemetryMsgRateLimit; private String transportDeviceTelemetryDataPointsRateLimit; + private String transportGatewayMsgRateLimit; + private String transportGatewayTelemetryMsgRateLimit; + private String transportGatewayTelemetryDataPointsRateLimit; private String tenantEntityExportRateLimit; private String tenantEntityImportRateLimit; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/util/TbPair.java b/common/data/src/main/java/org/thingsboard/server/common/data/util/TbPair.java index bdca8b2e77..6e8b2e5696 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/util/TbPair.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/util/TbPair.java @@ -23,4 +23,8 @@ import lombok.Data; public class TbPair { private S first; private T second; + + public static TbPair of(S first, T second) { + return new TbPair<>(first, second); + } } diff --git a/common/proto/src/main/proto/queue.proto b/common/proto/src/main/proto/queue.proto index 7b2aa2fe1b..53a3026424 100644 --- a/common/proto/src/main/proto/queue.proto +++ b/common/proto/src/main/proto/queue.proto @@ -96,6 +96,8 @@ message SessionInfoProto { int64 deviceProfileIdLSB = 13; int64 customerIdMSB = 14; int64 customerIdLSB = 15; + optional int64 gatewayIdMSB = 16; + optional int64 gatewayIdLSB = 17; } enum SessionEvent { diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewayDeviceSessionContext.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewayDeviceSessionContext.java index f803f0fe3a..e174ad86bd 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewayDeviceSessionContext.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewayDeviceSessionContext.java @@ -59,6 +59,8 @@ public abstract class AbstractGatewayDeviceSessionContext tenantAllowed = new ConcurrentHashMap<>(); private final ConcurrentMap> tenantDevices = new ConcurrentHashMap<>(); + private final ConcurrentMap> tenantGateways = new ConcurrentHashMap<>(); private final ConcurrentMap perTenantLimits = new ConcurrentHashMap<>(); private final ConcurrentMap perDeviceLimits = new ConcurrentHashMap<>(); + private final ConcurrentMap perGatewayLimits = new ConcurrentHashMap<>(); private final Map ipMap = new ConcurrentHashMap<>(); private final TransportTenantProfileCache tenantProfileCache; @@ -65,15 +72,20 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi } @Override - public EntityType checkLimits(TenantId tenantId, DeviceId deviceId, int dataPoints) { + public TbPair checkLimits(TenantId tenantId, DeviceId gatewayId, DeviceId deviceId, int dataPoints) { if (!tenantAllowed.getOrDefault(tenantId, Boolean.TRUE)) { - return EntityType.API_USAGE_STATE; + return TbPair.of(EntityType.API_USAGE_STATE, false); } if (!checkEntityRateLimit(dataPoints, getTenantRateLimits(tenantId))) { - return EntityType.TENANT; + return TbPair.of(EntityType.TENANT, false); + } + + if (gatewayId != null && !checkEntityRateLimit(dataPoints, getGatewayRateLimits(tenantId, gatewayId))) { + return TbPair.of(EntityType.DEVICE, true); } + if (!checkEntityRateLimit(dataPoints, getDeviceRateLimits(tenantId, deviceId))) { - return EntityType.DEVICE; + return TbPair.of(EntityType.DEVICE, false); } return null; } @@ -89,24 +101,27 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi @Override public void update(TenantProfileUpdateResult update) { log.info("Received tenant profile update: {}", update.getProfile()); - EntityTransportRateLimits tenantRateLimitPrototype = createRateLimits(update.getProfile(), true); - EntityTransportRateLimits deviceRateLimitPrototype = createRateLimits(update.getProfile(), false); + EntityTransportRateLimits tenantRateLimitPrototype = createRateLimits(update.getProfile(), TENANT_LIMITS); + EntityTransportRateLimits deviceRateLimitPrototype = createRateLimits(update.getProfile(), DEVICE_LIMITS); + EntityTransportRateLimits gatewayRateLimitPrototype = createRateLimits(update.getProfile(), GATEWAY_LIMITS); for (TenantId tenantId : update.getAffectedTenants()) { - mergeLimits(tenantId, tenantRateLimitPrototype, perTenantLimits::get, perTenantLimits::put); - getTenantDevices(tenantId).forEach(deviceId -> { - mergeLimits(deviceId, deviceRateLimitPrototype, perDeviceLimits::get, perDeviceLimits::put); - }); + update(tenantId, tenantRateLimitPrototype, deviceRateLimitPrototype, gatewayRateLimitPrototype); } } @Override public void update(TenantId tenantId) { - EntityTransportRateLimits tenantRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), true); - EntityTransportRateLimits deviceRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), false); + EntityTransportRateLimits tenantRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), TENANT_LIMITS); + EntityTransportRateLimits deviceRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), DEVICE_LIMITS); + EntityTransportRateLimits gatewayRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), GATEWAY_LIMITS); + update(tenantId, tenantRateLimitPrototype, deviceRateLimitPrototype, gatewayRateLimitPrototype); + } + + private void update(TenantId tenantId, EntityTransportRateLimits tenantRateLimitPrototype, + EntityTransportRateLimits deviceRateLimitPrototype, EntityTransportRateLimits gatewayRateLimitPrototype) { mergeLimits(tenantId, tenantRateLimitPrototype, perTenantLimits::get, perTenantLimits::put); - getTenantDevices(tenantId).forEach(deviceId -> { - mergeLimits(deviceId, deviceRateLimitPrototype, perDeviceLimits::get, perDeviceLimits::put); - }); + getTenantDevices(tenantId).forEach(deviceId -> mergeLimits(deviceId, deviceRateLimitPrototype, perDeviceLimits::get, perDeviceLimits::put)); + getTenantGateways(tenantId).forEach(deviceId -> mergeLimits(deviceId, gatewayRateLimitPrototype, perGatewayLimits::get, perGatewayLimits::put)); } @Override @@ -233,15 +248,34 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi } } - private EntityTransportRateLimits createRateLimits(TenantProfile tenantProfile, boolean tenant) { + private EntityTransportRateLimits createRateLimits(TenantProfile tenantProfile, TransportLimitsType limitsType) { TenantProfileData profileData = tenantProfile.getProfileData(); DefaultTenantProfileConfiguration profile = (DefaultTenantProfileConfiguration) profileData.getConfiguration(); if (profile == null) { return new EntityTransportRateLimits(ALLOW, ALLOW, ALLOW); } else { - TransportRateLimit regularMsgRateLimit = newLimit(tenant ? profile.getTransportTenantMsgRateLimit() : profile.getTransportDeviceMsgRateLimit()); - TransportRateLimit telemetryMsgRateLimit = newLimit(tenant ? profile.getTransportTenantTelemetryMsgRateLimit() : profile.getTransportDeviceTelemetryMsgRateLimit()); - TransportRateLimit telemetryDpRateLimit = newLimit(tenant ? profile.getTransportTenantTelemetryDataPointsRateLimit() : profile.getTransportDeviceTelemetryDataPointsRateLimit()); + TransportRateLimit regularMsgRateLimit; + TransportRateLimit telemetryMsgRateLimit; + TransportRateLimit telemetryDpRateLimit; + switch (limitsType) { + case TENANT_LIMITS -> { + regularMsgRateLimit = newLimit(profile.getTransportTenantMsgRateLimit()); + telemetryMsgRateLimit = newLimit(profile.getTransportTenantTelemetryMsgRateLimit()); + telemetryDpRateLimit = newLimit(profile.getTransportTenantTelemetryDataPointsRateLimit()); + } + case DEVICE_LIMITS -> { + regularMsgRateLimit = newLimit(profile.getTransportDeviceMsgRateLimit()); + telemetryMsgRateLimit = newLimit(profile.getTransportDeviceTelemetryMsgRateLimit()); + telemetryDpRateLimit = newLimit(profile.getTransportDeviceTelemetryDataPointsRateLimit()); + } + case GATEWAY_LIMITS -> { + regularMsgRateLimit = newLimit(profile.getTransportGatewayMsgRateLimit()); + telemetryMsgRateLimit = newLimit(profile.getTransportGatewayTelemetryMsgRateLimit()); + telemetryDpRateLimit = newLimit(profile.getTransportGatewayTelemetryDataPointsRateLimit()); + } + default -> throw new IllegalStateException("Unknown limits type: " + limitsType); + } + return new EntityTransportRateLimits(regularMsgRateLimit, telemetryMsgRateLimit, telemetryDpRateLimit); } } @@ -251,21 +285,31 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi } private EntityTransportRateLimits getTenantRateLimits(TenantId tenantId) { - return perTenantLimits.computeIfAbsent(tenantId, k -> { - return createRateLimits(tenantProfileCache.get(tenantId), true); - }); + return perTenantLimits.computeIfAbsent(tenantId, k -> createRateLimits(tenantProfileCache.get(tenantId), TENANT_LIMITS)); } private EntityTransportRateLimits getDeviceRateLimits(TenantId tenantId, DeviceId deviceId) { return perDeviceLimits.computeIfAbsent(deviceId, k -> { - EntityTransportRateLimits limits = createRateLimits(tenantProfileCache.get(tenantId), false); + EntityTransportRateLimits limits = createRateLimits(tenantProfileCache.get(tenantId), DEVICE_LIMITS); getTenantDevices(tenantId).add(deviceId); return limits; }); } + private EntityTransportRateLimits getGatewayRateLimits(TenantId tenantId, DeviceId deviceId) { + return perGatewayLimits.computeIfAbsent(deviceId, k -> { + EntityTransportRateLimits limits = createRateLimits(tenantProfileCache.get(tenantId), GATEWAY_LIMITS); + getTenantGateways(tenantId).add(deviceId); + return limits; + }); + } + private Set getTenantDevices(TenantId tenantId) { return tenantDevices.computeIfAbsent(tenantId, id -> ConcurrentHashMap.newKeySet()); } + private Set getTenantGateways(TenantId tenantId) { + return tenantGateways.computeIfAbsent(tenantId, id -> ConcurrentHashMap.newKeySet()); + } + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportLimitsType.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportLimitsType.java new file mode 100644 index 0000000000..ab65b07414 --- /dev/null +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportLimitsType.java @@ -0,0 +1,20 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.transport.limits; + +public enum TransportLimitsType { + TENANT_LIMITS, DEVICE_LIMITS, GATEWAY_LIMITS +} diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportRateLimitService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportRateLimitService.java index 975602c5da..e33f9db240 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportRateLimitService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportRateLimitService.java @@ -18,13 +18,14 @@ package org.thingsboard.server.common.transport.limits; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.util.TbPair; import org.thingsboard.server.common.transport.profile.TenantProfileUpdateResult; import java.net.InetSocketAddress; public interface TransportRateLimitService { - EntityType checkLimits(TenantId tenantId, DeviceId deviceId, int dataPoints); + TbPair checkLimits(TenantId tenantId, DeviceId gatewayId, DeviceId deviceId, int dataPoints); void update(TenantProfileUpdateResult update); diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java index 8b7b88c261..9312370c34 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java @@ -852,18 +852,27 @@ public class DefaultTransportService extends TransportActivityManager implements } TenantId tenantId = TenantId.fromUUID(new UUID(sessionInfo.getTenantIdMSB(), sessionInfo.getTenantIdLSB())); DeviceId deviceId = new DeviceId(new UUID(sessionInfo.getDeviceIdMSB(), sessionInfo.getDeviceIdLSB())); + DeviceId gatewayId = null; + if (sessionInfo.hasGatewayIdMSB() && sessionInfo.hasGatewayIdLSB()) { + gatewayId = new DeviceId(new UUID(sessionInfo.getGatewayIdMSB(), sessionInfo.getGatewayIdLSB())); + } - EntityType rateLimitedEntityType = rateLimitService.checkLimits(tenantId, deviceId, dataPoints); - if (rateLimitedEntityType == null) { + var rateLimitedPair = rateLimitService.checkLimits(tenantId, gatewayId, deviceId, dataPoints); + if (rateLimitedPair == null) { return true; } else { + var rateLimitedEntityType = rateLimitedPair.getFirst(); if (callback != null) { callback.onError(new TbRateLimitsException(rateLimitedEntityType)); } + if (rateLimitedEntityType == EntityType.DEVICE || rateLimitedEntityType == EntityType.TENANT) { + LimitedApi limitedApi = + rateLimitedEntityType == EntityType.TENANT ? LimitedApi.TRANSPORT_MESSAGES_PER_TENANT : + rateLimitedPair.getSecond() ? LimitedApi.TRANSPORT_MESSAGES_PER_GATEWAY : LimitedApi.TRANSPORT_MESSAGES_PER_DEVICE; notificationRuleProcessor.process(RateLimitsTrigger.builder() .tenantId(tenantId) - .api(rateLimitedEntityType == EntityType.DEVICE ? LimitedApi.TRANSPORT_MESSAGES_PER_DEVICE : LimitedApi.TRANSPORT_MESSAGES_PER_TENANT) + .api(limitedApi) .limitLevel(rateLimitedEntityType == EntityType.DEVICE ? deviceId : tenantId) .limitLevelEntityName(rateLimitedEntityType == EntityType.DEVICE ? sessionInfo.getDeviceName() : null) .build()); @@ -933,7 +942,6 @@ public class DefaultTransportService extends TransportActivityManager implements log.trace("ResourceUpdate - [{}] [{}]", id, mdRez); transportCallbackExecutor.submit(() -> mdRez.getListener().onResourceUpdate(msg)); }); - } else if (toSessionMsg.hasResourceDeleteMsg()) { TransportProtos.ResourceDeleteMsg msg = toSessionMsg.getResourceDeleteMsg(); TenantId tenantId = TenantId.fromUUID(new UUID(msg.getTenantIdMSB(), msg.getTenantIdLSB())); diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html index ef024fe0a2..91f96c7aba 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html @@ -516,6 +516,18 @@ [type]="rateLimitsType.DEVICE_TELEMETRY_MESSAGES">
+
+ + +
+
+
+ + +
+
@@ -531,6 +543,12 @@ [type]="rateLimitsType.DEVICE_TELEMETRY_DATA_POINTS">
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts index c13eed9c03..2b32504839 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts @@ -74,6 +74,9 @@ export class DefaultTenantProfileConfigurationComponent implements ControlValueA transportDeviceMsgRateLimit: [null, []], transportDeviceTelemetryMsgRateLimit: [null, []], transportDeviceTelemetryDataPointsRateLimit: [null, []], + transportGatewayMsgRateLimit: [null, []], + transportGatewayTelemetryMsgRateLimit: [null, []], + transportGatewayTelemetryDataPointsRateLimit: [null, []], tenantEntityExportRateLimit: [null, []], tenantEntityImportRateLimit: [null, []], tenantNotificationRequestsRateLimit: [null, []], diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits.models.ts b/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits.models.ts index ee13ea9b23..a04336e15e 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits.models.ts +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits.models.ts @@ -26,6 +26,9 @@ export enum RateLimitsType { DEVICE_TELEMETRY_MESSAGES = 'DEVICE_TELEMETRY_MESSAGES', DEVICE_TELEMETRY_DATA_POINTS = 'DEVICE_TELEMETRY_DATA_POINTS', TENANT_MESSAGES = 'TENANT_MESSAGES', + GATEWAY_MESSAGES = 'GATEWAY_MESSAGES', + GATEWAY_TELEMETRY_MESSAGES = 'GATEWAY_TELEMETRY_MESSAGES', + GATEWAY_TELEMETRY_DATA_POINTS = 'GATEWAY_TELEMETRY_DATA_POINTS', TENANT_TELEMETRY_MESSAGES = 'TENANT_TELEMETRY_MESSAGES', TENANT_TELEMETRY_DATA_POINTS = 'TENANT_TELEMETRY_DATA_POINTS', TENANT_SERVER_REST_LIMITS_CONFIGURATION = 'TENANT_SERVER_REST_LIMITS_CONFIGURATION', @@ -50,6 +53,9 @@ export const rateLimitsLabelTranslationMap = new Map( [RateLimitsType.DEVICE_MESSAGES, 'tenant-profile.rate-limits.transport-device-msg'], [RateLimitsType.DEVICE_TELEMETRY_MESSAGES, 'tenant-profile.rate-limits.transport-device-telemetry-msg'], [RateLimitsType.DEVICE_TELEMETRY_DATA_POINTS, 'tenant-profile.rate-limits.transport-device-telemetry-data-points'], + [RateLimitsType.GATEWAY_MESSAGES, 'tenant-profile.rate-limits.transport-gateway-msg'], + [RateLimitsType.GATEWAY_TELEMETRY_MESSAGES, 'tenant-profile.rate-limits.transport-gateway-telemetry-msg'], + [RateLimitsType.GATEWAY_TELEMETRY_DATA_POINTS, 'tenant-profile.rate-limits.transport-gateway-telemetry-data-points'], [RateLimitsType.TENANT_SERVER_REST_LIMITS_CONFIGURATION, 'tenant-profile.rest-requests-for-tenant'], [RateLimitsType.CUSTOMER_SERVER_REST_LIMITS_CONFIGURATION, 'tenant-profile.customer-rest-limits'], [RateLimitsType.WS_UPDATE_PER_SESSION_RATE_LIMIT, 'tenant-profile.ws-limit-updates-per-session'], @@ -74,6 +80,9 @@ export const rateLimitsDialogTitleTranslationMap = new Map Date: Mon, 20 May 2024 14:27:16 +0200 Subject: [PATCH 70/84] added tests for gateway rate limits --- .../mqtt/MqttGatewayRateLimitsTest.java | 171 ++++++++++++++++++ .../session/GatewaySessionHandlerTest.java | 4 + 2 files changed, 175 insertions(+) create mode 100644 application/src/test/java/org/thingsboard/server/transport/mqtt/MqttGatewayRateLimitsTest.java diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/MqttGatewayRateLimitsTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/MqttGatewayRateLimitsTest.java new file mode 100644 index 0000000000..794d93a44a --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/MqttGatewayRateLimitsTest.java @@ -0,0 +1,171 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.mqtt; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.test.context.TestPropertySource; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.notification.rule.trigger.RateLimitsTrigger; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; +import org.thingsboard.server.common.msg.notification.NotificationRuleProcessor; +import org.thingsboard.server.controller.AbstractControllerTest; +import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.transport.mqtt.mqttv3.MqttTestClient; + +import java.util.function.Consumer; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.ArgumentMatchers.eq; +import static org.thingsboard.server.common.data.limit.LimitedApi.TRANSPORT_MESSAGES_PER_GATEWAY; + +@DaoSqlTest +@TestPropertySource(properties = { + "service.integrations.supported=ALL", + "transport.mqtt.enabled=true", +}) +public class MqttGatewayRateLimitsTest extends AbstractControllerTest { + + private static final String TOPIC = "v1/gateway/telemetry"; + private static final String DEVICE_A = "DeviceA"; + private static final String DEVICE_B = "DeviceB"; + + private DeviceId gatewayId; + private String gatewayAccessToken; + + @SpyBean + private NotificationRuleProcessor notificationRuleProcessor; + + @Before + public void beforeTest() throws Exception { + loginSysAdmin(); + + TenantProfile tenantProfile = doGet("/api/tenantProfile/" + tenantProfileId, TenantProfile.class); + Assert.assertNotNull(tenantProfile); + + DefaultTenantProfileConfiguration profileConfiguration = (DefaultTenantProfileConfiguration) tenantProfile.getProfileData().getConfiguration(); + + profileConfiguration.setTransportGatewayMsgRateLimit(null); + profileConfiguration.setTransportGatewayTelemetryMsgRateLimit(null); + profileConfiguration.setTransportGatewayTelemetryDataPointsRateLimit(null); + + doPost("/api/tenantProfile", tenantProfile); + + loginTenantAdmin(); + createGateway(); + + Mockito.reset(notificationRuleProcessor); + } + + @Test + public void transportGatewayMsgRateLimitTest() throws Exception { + transportGatewayRateLimitTest(profileConfiguration -> profileConfiguration.setTransportGatewayMsgRateLimit("1:600")); + } + + @Test + public void transportGatewayTelemetryMsgRateLimitTest() throws Exception { + transportGatewayRateLimitTest(profileConfiguration -> profileConfiguration.setTransportGatewayTelemetryMsgRateLimit("1:600")); + } + + @Test + public void transportGatewayTelemetryDataPointsRateLimitTest() throws Exception { + transportGatewayRateLimitTest(profileConfiguration -> profileConfiguration.setTransportGatewayTelemetryDataPointsRateLimit("1:600")); + } + + private void transportGatewayRateLimitTest(Consumer profileConfiguration) throws Exception { + loginSysAdmin(); + + TenantProfile tenantProfile = doGet("/api/tenantProfile/" + tenantProfileId, TenantProfile.class); + Assert.assertNotNull(tenantProfile); + + profileConfiguration.accept((DefaultTenantProfileConfiguration) tenantProfile.getProfileData().getConfiguration()); + + doPost("/api/tenantProfile", tenantProfile); + + MqttTestClient client = new MqttTestClient(); + client.connectAndWait(gatewayAccessToken); + client.publishAndWait(TOPIC, getGatewayPayload(DEVICE_A)); + + loginTenantAdmin(); + + Device deviceA = getDeviceByName(DEVICE_A); + + var deviceATrigger = createRateLimitsTrigger(deviceA); + + Mockito.verify(notificationRuleProcessor, Mockito.never()).process(eq(deviceATrigger)); + + try { + client.publishAndWait(TOPIC, getGatewayPayload(DEVICE_B)); + } catch (Exception t) { + } + + Device deviceB = getDeviceByName(DEVICE_B); + + var deviceBTrigger = createRateLimitsTrigger(deviceB); + + Mockito.verify(notificationRuleProcessor, Mockito.times(1)).process(deviceBTrigger); + + if (client.isConnected()) { + client.disconnect(); + } + } + + private void createGateway() throws Exception { + Device device = new Device(); + device.setName("gateway"); + ObjectNode additionalInfo = JacksonUtil.newObjectNode(); + additionalInfo.put("gateway", true); + device.setAdditionalInfo(additionalInfo); + device = doPost("/api/device", device, Device.class); + assertNotNull(device); + gatewayId = device.getId(); + assertNotNull(gatewayId); + + DeviceCredentials deviceCredentials = doGet("/api/device/" + gatewayId + "/credentials", DeviceCredentials.class); + assertNotNull(deviceCredentials); + assertEquals(gatewayId, deviceCredentials.getDeviceId()); + gatewayAccessToken = deviceCredentials.getCredentialsId(); + assertNotNull(gatewayAccessToken); + } + + private Device getDeviceByName(String deviceName) throws Exception { + Device device = doGet("/api/tenant/devices?deviceName=" + deviceName, Device.class); + assertNotNull(device); + return device; + } + + private byte[] getGatewayPayload(String deviceName) { + return String.format("{\"%s\": [{\"values\": {\"temperature\": 42}}]}", deviceName).getBytes(); + } + + private RateLimitsTrigger createRateLimitsTrigger(Device device) { + return RateLimitsTrigger.builder() + .tenantId(tenantId) + .api(TRANSPORT_MESSAGES_PER_GATEWAY) + .limitLevel(device.getId()) + .limitLevelEntityName(device.getName()) + .build(); + } +} diff --git a/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandlerTest.java b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandlerTest.java index 158acc2ce2..670bd98af1 100644 --- a/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandlerTest.java +++ b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandlerTest.java @@ -64,6 +64,10 @@ public class GatewaySessionHandlerTest { lenient().doNothing().when(transportService).recordActivity(any()); lenient().when(transportContext.getTransportService()).thenReturn(transportService); lenient().when(deviceSessionCtx.getContext()).thenReturn(transportContext); + + var deviceInfo = new TransportDeviceInfo(); + deviceInfo.setDeviceId(new DeviceId(UUID.randomUUID())); + lenient().when(deviceSessionCtx.getDeviceInfo()).thenReturn(deviceInfo); handler = new GatewaySessionHandler(deviceSessionCtx, UUID.randomUUID(), true); lenient().when(handler.getNodeId()).thenReturn("nodeId"); } From 1d92cd227e89567540bc2ebe8f5f6ffbd1dc4291 Mon Sep 17 00:00:00 2001 From: rusikv Date: Mon, 20 May 2024 15:29:32 +0300 Subject: [PATCH 71/84] UI: refactored widget-action-dialog column index validation --- .../action/widget-action-dialog.component.ts | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts index 7a8d14e561..8f2398cab2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts @@ -118,7 +118,7 @@ export class WidgetActionDialogComponent extends DialogComponent { - if (this.action?.actionSourceId === 'cellClick' && isDefinedAndNotNull(this.action.columnIndex) && - this.widgetActionFormGroup.get('columnIndex').value === null) { - this.widgetActionFormGroup.get('columnIndex').setValidators([Validators.required]); - this.widgetActionFormGroup.get('columnIndex').updateValueAndValidity(); - this.columnIndexPlaceholderText = `${this.action.columnIndex} (${this.translate.instant('widget-config.not-set')})`; - this.columnIndexSelect.focus(); + if (this.action?.actionSourceId === 'cellClick') { + this.widgetActionFormGroup.get('columnIndex').enable(); + if (isDefinedAndNotNull(this.action.columnIndex) && this.widgetActionFormGroup.get('columnIndex').value === null) { + this.columnIndexPlaceholderText = `${this.action.columnIndex} (${this.translate.instant('widget-config.not-set')})`; + this.columnIndexSelect.focus(); + } } }); } @@ -255,9 +254,6 @@ export class WidgetActionDialogComponent extends DialogComponent Date: Mon, 20 May 2024 17:42:53 +0300 Subject: [PATCH 72/84] updated haproxy configuration for /.well-known/... apis --- docker/haproxy/config/haproxy.cfg | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/haproxy/config/haproxy.cfg b/docker/haproxy/config/haproxy.cfg index 757efe692f..b76fe29276 100644 --- a/docker/haproxy/config/haproxy.cfg +++ b/docker/haproxy/config/haproxy.cfg @@ -96,7 +96,7 @@ frontend http-in acl transport_http_acl path_beg /api/v1/ acl letsencrypt_http_acl path_beg /.well-known/acme-challenge/ acl tb_images_api_acl path_beg /api/images/ - acl tb_api_acl path_beg /api/ /swagger /webjars /v2/ /v3/ /static/rulenode/ /oauth2/ /login/oauth2/ /static/widgets/ + acl tb_api_acl path_beg /api/ /swagger /webjars /v2/ /v3/ /static/rulenode/ /oauth2/ /login/oauth2/ /static/widgets/ /.well-known/assetlinks.json /.well-known/apple-app-site-association redirect scheme https if !letsencrypt_http_acl !transport_http_acl { env(FORCE_HTTPS_REDIRECT) -m str true } @@ -124,7 +124,7 @@ frontend https_in acl transport_http_acl path_beg /api/v1/ acl tb_images_api_acl path_beg /api/images/ - acl tb_api_acl path_beg /api/ /swagger /webjars /v2/ /v3/ /static/rulenode/ /oauth2/ /login/oauth2/ /static/widgets/ + acl tb_api_acl path_beg /api/ /swagger /webjars /v2/ /v3/ /static/rulenode/ /oauth2/ /login/oauth2/ /static/widgets/ /.well-known/assetlinks.json /.well-known/apple-app-site-association use_backend tb-http-backend if transport_http_acl use_backend tb-images-api-backend if tb_images_api_acl From ba02898f0b841c1b07b6660d69e913fd1aaefc27 Mon Sep 17 00:00:00 2001 From: YevhenBondarenko Date: Mon, 20 May 2024 19:27:24 +0200 Subject: [PATCH 73/84] fixed race condition during adding new gateway connector with default config --- .../lib/gateway/dialog/add-connector-dialog.component.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/add-connector-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/add-connector-dialog.component.ts index 78a22cd267..98261a8e57 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/add-connector-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/add-connector-dialog.component.ts @@ -93,8 +93,7 @@ export class AddConnectorDialogComponent extends DialogComponent Date: Tue, 21 May 2024 12:05:06 +0200 Subject: [PATCH 74/84] added upgrade for the gateway rate limits --- .../update/DefaultDataUpdateService.java | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) 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 index 9626190f1b..b355ac32e9 100644 --- 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 @@ -92,12 +92,42 @@ public class DefaultDataUpdateService implements DataUpdateService { log.info("Updating data from version 3.6.4 to 3.7.0 ..."); updateCustomersWithTheSameTitle(); updateMaxRuleNodeExecsPerMessage(); + updateGatewayRateLimits(); break; default: throw new RuntimeException("Unable to update data, unsupported fromVersion: " + fromVersion); } } + private void updateGatewayRateLimits() { + var tenantProfiles = new PageDataIterable<>(link -> tenantProfileService.findTenantProfiles(TenantId.SYS_TENANT_ID, link), DEFAULT_PAGE_SIZE); + tenantProfiles.forEach(tenantProfile -> { + var configurationOpt = tenantProfile.getProfileConfiguration(); + configurationOpt.ifPresent(configuration -> { + boolean updated = false; + if (configuration.getTransportDeviceMsgRateLimit() != null && configuration.getTransportGatewayMsgRateLimit() == null) { + configuration.setTransportGatewayMsgRateLimit(configuration.getTransportDeviceMsgRateLimit()); + updated = true; + } + if (configuration.getTransportDeviceTelemetryMsgRateLimit() != null && configuration.getTransportGatewayTelemetryMsgRateLimit() == null) { + configuration.setTransportGatewayTelemetryMsgRateLimit(configuration.getTransportDeviceTelemetryMsgRateLimit()); + updated = true; + } + if (configuration.getTransportDeviceTelemetryDataPointsRateLimit() != null && configuration.getTransportGatewayTelemetryDataPointsRateLimit() == null) { + configuration.setTransportGatewayTelemetryDataPointsRateLimit(configuration.getTransportDeviceTelemetryDataPointsRateLimit()); + updated = true; + } + if (updated) { + try { + tenantProfileService.saveTenantProfile(TenantId.SYS_TENANT_ID, tenantProfile); + } catch (Exception e) { + log.error("Failed to update tenant profile with id: {} due to: ", tenantProfile.getId(), e); + } + } + }); + }); + } + private void updateMaxRuleNodeExecsPerMessage() { var tenantProfiles = new PageDataIterable<>( link -> tenantProfileService.findTenantProfiles(TenantId.SYS_TENANT_ID, link), DEFAULT_PAGE_SIZE); From 811f7d7206ebcbb4a0ce7b5fd6d2803fa5f9e7c3 Mon Sep 17 00:00:00 2001 From: Andrii Shvaika Date: Wed, 15 May 2024 17:42:37 +0300 Subject: [PATCH 75/84] Additional logging of WS commands processing --- .../DefaultTbEntityDataSubscriptionService.java | 12 ++++++++++-- .../DefaultTbLocalSubscriptionService.java | 6 +++++- .../server/service/ws/DefaultWebSocketService.java | 3 ++- .../server/service/ws/WebSocketSessionRef.java | 2 +- 4 files changed, 18 insertions(+), 5 deletions(-) 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 index 10e342b1e8..984af019b4 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java @@ -95,7 +95,8 @@ public class DefaultTbEntityDataSubscriptionService implements TbEntityDataSubsc private static final int DEFAULT_LIMIT = 100; private final Map> subscriptionsBySessionId = new ConcurrentHashMap<>(); - @Autowired @Lazy + @Autowired + @Lazy private WebSocketService wsService; @Autowired @@ -728,7 +729,14 @@ public class DefaultTbEntityDataSubscriptionService implements TbEntityDataSubsc public void cancelAllSessionSubscriptions(String sessionId) { Map sessionSubs = subscriptionsBySessionId.remove(sessionId); if (sessionSubs != null) { - sessionSubs.values().forEach(this::cleanupAndCancel); + sessionSubs.values().forEach(sub -> { + try { + cleanupAndCancel(sub); + } catch (Exception e) { + log.warn("[{}] Failed to remove subscription {} due to ", sub.getTenantId(), sub, e); + } + } + ); } } 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 index bcb900c3f0..a280ae6754 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionService.java @@ -217,7 +217,11 @@ public class DefaultTbLocalSubscriptionService implements TbLocalSubscriptionSer Map> sessionSubscriptions = subscriptionsBySessionId.remove(sessionId); if (sessionSubscriptions != null) { for (TbSubscription subscription : sessionSubscriptions.values()) { - modifySubscription(subscription.getTenantId(), subscription.getEntityId(), subscription, false); + try { + modifySubscription(subscription.getTenantId(), subscription.getEntityId(), subscription, false); + } catch (Exception e) { + log.warn("[{}][{}] Failed to remove subscription {} due to ", subscription.getTenantId(), subscription.getEntityId(), subscription, e); + } } } else { log.debug("[{}] No session subscriptions found!", sessionId); diff --git a/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java b/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java index e41d1821c1..78b78d8b8c 100644 --- a/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java +++ b/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java @@ -197,7 +197,8 @@ public class DefaultWebSocketService implements WebSocketService { wsSessionsMap.put(sessionId, new WsSessionMetaData(sessionRef)); break; case ERROR: - log.debug("[{}] Unknown websocket session error: {}. ", sessionId, event.getError().orElse(null)); + log.debug("[{}] Unknown websocket session error: ", sessionId, + event.getError().orElse(new RuntimeException("No error specified"))); break; case CLOSED: wsSessionsMap.remove(sessionId); diff --git a/application/src/main/java/org/thingsboard/server/service/ws/WebSocketSessionRef.java b/application/src/main/java/org/thingsboard/server/service/ws/WebSocketSessionRef.java index 190183cce2..44919b138c 100644 --- a/application/src/main/java/org/thingsboard/server/service/ws/WebSocketSessionRef.java +++ b/application/src/main/java/org/thingsboard/server/service/ws/WebSocketSessionRef.java @@ -33,7 +33,7 @@ public class WebSocketSessionRef { private static final long serialVersionUID = 1L; private final String sessionId; - private SecurityUser securityCtx; + private volatile SecurityUser securityCtx; private final InetSocketAddress localAddress; private final InetSocketAddress remoteAddress; private final WebSocketSessionType sessionType; From a1cbd8815bd746ff46fbaad52ced7d43494b105f Mon Sep 17 00:00:00 2001 From: Daria Shevchenko <116559345+dashevchenko@users.noreply.github.com> Date: Fri, 17 May 2024 17:54:23 +0300 Subject: [PATCH 76/84] Added scheduled session cleanup (#10818) * added scheduled session cleanup for cases when session was closed before last session command processed * updated logging to include user info, updated isOpen method to include native check * updated cleanup to go through subscriptionsByEntityId map values * made modification of subscriptionsBySessionId and subscriptionsByEntityId maps atomic * fixed cancelAllSessionSubscriptions * added try-catch for pushSubscriptionEvent, modifySubscription * refactored logging * deleted redundant logging * refactoring: updated isEmpty to hasEvent --- .../controller/plugin/TbWebSocketHandler.java | 12 ++ .../DefaultTbLocalSubscriptionService.java | 107 ++++++++++++------ .../ModifySubscriptionResult.java | 39 +++++++ .../service/ws/DefaultWebSocketService.java | 10 ++ .../service/ws/WebSocketMsgEndpoint.java | 2 + .../server/service/ws/WebSocketService.java | 3 + 6 files changed, 140 insertions(+), 33 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/service/subscription/ModifySubscriptionResult.java 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 index 71350bb1fd..69728b20c3 100644 --- a/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java +++ b/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java @@ -539,6 +539,18 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke } } + @Override + public boolean isOpen(String externalId) { + String internalId = externalSessionMap.get(externalId); + if (internalId != null) { + SessionMetaData sessionMd = getSessionMd(internalId); + if (sessionMd != null) { + return sessionMd.session.isOpen(); + } + } + return false; + } + private boolean checkLimits(WebSocketSession session, WebSocketSessionRef sessionRef) throws IOException { var tenantProfileConfiguration = getTenantProfileConfiguration(sessionRef); if (tenantProfileConfiguration == null) { 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 index a280ae6754..d1addf8b0d 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionService.java @@ -45,6 +45,7 @@ import org.thingsboard.server.queue.discovery.PartitionService; import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; import org.thingsboard.server.queue.discovery.event.ClusterTopologyChangeEvent; import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.ws.WebSocketService; import org.thingsboard.server.service.ws.notification.sub.NotificationRequestUpdate; import org.thingsboard.server.service.ws.notification.sub.NotificationsSubscriptionUpdate; import org.thingsboard.server.service.ws.telemetry.sub.AlarmSubscriptionUpdate; @@ -62,6 +63,8 @@ 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.atomic.AtomicInteger; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; @@ -84,18 +87,21 @@ public class DefaultTbLocalSubscriptionService implements TbLocalSubscriptionSer private final PartitionService partitionService; private final TbClusterService clusterService; private final SubscriptionManagerService subscriptionManagerService; + private final WebSocketService webSocketService; private ExecutorService tsCallBackExecutor; + private ScheduledExecutorService staleSessionCleanupExecutor; public DefaultTbLocalSubscriptionService(AttributesService attrService, TimeseriesService tsService, TbServiceInfoProvider serviceInfoProvider, PartitionService partitionService, TbClusterService clusterService, - @Lazy SubscriptionManagerService subscriptionManagerService) { + @Lazy SubscriptionManagerService subscriptionManagerService, @Lazy WebSocketService webSocketService) { this.attrService = attrService; this.tsService = tsService; this.serviceInfoProvider = serviceInfoProvider; this.partitionService = partitionService; this.clusterService = clusterService; this.subscriptionManagerService = subscriptionManagerService; + this.webSocketService = webSocketService; } private String serviceId; @@ -108,6 +114,8 @@ public class DefaultTbLocalSubscriptionService implements TbLocalSubscriptionSer subscriptionUpdateExecutor = ThingsBoardExecutors.newWorkStealingPool(20, getClass()); tsCallBackExecutor = Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName("ts-sub-callback")); serviceId = serviceInfoProvider.getServiceId(); + staleSessionCleanupExecutor = Executors.newSingleThreadScheduledExecutor(ThingsBoardThreadFactory.forName("stale-session-cleanup")); + staleSessionCleanupExecutor.scheduleWithFixedDelay(this::cleanupStaleSessions, 60, 60, TimeUnit.SECONDS); } @PreDestroy @@ -118,6 +126,9 @@ public class DefaultTbLocalSubscriptionService implements TbLocalSubscriptionSer if (tsCallBackExecutor != null) { tsCallBackExecutor.shutdownNow(); } + if (staleSessionCleanupExecutor != null) { + staleSessionCleanupExecutor.shutdownNow(); + } } @Override @@ -157,9 +168,18 @@ public class DefaultTbLocalSubscriptionService implements TbLocalSubscriptionSer TenantId tenantId = subscription.getTenantId(); EntityId entityId = subscription.getEntityId(); log.debug("[{}][{}] Register subscription: {}", tenantId, entityId, subscription); - Map> sessionSubscriptions = subscriptionsBySessionId.computeIfAbsent(subscription.getSessionId(), k -> new ConcurrentHashMap<>()); - sessionSubscriptions.put(subscription.getSubscriptionId(), subscription); - modifySubscription(tenantId, entityId, subscription, true); + ModifySubscriptionResult modifySubscriptionResult; + subsLock.lock(); + try { + Map> sessionSubscriptions = subscriptionsBySessionId.computeIfAbsent(subscription.getSessionId(), k -> new ConcurrentHashMap<>()); + sessionSubscriptions.put(subscription.getSubscriptionId(), subscription); + modifySubscriptionResult = modifySubscription(tenantId, entityId, subscription, true); + } finally { + subsLock.unlock(); + } + if (modifySubscriptionResult.hasEvent()) { + pushSubscriptionEvent(modifySubscriptionResult); + } } @Override @@ -195,37 +215,49 @@ public class DefaultTbLocalSubscriptionService implements TbLocalSubscriptionSer @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); + ModifySubscriptionResult modifySubscriptionResult = null; + subsLock.lock(); + try { + Map> sessionSubscriptions = subscriptionsBySessionId.get(sessionId); + if (sessionSubscriptions != null) { + TbSubscription subscription = sessionSubscriptions.remove(subscriptionId); + if (subscription != null) { + if (sessionSubscriptions.isEmpty()) { + subscriptionsBySessionId.remove(sessionId); + } + modifySubscriptionResult = modifySubscription(subscription.getTenantId(), subscription.getEntityId(), subscription, false); + } else { + log.debug("[{}][{}] Subscription not found!", sessionId, subscriptionId); } - modifySubscription(subscription.getTenantId(), subscription.getEntityId(), subscription, false); } else { - log.debug("[{}][{}] Subscription not found!", sessionId, subscriptionId); + log.debug("[{}] No session subscriptions found!", sessionId); } - } else { - log.debug("[{}] No session subscriptions found!", sessionId); + } finally { + subsLock.unlock(); + } + if (modifySubscriptionResult != null && modifySubscriptionResult.hasEvent()) { + pushSubscriptionEvent(modifySubscriptionResult); } } @Override public void cancelAllSessionSubscriptions(String sessionId) { log.debug("[{}] Going to remove session subscriptions.", sessionId); - Map> sessionSubscriptions = subscriptionsBySessionId.remove(sessionId); - if (sessionSubscriptions != null) { - for (TbSubscription subscription : sessionSubscriptions.values()) { - try { - modifySubscription(subscription.getTenantId(), subscription.getEntityId(), subscription, false); - } catch (Exception e) { - log.warn("[{}][{}] Failed to remove subscription {} due to ", subscription.getTenantId(), subscription.getEntityId(), subscription, e); + List result = new ArrayList<>(); + subsLock.lock(); + try { + Map> sessionSubscriptions = subscriptionsBySessionId.remove(sessionId); + if (sessionSubscriptions != null) { + for (TbSubscription subscription : sessionSubscriptions.values()) { + result.add(modifySubscription(subscription.getTenantId(), subscription.getEntityId(), subscription, false)); } + } else { + log.debug("[{}] No session subscriptions found!", sessionId); } - } else { - log.debug("[{}] No session subscriptions found!", sessionId); + } finally { + subsLock.unlock(); } + result.stream().filter(ModifySubscriptionResult::hasEvent).forEach(this::pushSubscriptionEvent); } @Override @@ -388,10 +420,9 @@ public class DefaultTbLocalSubscriptionService implements TbLocalSubscriptionSer callback.onSuccess(); } - private void modifySubscription(TenantId tenantId, EntityId entityId, TbSubscription subscription, boolean add) { + private ModifySubscriptionResult modifySubscription(TenantId tenantId, EntityId entityId, TbSubscription subscription, boolean add) { TbSubscription missedUpdatesCandidate = null; - TbEntitySubEvent event; - subsLock.lock(); + TbEntitySubEvent event = null; try { TbEntityLocalSubsInfo entitySubs = subscriptionsByEntityId.computeIfAbsent(entityId.getId(), id -> new TbEntityLocalSubsInfo(tenantId, entityId)); event = add ? entitySubs.add(subscription) : entitySubs.remove(subscription); @@ -401,17 +432,23 @@ public class DefaultTbLocalSubscriptionService implements TbLocalSubscriptionSer } else if (add) { missedUpdatesCandidate = entitySubs.registerPendingSubscription(subscription, event); } - } finally { - subsLock.unlock(); + } catch (Exception e) { + log.warn("[{}][{}] Failed to {} subscription {} due to ", tenantId, entityId, add ? "add" : "remove", subscription, e); } - if (event != null) { - log.trace("[{}][{}][{}] Event: {}", tenantId, entityId, subscription.getSubscriptionId(), event); - pushSubEventToManagerService(tenantId, entityId, event); + return new ModifySubscriptionResult(tenantId, entityId, subscription, missedUpdatesCandidate, event); + } + + private void pushSubscriptionEvent(ModifySubscriptionResult modifySubResult) { + try { + TbEntitySubEvent event = modifySubResult.getEvent(); + log.trace("[{}][{}][{}] Event: {}", modifySubResult.getTenantId(), modifySubResult.getEntityId(), modifySubResult.getSubscription().getSubscriptionId(), event); + pushSubEventToManagerService(modifySubResult.getTenantId(), modifySubResult.getEntityId(), event); + TbSubscription missedUpdatesCandidate = modifySubResult.getMissedUpdatesCandidate(); if (missedUpdatesCandidate != null) { checkMissedUpdates(missedUpdatesCandidate); } - } else { - log.trace("[{}][{}][{}] No changes detected.", tenantId, entityId, subscription.getSubscriptionId()); + } catch (Exception e) { + log.warn("[{}][{}] Failed to push subscription event {} due to ", modifySubResult.getTenantId(), modifySubResult.getEntityId(), modifySubResult.getEvent(), e); } } @@ -522,4 +559,8 @@ public class DefaultTbLocalSubscriptionService implements TbLocalSubscriptionSer } } + private void cleanupStaleSessions() { + subscriptionsBySessionId.keySet().forEach(webSocketService::cleanupIfStale); + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/ModifySubscriptionResult.java b/application/src/main/java/org/thingsboard/server/service/subscription/ModifySubscriptionResult.java new file mode 100644 index 0000000000..483c250065 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/subscription/ModifySubscriptionResult.java @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.Data; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; + +/** + * The modification result of entity subscription + */ +@Builder +@Data +public class ModifySubscriptionResult { + + private TenantId tenantId; + private EntityId entityId; + private TbSubscription subscription; + private TbSubscription missedUpdatesCandidate; + private TbEntitySubEvent event; + + public boolean hasEvent() { + return event != null; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java b/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java index 78b78d8b8c..2a53e9a772 100644 --- a/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java +++ b/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java @@ -295,6 +295,16 @@ public class DefaultWebSocketService implements WebSocketService { } } + @Override + public void cleanupIfStale(String sessionId) { + if (!msgEndpoint.isOpen(sessionId)) { + log.info("[{}] Cleaning up stale session ", sessionId); + wsSessionsMap.remove(sessionId); + oldSubService.cancelAllSessionSubscriptions(sessionId); + entityDataSubService.cancelAllSessionSubscriptions(sessionId); + } + } + private void processSessionClose(WebSocketSessionRef sessionRef) { var tenantProfileConfiguration = getTenantProfileConfiguration(sessionRef); if (tenantProfileConfiguration != null) { diff --git a/application/src/main/java/org/thingsboard/server/service/ws/WebSocketMsgEndpoint.java b/application/src/main/java/org/thingsboard/server/service/ws/WebSocketMsgEndpoint.java index 41a68c98ca..416af080f8 100644 --- a/application/src/main/java/org/thingsboard/server/service/ws/WebSocketMsgEndpoint.java +++ b/application/src/main/java/org/thingsboard/server/service/ws/WebSocketMsgEndpoint.java @@ -29,4 +29,6 @@ public interface WebSocketMsgEndpoint { void sendPing(WebSocketSessionRef sessionRef, long currentTime) throws IOException; void close(WebSocketSessionRef sessionRef, CloseStatus withReason) throws IOException; + + boolean isOpen(String sessionId); } diff --git a/application/src/main/java/org/thingsboard/server/service/ws/WebSocketService.java b/application/src/main/java/org/thingsboard/server/service/ws/WebSocketService.java index 8f586f264e..284431ff84 100644 --- a/application/src/main/java/org/thingsboard/server/service/ws/WebSocketService.java +++ b/application/src/main/java/org/thingsboard/server/service/ws/WebSocketService.java @@ -36,4 +36,7 @@ public interface WebSocketService { void sendError(WebSocketSessionRef sessionRef, int subId, SubscriptionErrorCode errorCode, String errorMsg); void close(String sessionId, CloseStatus status); + + void cleanupIfStale(String sessionId); + } From cdc9bda92c94c22fc0b8b7bd822ec0ff33af74a1 Mon Sep 17 00:00:00 2001 From: ViacheslavKlimov Date: Fri, 17 May 2024 18:12:32 +0300 Subject: [PATCH 77/84] Minor refactoring for subscription service --- .../DefaultTbLocalSubscriptionService.java | 38 +++++++++---------- ...va => SubscriptionModificationResult.java} | 2 +- 2 files changed, 20 insertions(+), 20 deletions(-) rename application/src/main/java/org/thingsboard/server/service/subscription/{ModifySubscriptionResult.java => SubscriptionModificationResult.java} (96%) 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 index d1addf8b0d..8abb78d9c1 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionService.java @@ -168,17 +168,17 @@ public class DefaultTbLocalSubscriptionService implements TbLocalSubscriptionSer TenantId tenantId = subscription.getTenantId(); EntityId entityId = subscription.getEntityId(); log.debug("[{}][{}] Register subscription: {}", tenantId, entityId, subscription); - ModifySubscriptionResult modifySubscriptionResult; + SubscriptionModificationResult result; subsLock.lock(); try { Map> sessionSubscriptions = subscriptionsBySessionId.computeIfAbsent(subscription.getSessionId(), k -> new ConcurrentHashMap<>()); sessionSubscriptions.put(subscription.getSubscriptionId(), subscription); - modifySubscriptionResult = modifySubscription(tenantId, entityId, subscription, true); + result = modifySubscription(tenantId, entityId, subscription, true); } finally { subsLock.unlock(); } - if (modifySubscriptionResult.hasEvent()) { - pushSubscriptionEvent(modifySubscriptionResult); + if (result.hasEvent()) { + pushSubscriptionEvent(result); } } @@ -215,7 +215,7 @@ public class DefaultTbLocalSubscriptionService implements TbLocalSubscriptionSer @Override public void cancelSubscription(String sessionId, int subscriptionId) { log.debug("[{}][{}] Going to remove subscription.", sessionId, subscriptionId); - ModifySubscriptionResult modifySubscriptionResult = null; + SubscriptionModificationResult result = null; subsLock.lock(); try { Map> sessionSubscriptions = subscriptionsBySessionId.get(sessionId); @@ -225,7 +225,7 @@ public class DefaultTbLocalSubscriptionService implements TbLocalSubscriptionSer if (sessionSubscriptions.isEmpty()) { subscriptionsBySessionId.remove(sessionId); } - modifySubscriptionResult = modifySubscription(subscription.getTenantId(), subscription.getEntityId(), subscription, false); + result = modifySubscription(subscription.getTenantId(), subscription.getEntityId(), subscription, false); } else { log.debug("[{}][{}] Subscription not found!", sessionId, subscriptionId); } @@ -235,21 +235,21 @@ public class DefaultTbLocalSubscriptionService implements TbLocalSubscriptionSer } finally { subsLock.unlock(); } - if (modifySubscriptionResult != null && modifySubscriptionResult.hasEvent()) { - pushSubscriptionEvent(modifySubscriptionResult); + if (result != null && result.hasEvent()) { + pushSubscriptionEvent(result); } } @Override public void cancelAllSessionSubscriptions(String sessionId) { log.debug("[{}] Going to remove session subscriptions.", sessionId); - List result = new ArrayList<>(); + List results = new ArrayList<>(); subsLock.lock(); try { Map> sessionSubscriptions = subscriptionsBySessionId.remove(sessionId); if (sessionSubscriptions != null) { for (TbSubscription subscription : sessionSubscriptions.values()) { - result.add(modifySubscription(subscription.getTenantId(), subscription.getEntityId(), subscription, false)); + results.add(modifySubscription(subscription.getTenantId(), subscription.getEntityId(), subscription, false)); } } else { log.debug("[{}] No session subscriptions found!", sessionId); @@ -257,7 +257,7 @@ public class DefaultTbLocalSubscriptionService implements TbLocalSubscriptionSer } finally { subsLock.unlock(); } - result.stream().filter(ModifySubscriptionResult::hasEvent).forEach(this::pushSubscriptionEvent); + results.stream().filter(SubscriptionModificationResult::hasEvent).forEach(this::pushSubscriptionEvent); } @Override @@ -420,7 +420,7 @@ public class DefaultTbLocalSubscriptionService implements TbLocalSubscriptionSer callback.onSuccess(); } - private ModifySubscriptionResult modifySubscription(TenantId tenantId, EntityId entityId, TbSubscription subscription, boolean add) { + private SubscriptionModificationResult modifySubscription(TenantId tenantId, EntityId entityId, TbSubscription subscription, boolean add) { TbSubscription missedUpdatesCandidate = null; TbEntitySubEvent event = null; try { @@ -435,20 +435,20 @@ public class DefaultTbLocalSubscriptionService implements TbLocalSubscriptionSer } catch (Exception e) { log.warn("[{}][{}] Failed to {} subscription {} due to ", tenantId, entityId, add ? "add" : "remove", subscription, e); } - return new ModifySubscriptionResult(tenantId, entityId, subscription, missedUpdatesCandidate, event); + return new SubscriptionModificationResult(tenantId, entityId, subscription, missedUpdatesCandidate, event); } - private void pushSubscriptionEvent(ModifySubscriptionResult modifySubResult) { + private void pushSubscriptionEvent(SubscriptionModificationResult modificationResult) { try { - TbEntitySubEvent event = modifySubResult.getEvent(); - log.trace("[{}][{}][{}] Event: {}", modifySubResult.getTenantId(), modifySubResult.getEntityId(), modifySubResult.getSubscription().getSubscriptionId(), event); - pushSubEventToManagerService(modifySubResult.getTenantId(), modifySubResult.getEntityId(), event); - TbSubscription missedUpdatesCandidate = modifySubResult.getMissedUpdatesCandidate(); + TbEntitySubEvent event = modificationResult.getEvent(); + log.trace("[{}][{}][{}] Event: {}", modificationResult.getTenantId(), modificationResult.getEntityId(), modificationResult.getSubscription().getSubscriptionId(), event); + pushSubEventToManagerService(modificationResult.getTenantId(), modificationResult.getEntityId(), event); + TbSubscription missedUpdatesCandidate = modificationResult.getMissedUpdatesCandidate(); if (missedUpdatesCandidate != null) { checkMissedUpdates(missedUpdatesCandidate); } } catch (Exception e) { - log.warn("[{}][{}] Failed to push subscription event {} due to ", modifySubResult.getTenantId(), modifySubResult.getEntityId(), modifySubResult.getEvent(), e); + log.warn("[{}][{}] Failed to push subscription event {} due to ", modificationResult.getTenantId(), modificationResult.getEntityId(), modificationResult.getEvent(), e); } } diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/ModifySubscriptionResult.java b/application/src/main/java/org/thingsboard/server/service/subscription/SubscriptionModificationResult.java similarity index 96% rename from application/src/main/java/org/thingsboard/server/service/subscription/ModifySubscriptionResult.java rename to application/src/main/java/org/thingsboard/server/service/subscription/SubscriptionModificationResult.java index 483c250065..a31ecedb8f 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/ModifySubscriptionResult.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/SubscriptionModificationResult.java @@ -25,7 +25,7 @@ import org.thingsboard.server.common.data.id.TenantId; */ @Builder @Data -public class ModifySubscriptionResult { +public class SubscriptionModificationResult { private TenantId tenantId; private EntityId entityId; From 74a94efef724f7387223a7c51db79a49481051b0 Mon Sep 17 00:00:00 2001 From: ViacheslavKlimov Date: Tue, 21 May 2024 18:36:32 +0300 Subject: [PATCH 78/84] Truncate notification table on test tear down --- .../service/notification/AbstractNotificationApiTest.java | 6 +++++- .../server/service/notification/NotificationApiTest.java | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/service/notification/AbstractNotificationApiTest.java b/application/src/test/java/org/thingsboard/server/service/notification/AbstractNotificationApiTest.java index ae9dbe92c4..15650ff2e6 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/AbstractNotificationApiTest.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/AbstractNotificationApiTest.java @@ -21,6 +21,7 @@ import org.junit.After; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.data.util.Pair; +import org.springframework.jdbc.core.JdbcTemplate; import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.rule.engine.api.notification.SlackService; import org.thingsboard.server.common.data.User; @@ -102,6 +103,8 @@ public abstract class AbstractNotificationApiTest extends AbstractControllerTest protected SqlPartitioningRepository partitioningRepository; @Autowired protected DefaultNotifications defaultNotifications; + @Autowired + private JdbcTemplate jdbcTemplate; public static final String DEFAULT_NOTIFICATION_SUBJECT = "Just a test"; public static final NotificationType DEFAULT_NOTIFICATION_TYPE = NotificationType.GENERAL; @@ -112,7 +115,8 @@ public abstract class AbstractNotificationApiTest extends AbstractControllerTest notificationRuleService.deleteNotificationRulesByTenantId(TenantId.SYS_TENANT_ID); notificationTemplateService.deleteNotificationTemplatesByTenantId(TenantId.SYS_TENANT_ID); notificationTargetService.deleteNotificationTargetsByTenantId(TenantId.SYS_TENANT_ID); - partitioningRepository.dropPartitionsBefore("notification", Long.MAX_VALUE, 1); + jdbcTemplate.execute("TRUNCATE TABLE notification"); + partitioningRepository.cleanupPartitionsCache("notification", Long.MAX_VALUE, 0); notificationSettingsService.deleteNotificationSettings(TenantId.SYS_TENANT_ID); } diff --git a/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java b/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java index 1a788d437b..883a173edb 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java @@ -331,7 +331,7 @@ public class NotificationApiTest extends AbstractNotificationApiTest { @Test public void testNotificationUpdatesForSeveralUsers() throws Exception { - int usersCount = 150; + int usersCount = 50; Map sessions = new HashMap<>(); List targets = new ArrayList<>(); From 684ad858b2b55b7e45544cbcfa922159839cb817 Mon Sep 17 00:00:00 2001 From: nick Date: Wed, 22 May 2024 07:44:59 +0300 Subject: [PATCH 79/84] lwm2m: fix bug Content Format M14 --- .../lwm2m/AbstractLwM2MIntegrationTest.java | 5 +- .../lwm2m/client/LwM2MTestClient.java | 33 ++++++- .../rpc/AbstractRpcLwM2MIntegrationTest.java | 3 + .../sql/RpcLwm2mIntegrationWriteCborTest.java | 98 +++++++++++++++++++ .../rpc/sql/RpcLwm2mIntegrationWriteTest.java | 27 +++++ .../lwm2m/server/client/LwM2mClient.java | 9 +- .../DefaultLwM2mDownlinkMsgHandler.java | 50 ++-------- 7 files changed, 175 insertions(+), 50 deletions(-) create mode 100644 application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationWriteCborTest.java diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/AbstractLwM2MIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/AbstractLwM2MIntegrationTest.java index a44d2b6a62..983cfde0e5 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/AbstractLwM2MIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/AbstractLwM2MIntegrationTest.java @@ -178,6 +178,7 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte private String[] resources; protected String deviceId; protected boolean isWriteAttribute = false; + protected boolean supportFormatOnly_SenMLJSON_SenMLCBOR = false; @Before public void startInit() throws Exception { @@ -318,8 +319,8 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte try (ServerSocket socket = new ServerSocket(0)) { int clientPort = socket.getLocalPort(); lwM2MTestClient.init(security, securityBs, clientPort, isRpc, - this.defaultLwM2mUplinkMsgHandlerTest, this.clientContextTest, isWriteAttribute - , clientDtlsCidLength, queueMode); + this.defaultLwM2mUplinkMsgHandlerTest, this.clientContextTest, isWriteAttribute, + clientDtlsCidLength, queueMode, supportFormatOnly_SenMLJSON_SenMLCBOR); } } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2MTestClient.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2MTestClient.java index 00a5510788..02ff8be9d4 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2MTestClient.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2MTestClient.java @@ -47,10 +47,19 @@ import org.eclipse.leshan.core.model.ObjectModel; import org.eclipse.leshan.core.model.StaticModel; import org.eclipse.leshan.core.node.codec.DefaultLwM2mDecoder; import org.eclipse.leshan.core.node.codec.DefaultLwM2mEncoder; +import org.eclipse.leshan.core.node.codec.NodeDecoder; +import org.eclipse.leshan.core.node.codec.NodeEncoder; +import org.eclipse.leshan.core.node.codec.cbor.LwM2mNodeCborDecoder; +import org.eclipse.leshan.core.node.codec.cbor.LwM2mNodeCborEncoder; +import org.eclipse.leshan.core.node.codec.senml.LwM2mNodeSenMLDecoder; +import org.eclipse.leshan.core.node.codec.senml.LwM2mNodeSenMLEncoder; import org.eclipse.leshan.core.request.BootstrapRequest; +import org.eclipse.leshan.core.request.ContentFormat; import org.eclipse.leshan.core.request.DeregisterRequest; import org.eclipse.leshan.core.request.RegisterRequest; import org.eclipse.leshan.core.request.UpdateRequest; +import org.eclipse.leshan.senml.cbor.upokecenter.SenMLCborUpokecenterEncoderDecoder; +import org.eclipse.leshan.senml.json.jackson.SenMLJsonJacksonEncoderDecoder; import org.junit.Assert; import org.mockito.Mockito; import org.thingsboard.server.transport.lwm2m.server.client.LwM2mClient; @@ -78,6 +87,7 @@ import static org.eclipse.leshan.core.LwM2mId.LOCATION; import static org.eclipse.leshan.core.LwM2mId.SECURITY; import static org.eclipse.leshan.core.LwM2mId.SERVER; import static org.eclipse.leshan.core.LwM2mId.SOFTWARE_MANAGEMENT; +import static org.eclipse.leshan.core.node.codec.DefaultLwM2mEncoder.getDefaultPathEncoder; import static org.thingsboard.server.transport.lwm2m.AbstractLwM2MIntegrationTest.serverId; import static org.thingsboard.server.transport.lwm2m.AbstractLwM2MIntegrationTest.serverIdBs; import static org.thingsboard.server.transport.lwm2m.AbstractLwM2MIntegrationTest.shortServerId; @@ -130,7 +140,8 @@ public class LwM2MTestClient { public void init(Security security, Security securityBs, int port, boolean isRpc, LwM2mUplinkMsgHandler defaultLwM2mUplinkMsgHandler, - LwM2mClientContext clientContext, boolean isWriteAttribute, Integer cIdLength, boolean queueMode) throws InvalidDDFFileException, IOException { + LwM2mClientContext clientContext, boolean isWriteAttribute, Integer cIdLength, boolean queueMode, + boolean supportFormatOnly_SenMLJSON_SenMLCBOR) throws InvalidDDFFileException, IOException { Assert.assertNull("client already initialized", leshanClient); this.defaultLwM2mUplinkMsgHandlerTest = defaultLwM2mUplinkMsgHandler; this.clientContext = clientContext; @@ -274,12 +285,28 @@ public class LwM2MTestClient { builder.setEndpointsProviders(endpointsProvider.toArray(new LwM2mClientEndpointsProvider[endpointsProvider.size()])); builder.setDataSenders(new ManualDataSender()); builder.setRegistrationEngineFactory(engineFactory); - boolean supportOldFormat = true; - if (supportOldFormat) { + Map decoders = new HashMap<>(); + Map encoders = new HashMap<>(); + if (supportFormatOnly_SenMLJSON_SenMLCBOR) { +// decoders.put(ContentFormat.OPAQUE, new LwM2mNodeOpaqueDecoder()); + decoders.put(ContentFormat.CBOR, new LwM2mNodeCborDecoder()); + decoders.put(ContentFormat.SENML_JSON, new LwM2mNodeSenMLDecoder(new SenMLJsonJacksonEncoderDecoder(), true)); + decoders.put(ContentFormat.SENML_CBOR, new LwM2mNodeSenMLDecoder(new SenMLCborUpokecenterEncoderDecoder(), false)); + builder.setDecoder(new DefaultLwM2mDecoder(decoders)); + +// encoders.put(ContentFormat.OPAQUE, new LwM2mNodeOpaqueEncoder()); + encoders.put(ContentFormat.CBOR, new LwM2mNodeCborEncoder()); + encoders.put(ContentFormat.SENML_JSON, new LwM2mNodeSenMLEncoder(new SenMLJsonJacksonEncoderDecoder())); + encoders.put(ContentFormat.SENML_CBOR, new LwM2mNodeSenMLEncoder(new SenMLCborUpokecenterEncoderDecoder())); + builder.setEncoder(new DefaultLwM2mEncoder(new LwM2mValueConverterImpl(), false)); + builder.setEncoder(new DefaultLwM2mEncoder(encoders, getDefaultPathEncoder(), new LwM2mValueConverterImpl())); + } else { + boolean supportOldFormat = true; builder.setDecoder(new DefaultLwM2mDecoder(supportOldFormat)); builder.setEncoder(new DefaultLwM2mEncoder(new LwM2mValueConverterImpl(), supportOldFormat)); } + builder.setRegistrationEngineFactory(engineFactory); builder.setSharedExecutor(executor); diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationTest.java index 1c6e384ae1..b5f23c3e9a 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationTest.java @@ -86,6 +86,9 @@ public abstract class AbstractRpcLwM2MIntegrationTest extends AbstractLwM2MInteg if (this.getClass().getSimpleName().equals("RpcLwm2mIntegrationDiscoverWriteAttributesTest")){ isWriteAttribute = true; } + if (this.getClass().getSimpleName().equals("RpcLwm2mIntegrationWriteCborTest")){ + supportFormatOnly_SenMLJSON_SenMLCBOR = true; + } initRpc(); } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationWriteCborTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationWriteCborTest.java new file mode 100644 index 0000000000..2850d5b8cd --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationWriteCborTest.java @@ -0,0 +1,98 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.lwm2m.rpc.sql; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.eclipse.leshan.core.ResponseCode; +import org.junit.Test; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.transport.lwm2m.rpc.AbstractRpcLwM2MIntegrationTest; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.OBJECT_INSTANCE_ID_0; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_0; + +public class RpcLwm2mIntegrationWriteCborTest extends AbstractRpcLwM2MIntegrationTest { + + /** + * Client with ContentFormat ={} + */ + + /** + * Resource: + * Opaque + * Input type String (HexDec) + * WriteReplace {"id": "/19_1.1/0/0","value": {"0":"01234567890CAB"}} + * return Hex.decodeHex(((String)value).toCharArray()) - ok [1, 35, 69, 103, -119, 12, -85, -1] + * {"result":"CHANGED"} -> Actual Value Equals expectedValue0 + */ + @Test + public void testWriteReplaceValueMultipleResource_InputFormatData_Ok_Result_CHANGED_Value_Equals_expectedValue0() throws Exception { + String expectedPath = objectIdVer_19 + "/" + OBJECT_INSTANCE_ID_0 + "/" + RESOURCE_ID_0; + int resourceInstanceId0 = 0; + String expectedValue0 = "01234567890CABFF"; + String expectedValue = "{\"" + resourceInstanceId0 + "\":\"" + expectedValue0 + "\"}"; + String actualResult = sendRPCWriteObjectById("WriteReplace", expectedPath, expectedValue); + ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); + assertEquals(ResponseCode.CHANGED.getName(), rpcActualResult.get("result").asText()); + String expectedPath0 = expectedPath + "/" + resourceInstanceId0; + actualResult = sendRPCReadById(expectedPath0); + rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); + String actualValues = rpcActualResult.get("value").asText(); + String expected = "LwM2mResourceInstance [id=" + resourceInstanceId0 + ", value=" + expectedValue0.length()/2 + "Bytes, type=OPAQUE]"; + assertTrue(actualValues.contains(expected)); + } + + /** + * Resource: + * Opaque + * Input type String (not HexDec) + * return Hex.decodeHex(((String)value).toCharArray()) - error + * return Base64.getDecoder().decode(((String) value).getBytes()) - ok; + * WriteReplace {"id": "/19_1.1/0/0","value": {"0":"01234567890"}} + * {"result":"CHANGED"} -> Actual Value Not Equals expectedValue0 + */ + @Test + public void testWriteReplaceValueMultipleResource_Error_InputFormatData_Result_CHANGED_Value_Not_Equals_expectedValue0() throws Exception { + String expectedPath = objectIdVer_19 + "/" + OBJECT_INSTANCE_ID_0 + "/" + RESOURCE_ID_0; + int resourceInstanceId0 = 0; + String expectedValue0 = "01234567890"; + String expectedValue = "{\"" + resourceInstanceId0 + "\":\"" + expectedValue0 + "\"}"; + String actualResult = sendRPCWriteObjectById("WriteReplace", expectedPath, expectedValue); + ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); + assertEquals(ResponseCode.CHANGED.getName(), rpcActualResult.get("result").asText()); + String expectedPath0 = expectedPath + "/" + resourceInstanceId0; + actualResult = sendRPCReadById(expectedPath0); + rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); + String actualValues = rpcActualResult.get("value").asText(); + String expected = "LwM2mResourceInstance [id=" + resourceInstanceId0 + ", value=" + expectedValue0.length()/2 + "Bytes, type=OPAQUE]"; + assertFalse(actualValues.contains(expected)); + } + + + private String sendRPCWriteObjectById(String method, String path, Object value) throws Exception { + String setRpcRequest = "{\"method\": \"" + method + "\", \"params\": {\"id\": \"" + path + "\", \"value\": " + value + " }}"; + return doPostAsync("/api/plugins/rpc/twoway/" + deviceId, setRpcRequest, String.class, status().isOk()); + } + + private String sendRPCReadById(String id) throws Exception { + String setRpcRequest = "{\"method\": \"Read\", \"params\": {\"id\": \"" + id + "\"}}"; + return doPostAsync("/api/plugins/rpc/twoway/" + deviceId, setRpcRequest, String.class, status().isOk()); + } +} diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationWriteTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationWriteTest.java index a230b17eb3..f99826780e 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationWriteTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationWriteTest.java @@ -23,6 +23,7 @@ import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.transport.lwm2m.rpc.AbstractRpcLwM2MIntegrationTest; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.OBJECT_INSTANCE_ID_0; @@ -107,6 +108,32 @@ public class RpcLwm2mIntegrationWriteTest extends AbstractRpcLwM2MIntegrationTes assertTrue(actualValues.contains(expected)); } + /** + * Resource: + * Opaque + * Input type String (not HexDec) + * return Hex.decodeHex(((String)value).toCharArray()) - error + * return Base64.getDecoder().decode(((String) value).getBytes()) - ok; + * WriteReplace {"id": "/19_1.1/0/0","value": {"0":"01234567890"}} + * {"result":"CHANGED"} -> Actual Value Not Equals expectedValue0 + */ + @Test + public void testWriteReplaceValueMultipleResource_Error_InputFormatData_Result_CHANGED_Value_Not_Equals_expectedValue0() throws Exception { + String expectedPath = objectIdVer_19 + "/" + OBJECT_INSTANCE_ID_0 + "/" + RESOURCE_ID_0; + int resourceInstanceId0 = 0; + String expectedValue0 = "01234567890"; + String expectedValue = "{\"" + resourceInstanceId0 + "\":\"" + expectedValue0 + "\"}"; + String actualResult = sendRPCWriteObjectById("WriteReplace", expectedPath, expectedValue); + ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); + assertEquals(ResponseCode.CHANGED.getName(), rpcActualResult.get("result").asText()); + String expectedPath0 = expectedPath + "/" + resourceInstanceId0; + actualResult = sendRPCReadById(expectedPath0); + rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); + String actualValues = rpcActualResult.get("value").asText(); + String expected = "LwM2mResourceInstance [id=" + resourceInstanceId0 + ", value=" + expectedValue0.length()/2 + "Bytes, type=OPAQUE]"; + assertFalse(actualValues.contains(expected)); + } + /** * bad: singleResource, operation="R" - only read * WriteReplace {"id":"/3/0/9","value":90} diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClient.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClient.java index 141aa83b7e..5672ae33c3 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClient.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClient.java @@ -61,7 +61,6 @@ import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.stream.Collectors; -import java.util.stream.Stream; import static org.thingsboard.server.common.data.lwm2m.LwM2mConstants.LWM2M_SEPARATOR_PATH; import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.LWM2M_OBJECT_VERSION_DEFAULT; @@ -417,14 +416,14 @@ public class LwM2mClient { private static Set clientSupportContentFormat(Registration registration) { Set contentFormats = new HashSet<>(); - contentFormats.add(ContentFormat.DEFAULT); Attribute ct = Arrays.stream(registration.getObjectLinks()) .filter(link -> link.getUriReference().equals("/")) .findFirst() .map(link -> link.getAttributes().get("ct")).orElse(null); - if (ct != null) { - Set codes = Stream.of(ct.getValue()).collect(Collectors.toSet()); - contentFormats.addAll(codes); + if (ct != null && ct.getValue() instanceof Collection) { + contentFormats.addAll((Collection) ct.getValue()); + } else { + contentFormats.add(ContentFormat.DEFAULT); } return contentFormats; } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/DefaultLwM2mDownlinkMsgHandler.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/DefaultLwM2mDownlinkMsgHandler.java index af127dba88..15171b168f 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/DefaultLwM2mDownlinkMsgHandler.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/DefaultLwM2mDownlinkMsgHandler.java @@ -98,7 +98,6 @@ import java.util.Set; import java.util.TreeSet; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.RejectedExecutionException; -import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; import java.util.function.Predicate; import java.util.stream.Collectors; @@ -115,8 +114,6 @@ import static org.eclipse.leshan.core.link.lwm2m.attributes.LwM2mAttributes.OBJE import static org.eclipse.leshan.core.link.lwm2m.attributes.LwM2mAttributes.SERVER_URI; import static org.eclipse.leshan.core.link.lwm2m.attributes.LwM2mAttributes.SHORT_SERVER_ID; import static org.eclipse.leshan.core.link.lwm2m.attributes.LwM2mAttributes.STEP; -import static org.eclipse.leshan.core.model.ResourceModel.Type.OBJLNK; -import static org.eclipse.leshan.core.model.ResourceModel.Type.OPAQUE; import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.convertMultiResourceValuesFromRpcBody; import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.createModelsDefault; import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.fromVersionedIdToObjectId; @@ -742,19 +739,8 @@ public class DefaultLwM2mDownlinkMsgHandler extends LwM2MExecutorAwareService im private static ContentFormat getRequestContentFormat(LwM2mClient client, String versionedId, LwM2mModelProvider modelProvider) { LwM2mPath pathIds = new LwM2mPath(fromVersionedIdToObjectId(versionedId)); - if (pathIds.isResource() || pathIds.isResourceInstance()) { - ResourceModel resourceModel = client.getResourceModel(versionedId, modelProvider); - if (resourceModel != null && (pathIds.isResourceInstance() || (pathIds.isResource() && !resourceModel.multiple))) { - if (OBJLNK.equals(resourceModel.type)) { - return ContentFormat.LINK; - } else if (OPAQUE.equals(resourceModel.type)) { - return ContentFormat.OPAQUE; - } else { - return findFirstContentFormatForComp(client.getClientSupportContentFormats(), client.getDefaultContentFormat(), ContentFormat.CBOR, ContentFormat.SENML_CBOR, ContentFormat.SENML_JSON); - } - } else { - return getContentFormatForComplex(client); - } + if (pathIds.isResourceInstance()) { + return findFirstContentFormatForComp(client.getClientSupportContentFormats(), client.getDefaultContentFormat(), ContentFormat.CBOR, ContentFormat.SENML_CBOR, ContentFormat.SENML_JSON); } else { return getContentFormatForComplex(client); } @@ -783,37 +769,21 @@ public class DefaultLwM2mDownlinkMsgHandler extends LwM2MExecutorAwareService im } } - private ContentFormat findFirstContentFormatForComposite (Set clientSupportContentFormats) { - ContentFormat contentFormat = findFirstContentFormatForComp(clientSupportContentFormats, null, ContentFormat.SENML_JSON, ContentFormat.SENML_CBOR); + private ContentFormat findFirstContentFormatForComposite (Set clientSupportContentFormats) { + ContentFormat contentFormat = findFirstContentFormatForComp(clientSupportContentFormats, null, ContentFormat.SENML_CBOR, ContentFormat.SENML_JSON); if (contentFormat != null) { return contentFormat; } else { throw new RuntimeException("This device does not support Composite Operation"); } } - private static ContentFormat findFirstContentFormatForComp(Set clientSupportContentFormats, ContentFormat defaultValue, ContentFormat... desiredFormats) { - AtomicReference compositeContentFormat = new AtomicReference<>(); - clientSupportContentFormats.forEach(c -> { - if (c instanceof Collection) { - for (ContentFormat contentFormat : desiredFormats) { - if (((Collection) c).contains(contentFormat)) { - compositeContentFormat.set(contentFormat); - break; - } - } - } else if (compositeContentFormat.get() == null && c instanceof ContentFormat) { - compositeContentFormat.set(Arrays.stream(desiredFormats).filter(f -> f.equals(c)).findFirst().get()); - } - }); - return compositeContentFormat.get() != null ? compositeContentFormat.get() : defaultValue; - } - - private static boolean containsObserveComposite(List l1, List l2) { - for (T elem : l1) { - if (l2.contains(elem)) { - return true; + private static ContentFormat findFirstContentFormatForComp(Set clientSupportContentFormats, ContentFormat defaultValue, ContentFormat... desiredFormats) { + List desiredFormatsList = Arrays.asList(desiredFormats); + for (ContentFormat c : clientSupportContentFormats) { + if (desiredFormatsList.contains(c)) { + return c; } } - return false; + return defaultValue; } } From 9eeeebc044be27e0b4330e97e4c3e83dfe495537 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 22 May 2024 08:41:51 +0200 Subject: [PATCH 80/84] Update locale.constant-de_DE.json Typo --- ui-ngx/src/assets/locale/locale.constant-de_DE.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-ngx/src/assets/locale/locale.constant-de_DE.json b/ui-ngx/src/assets/locale/locale.constant-de_DE.json index 5a2a7cbeeb..acc2a5dee7 100644 --- a/ui-ngx/src/assets/locale/locale.constant-de_DE.json +++ b/ui-ngx/src/assets/locale/locale.constant-de_DE.json @@ -1716,7 +1716,7 @@ "current-hour": "Aktuelle Stunde", "current-day": "Aktueller Tag", "current-day-so-far": "Aktueller Tag bisher", - "current-week": "Aktuelle Wiche (So - Sa)", + "current-week": "Aktuelle Woche (So - Sa)", "current-week-iso": "Aktuelle Woche (Mo - So)", "current-week-so-far": "Aktuelle Woche bisher (So - Sa)", "current-week-iso-so-far": "Aktuelle Woche bisher (Mo - So)", From 5809d014de3bedab7b9f533c0af1b8fc7c5dd248 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Wed, 22 May 2024 12:11:32 +0300 Subject: [PATCH 81/84] Queue option to duplicate msgs to all partitions (#10815) * Queue config option to duplicate msgs to all partitions * UI for queue config option to duplicate msgs to all partitions * Fixes for msg duplication; add tests * Introduce TbRuleEngineProducerService * Use TbRuleEngineProducerService in TbClusterService * Fix DefaultTbClusterServiceTest * Add correlationId to TbMsg * Refactor TbMsg * Support msg duplication for all msg types --- .../server/actors/ActorSystemContext.java | 4 + .../RuleChainActorMessageProcessor.java | 3 +- .../queue/DefaultTbClusterService.java | 17 +-- .../transport/DefaultTransportApiService.java | 1 + .../controller/BaseQueueControllerTest.java | 95 +++++++++++- .../housekeeper/HousekeeperServiceTest.java | 1 - .../queue/DefaultTbClusterServiceTest.java | 3 + .../server/common/data/queue/Queue.java | 7 + .../thingsboard/server/common/msg/TbMsg.java | 61 ++++++-- common/message/src/main/proto/tbmsg.proto | 6 +- common/proto/src/main/proto/queue.proto | 2 + .../common/TbRuleEngineProducerService.java | 74 ++++++++++ .../queue/discovery/HashPartitionService.java | 137 ++++++++++++------ .../queue/discovery/PartitionService.java | 4 + .../queue/discovery/QueueRoutingInfo.java | 9 +- .../kafka/TbKafkaConsumerStatsService.java | 4 +- .../service/DefaultTransportService.java | 48 +----- .../queue/tenant-profile-queues.component.ts | 3 +- .../profile/tenant-profile.component.ts | 9 +- .../queue/queue-form.component.html | 25 ++-- .../components/queue/queue-form.component.ts | 7 +- ui-ngx/src/app/shared/models/queue.models.ts | 15 +- .../assets/locale/locale.constant-en_US.json | 1 + 23 files changed, 397 insertions(+), 139 deletions(-) create mode 100644 common/queue/src/main/java/org/thingsboard/server/queue/common/TbRuleEngineProducerService.java diff --git a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java index f411344e3f..b125daa591 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java @@ -636,6 +636,10 @@ public class ActorSystemContext { return partitionService.resolve(serviceType, queueName, tenantId, entityId); } + public TopicPartitionInfo resolve(TenantId tenantId, EntityId entityId, TbMsg msg) { + return partitionService.resolve(ServiceType.TB_RULE_ENGINE, msg.getQueueName(), tenantId, entityId, msg.getPartition()); + } + public String getServiceId() { return serviceInfoProvider.getServiceId(); } diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActorMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActorMessageProcessor.java index a16e6122f5..eaa2218116 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActorMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActorMessageProcessor.java @@ -42,7 +42,6 @@ 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; @@ -295,7 +294,7 @@ public class RuleChainActorMessageProcessor extends ComponentMsgProcessor ruleNodeRelations = nodeRoutes.get(originatorNodeId); if (ruleNodeRelations == null) { // When unchecked, this will cause NullPointerException when rule node doesn't exist anymore 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 index 7af2579b89..bce5dece3b 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java @@ -30,7 +30,6 @@ 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.HasRuleEngineProfile; -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.TenantProfile; @@ -59,8 +58,8 @@ import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponse; import org.thingsboard.server.common.msg.rule.engine.DeviceEdgeUpdateMsg; import org.thingsboard.server.common.msg.rule.engine.DeviceNameOrTypeUpdateMsg; -import org.thingsboard.server.dao.edge.EdgeService; import org.thingsboard.server.common.util.ProtoUtils; +import org.thingsboard.server.dao.edge.EdgeService; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.FromDeviceRPCResponseProto; import org.thingsboard.server.gen.transport.TransportProtos.QueueDeleteMsg; @@ -74,6 +73,7 @@ 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.common.TbRuleEngineProducerService; import org.thingsboard.server.queue.discovery.PartitionService; import org.thingsboard.server.queue.discovery.TopicService; import org.thingsboard.server.queue.provider.TbQueueProducerProvider; @@ -116,6 +116,9 @@ public class DefaultTbClusterService implements TbClusterService { @Lazy private TbQueueProducerProvider producerProvider; + @Autowired + private TbRuleEngineProducerService ruleEngineProducerService; + @Autowired @Lazy private OtaPackageStateService otaPackageStateService; @@ -203,13 +206,8 @@ public class DefaultTbClusterService implements TbClusterService { HasRuleEngineProfile ruleEngineProfile = getRuleEngineProfileForEntityOrElseNull(tenantId, entityId); tbMsg = transformMsg(tbMsg, ruleEngineProfile); } - 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); + + ruleEngineProducerService.sendToRuleEngine(producerProvider.getRuleEngineMsgProducer(), tenantId, tbMsg, callback); toRuleEngineMsgs.incrementAndGet(); } @@ -584,6 +582,7 @@ public class DefaultTbClusterService implements TbClusterService { .setQueueName(queue.getName()) .setQueueTopic(queue.getTopic()) .setPartitions(queue.getPartitions()) + .setDuplicateMsgToAllPartitions(queue.isDuplicateMsgToAllPartitions()) .build()) .collect(Collectors.toList()); 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 index 6dcf84e721..f5bd42e29d 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java @@ -675,6 +675,7 @@ public class DefaultTransportApiService implements TransportApiService { .setQueueName(queue.getName()) .setQueueTopic(queue.getTopic()) .setPartitions(queue.getPartitions()) + .setDuplicateMsgToAllPartitions(queue.isDuplicateMsgToAllPartitions()) .build()).collect(Collectors.toList())).build()); } diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseQueueControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseQueueControllerTest.java index b0a6db944c..ad52934587 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseQueueControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseQueueControllerTest.java @@ -16,6 +16,8 @@ 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.apache.commons.lang3.RandomStringUtils; import org.junit.Assert; import org.junit.Test; @@ -25,9 +27,13 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.test.context.TestPropertySource; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.queue.ProcessingStrategy; @@ -36,14 +42,19 @@ import org.thingsboard.server.common.data.queue.Queue; import org.thingsboard.server.common.data.queue.QueueStats; import org.thingsboard.server.common.data.queue.SubmitStrategy; import org.thingsboard.server.common.data.queue.SubmitStrategyType; +import org.thingsboard.server.common.msg.TbActorMsg; +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.ServiceType; import org.thingsboard.server.common.stats.StatsFactory; import org.thingsboard.server.dao.queue.QueueStatsService; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.dao.timeseries.TimeseriesDao; +import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.gen.transport.TransportProtos; 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.service.queue.TbRuleEngineConsumerStats; import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingResult; @@ -57,20 +68,25 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; +import java.util.stream.IntStream; import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @DaoSqlTest @TestPropertySource(properties = { - "queue.rule-engine.stats.max-error-message-length=100" + "queue.rule-engine.stats.max-error-message-length=100", + "transport.http.enabled=true" }) public class BaseQueueControllerTest extends AbstractControllerTest { @@ -82,6 +98,12 @@ public class BaseQueueControllerTest extends AbstractControllerTest { private TimeseriesDao timeseriesDao; @Autowired private QueueStatsService queueStatsService; + @SpyBean + private PartitionService partitionService; + @SpyBean + private TimeseriesService timeseriesService; + @SpyBean + private ActorSystemContext actorSystemContext; @Test public void testQueueWithServiceTypeRE() throws Exception { @@ -236,4 +258,75 @@ public class BaseQueueControllerTest extends AbstractControllerTest { assertThat(finalErrorMessage).isEqualTo(largeExceptionMessage.substring(0, 100) + "...[truncated 50 symbols]"); } + @Test + public void testMsgDuplicationToAllPartitions_fromTransport() throws Exception { + loginSysAdmin(); + Queue queue = new Queue(); + queue.setName("RealTime"); + queue.setTopic("tb_rule_engine.real_time"); + queue.setPollInterval(25); + int partitions = 12; + queue.setPartitions(partitions); + queue.setTenantId(TenantId.SYS_TENANT_ID); + queue.setConsumerPerPartition(true); + queue.setPackProcessingTimeout(2000); + SubmitStrategy submitStrategy = new SubmitStrategy(); + submitStrategy.setType(SubmitStrategyType.BURST); + queue.setSubmitStrategy(submitStrategy); + ProcessingStrategy processingStrategy = new ProcessingStrategy(); + processingStrategy.setType(ProcessingStrategyType.RETRY_ALL); + processingStrategy.setRetries(0); + processingStrategy.setPauseBetweenRetries(3); + processingStrategy.setMaxPauseBetweenRetries(5); + queue.setProcessingStrategy(processingStrategy); + queue.setAdditionalInfo(JacksonUtil.newObjectNode() + .put("duplicateMsgToAllPartitions", true)); + queue = saveQueue(queue); + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + assertThat(partitionService.resolveAll(ServiceType.TB_RULE_ENGINE, "RealTime", tenantId, tenantId)).hasSize(partitions); + }); + + loginTenantAdmin(); + DeviceProfile deviceProfile = createDeviceProfile("realtime"); + deviceProfile.setDefaultQueueName(queue.getName()); + deviceProfile = doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class); + Device device = createDevice("test", deviceProfile.getName(), "test-token"); + + JsonNode payload = JacksonUtil.newObjectNode() + .put("test", "test"); + doPost("/api/v1/test-token/telemetry", payload).andExpect(status().isOk()); + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + verify(timeseriesService, times(partitions)).save(eq(tenantId), eq(device.getId()), + argThat(ts -> ts.size() == 1 && ts.get(0).getKey().equals("test")), anyLong()); + + ArgumentCaptor msgCaptor = ArgumentCaptor.forClass(TbActorMsg.class); + verify(actorSystemContext, atLeastOnce()).tell(msgCaptor.capture()); + List tbMsgs = msgCaptor.getAllValues().stream() + .map(actorMsg -> actorMsg instanceof QueueToRuleEngineMsg queueToRuleEngineMsg ? queueToRuleEngineMsg.getMsg() : null) + .filter(tbMsg -> tbMsg != null && tbMsg.getCorrelationId() != null && tbMsg.getInternalType() == TbMsgType.POST_TELEMETRY_REQUEST) + .toList(); + assertThat(tbMsgs).hasSize(partitions); + UUID correlationId = tbMsgs.get(0).getCorrelationId(); + assertThat(tbMsgs).extracting(TbMsg::getCorrelationId).containsOnly(correlationId); + assertThat(tbMsgs).extracting(TbMsg::getId).doesNotHaveDuplicates(); + assertThat(tbMsgs).extracting(TbMsg::getPartition).containsExactlyInAnyOrder(IntStream.range(0, partitions).boxed().toArray(Integer[]::new)); + }); + + loginSysAdmin(); + ((ObjectNode) queue.getAdditionalInfo()).put("duplicateMsgToAllPartitions", false); + queue = saveQueue(queue); + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + assertThat(partitionService.resolveAll(ServiceType.TB_RULE_ENGINE, "RealTime", tenantId, tenantId)).hasSize(1); + }); + + doDelete("/api/queues/" + queue.getUuidId()).andExpect(status().isOk()); + } + + private Queue saveQueue(Queue queue) { + return doPost("/api/queues?serviceType=TB_RULE_ENGINE", queue, Queue.class); + } + } diff --git a/application/src/test/java/org/thingsboard/server/service/housekeeper/HousekeeperServiceTest.java b/application/src/test/java/org/thingsboard/server/service/housekeeper/HousekeeperServiceTest.java index e8f1a43ad1..06740e2851 100644 --- a/application/src/test/java/org/thingsboard/server/service/housekeeper/HousekeeperServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/housekeeper/HousekeeperServiceTest.java @@ -101,7 +101,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @DaoSqlTest @TestPropertySource(properties = { - "transport.http.enabled=true", "queue.core.housekeeper.task-reprocessing-delay-ms=2000", "queue.core.housekeeper.poll-interval-ms=1000", "queue.core.housekeeper.max-reprocessing-attempts=5" diff --git a/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbClusterServiceTest.java b/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbClusterServiceTest.java index 33b0fc7801..22f3620d8c 100644 --- a/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbClusterServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbClusterServiceTest.java @@ -33,6 +33,7 @@ import org.thingsboard.server.dao.edge.EdgeService; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.queue.TbQueueProducer; import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.common.TbRuleEngineProducerService; import org.thingsboard.server.queue.discovery.PartitionService; import org.thingsboard.server.queue.discovery.TopicService; import org.thingsboard.server.queue.provider.TbQueueProducerProvider; @@ -77,6 +78,8 @@ public class DefaultTbClusterServiceTest { protected PartitionService partitionService; @MockBean protected TbQueueProducerProvider producerProvider; + @MockBean + protected TbRuleEngineProducerService ruleEngineProducerService; @SpyBean protected TopicService topicService; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/queue/Queue.java b/common/data/src/main/java/org/thingsboard/server/common/data/queue/Queue.java index 1c08072e61..ad5b9cf808 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/queue/Queue.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/queue/Queue.java @@ -73,4 +73,11 @@ public class Queue extends BaseDataWithAdditionalInfo implements HasNam .filter(JsonNode::isTextual).map(JsonNode::asText).orElse(null); } + @JsonIgnore + public boolean isDuplicateMsgToAllPartitions() { + return Optional.ofNullable(getAdditionalInfo()) + .map(info -> info.get("duplicateMsgToAllPartitions")) + .filter(JsonNode::isBoolean).map(JsonNode::asBoolean).orElse(false); + } + } diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsg.java index cbf9ae34b3..47fb80c90e 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsg.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsg.java @@ -19,6 +19,8 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.google.protobuf.ByteString; import com.google.protobuf.InvalidProtocolBufferException; import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; import lombok.Getter; import lombok.extern.slf4j.Slf4j; @@ -42,6 +44,8 @@ import java.util.UUID; */ @Data @Slf4j +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder(toBuilder = true) public final class TbMsg implements Serializable { public static final String EMPTY_JSON_OBJECT = "{}"; @@ -60,6 +64,10 @@ public final class TbMsg implements Serializable { private final String data; private final RuleChainId ruleChainId; private final RuleNodeId ruleNodeId; + + private final UUID correlationId; + private final Integer partition; + @Getter(value = AccessLevel.NONE) @JsonIgnore //This field is not serialized because we use queues and there is no need to do it @@ -257,7 +265,7 @@ public final class TbMsg implements Serializable { @Deprecated(since = "3.6.0") public static TbMsg transformMsg(TbMsg tbMsg, String type, EntityId originator, TbMsgMetaData metaData, String data) { return new TbMsg(tbMsg.queueName, tbMsg.id, tbMsg.ts, null, type, originator, tbMsg.customerId, metaData.copy(), tbMsg.dataType, - data, tbMsg.ruleChainId, tbMsg.ruleNodeId, tbMsg.ctx.copy(), tbMsg.callback); + data, tbMsg.ruleChainId, tbMsg.ruleNodeId, tbMsg.correlationId, tbMsg.partition, tbMsg.ctx.copy(), tbMsg.callback); } public static TbMsg newMsg(TbMsgType type, EntityId originator, TbMsgMetaData metaData, TbMsgDataType dataType, String data, RuleChainId ruleChainId, RuleNodeId ruleNodeId) { @@ -271,54 +279,54 @@ public final class TbMsg implements Serializable { } public static TbMsg transformMsg(TbMsg tbMsg, TbMsgType type, EntityId originator, TbMsgMetaData metaData, String data) { - return new TbMsg(tbMsg.queueName, tbMsg.id, tbMsg.ts, type, originator, tbMsg.customerId, metaData.copy(), tbMsg.dataType, - data, tbMsg.ruleChainId, tbMsg.ruleNodeId, tbMsg.ctx.copy(), tbMsg.callback); + return new TbMsg(tbMsg.queueName, tbMsg.id, tbMsg.ts, type, type.name(), originator, tbMsg.customerId, metaData.copy(), tbMsg.dataType, + data, tbMsg.ruleChainId, tbMsg.ruleNodeId, tbMsg.correlationId, tbMsg.partition, tbMsg.ctx.copy(), tbMsg.callback); } public static TbMsg transformMsgOriginator(TbMsg tbMsg, EntityId originatorId) { return new TbMsg(tbMsg.queueName, tbMsg.id, tbMsg.ts, tbMsg.internalType, tbMsg.type, originatorId, tbMsg.getCustomerId(), tbMsg.metaData, tbMsg.dataType, - tbMsg.data, tbMsg.ruleChainId, tbMsg.ruleNodeId, tbMsg.ctx.copy(), tbMsg.getCallback()); + tbMsg.data, tbMsg.ruleChainId, tbMsg.ruleNodeId, tbMsg.correlationId, tbMsg.partition, tbMsg.ctx.copy(), tbMsg.getCallback()); } public static TbMsg transformMsgData(TbMsg tbMsg, String data) { return new TbMsg(tbMsg.queueName, tbMsg.id, tbMsg.ts, tbMsg.internalType, tbMsg.type, tbMsg.originator, tbMsg.customerId, tbMsg.metaData, tbMsg.dataType, - data, tbMsg.ruleChainId, tbMsg.ruleNodeId, tbMsg.ctx.copy(), tbMsg.getCallback()); + data, tbMsg.ruleChainId, tbMsg.ruleNodeId, tbMsg.correlationId, tbMsg.partition, tbMsg.ctx.copy(), tbMsg.getCallback()); } public static TbMsg transformMsgMetadata(TbMsg tbMsg, TbMsgMetaData metadata) { return new TbMsg(tbMsg.queueName, tbMsg.id, tbMsg.ts, tbMsg.internalType, tbMsg.type, tbMsg.originator, tbMsg.customerId, metadata.copy(), tbMsg.dataType, - tbMsg.data, tbMsg.ruleChainId, tbMsg.ruleNodeId, tbMsg.ctx.copy(), tbMsg.getCallback()); + tbMsg.data, tbMsg.ruleChainId, tbMsg.ruleNodeId, tbMsg.correlationId, tbMsg.partition, tbMsg.ctx.copy(), tbMsg.getCallback()); } public static TbMsg transformMsg(TbMsg tbMsg, TbMsgMetaData metadata, String data) { return new TbMsg(tbMsg.queueName, tbMsg.id, tbMsg.ts, tbMsg.internalType, tbMsg.type, tbMsg.originator, tbMsg.customerId, metadata, tbMsg.dataType, - data, tbMsg.ruleChainId, tbMsg.ruleNodeId, tbMsg.ctx.copy(), tbMsg.getCallback()); + data, tbMsg.ruleChainId, tbMsg.ruleNodeId, tbMsg.correlationId, tbMsg.partition, tbMsg.ctx.copy(), tbMsg.getCallback()); } public static TbMsg transformMsgCustomerId(TbMsg tbMsg, CustomerId customerId) { return new TbMsg(tbMsg.queueName, tbMsg.id, tbMsg.ts, tbMsg.internalType, tbMsg.type, tbMsg.originator, customerId, tbMsg.metaData, tbMsg.dataType, - tbMsg.data, tbMsg.ruleChainId, tbMsg.ruleNodeId, tbMsg.ctx.copy(), tbMsg.getCallback()); + tbMsg.data, tbMsg.ruleChainId, tbMsg.ruleNodeId, tbMsg.correlationId, tbMsg.partition, tbMsg.ctx.copy(), tbMsg.getCallback()); } public static TbMsg transformMsgRuleChainId(TbMsg tbMsg, RuleChainId ruleChainId) { return new TbMsg(tbMsg.queueName, tbMsg.id, tbMsg.ts, tbMsg.internalType, tbMsg.type, tbMsg.originator, tbMsg.customerId, tbMsg.metaData, tbMsg.dataType, - tbMsg.data, ruleChainId, null, tbMsg.ctx.copy(), tbMsg.getCallback()); + tbMsg.data, ruleChainId, null, tbMsg.correlationId, tbMsg.partition, tbMsg.ctx.copy(), tbMsg.getCallback()); } public static TbMsg transformMsgQueueName(TbMsg tbMsg, String queueName) { return new TbMsg(queueName, tbMsg.id, tbMsg.ts, tbMsg.internalType, tbMsg.type, tbMsg.originator, tbMsg.customerId, tbMsg.metaData, tbMsg.dataType, - tbMsg.data, tbMsg.getRuleChainId(), null, tbMsg.ctx.copy(), tbMsg.getCallback()); + tbMsg.data, tbMsg.getRuleChainId(), null, tbMsg.correlationId, tbMsg.partition, tbMsg.ctx.copy(), tbMsg.getCallback()); } public static TbMsg transformMsg(TbMsg tbMsg, RuleChainId ruleChainId, String queueName) { return new TbMsg(queueName, tbMsg.id, tbMsg.ts, tbMsg.internalType, tbMsg.type, tbMsg.originator, tbMsg.customerId, tbMsg.metaData, tbMsg.dataType, - tbMsg.data, ruleChainId, null, tbMsg.ctx.copy(), tbMsg.getCallback()); + tbMsg.data, ruleChainId, null, tbMsg.correlationId, tbMsg.partition, tbMsg.ctx.copy(), tbMsg.getCallback()); } //used for enqueueForTellNext public static TbMsg newMsg(TbMsg tbMsg, String queueName, RuleChainId ruleChainId, RuleNodeId ruleNodeId) { return new TbMsg(queueName, UUID.randomUUID(), tbMsg.getTs(), tbMsg.getInternalType(), tbMsg.getType(), tbMsg.getOriginator(), tbMsg.customerId, tbMsg.getMetaData().copy(), - tbMsg.getDataType(), tbMsg.getData(), ruleChainId, ruleNodeId, tbMsg.ctx.copy(), TbMsgCallback.EMPTY); + tbMsg.getDataType(), tbMsg.getData(), ruleChainId, ruleNodeId, tbMsg.correlationId, tbMsg.partition, tbMsg.ctx.copy(), TbMsgCallback.EMPTY); } private TbMsg(String queueName, UUID id, long ts, TbMsgType internalType, EntityId originator, CustomerId customerId, TbMsgMetaData metaData, TbMsgDataType dataType, String data, @@ -328,6 +336,11 @@ public final class TbMsg implements Serializable { private TbMsg(String queueName, UUID id, long ts, TbMsgType internalType, String type, EntityId originator, CustomerId customerId, TbMsgMetaData metaData, TbMsgDataType dataType, String data, RuleChainId ruleChainId, RuleNodeId ruleNodeId, TbMsgProcessingCtx ctx, TbMsgCallback callback) { + this(queueName, id, ts, internalType, type, originator, customerId, metaData, dataType, data, ruleChainId, ruleNodeId, null, null, ctx, callback); + } + + private TbMsg(String queueName, UUID id, long ts, TbMsgType internalType, String type, EntityId originator, CustomerId customerId, TbMsgMetaData metaData, TbMsgDataType dataType, String data, + RuleChainId ruleChainId, RuleNodeId ruleNodeId, UUID correlationId, Integer partition, TbMsgProcessingCtx ctx, TbMsgCallback callback) { this.id = id; this.queueName = queueName; if (ts > 0) { @@ -352,6 +365,8 @@ public final class TbMsg implements Serializable { this.data = data; this.ruleChainId = ruleChainId; this.ruleNodeId = ruleNodeId; + this.correlationId = correlationId; + this.partition = partition; this.ctx = ctx != null ? ctx : new TbMsgProcessingCtx(); this.callback = Objects.requireNonNullElse(callback, TbMsgCallback.EMPTY); } @@ -391,6 +406,14 @@ public final class TbMsg implements Serializable { builder.setDataType(msg.getDataType().ordinal()); builder.setData(msg.getData()); + if (msg.getCorrelationId() != null) { + builder.setCorrelationIdMSB(msg.getCorrelationId().getMostSignificantBits()); + builder.setCorrelationIdLSB(msg.getCorrelationId().getLeastSignificantBits()); + } + if (msg.getPartition() != null) { + builder.setPartition(msg.getPartition()); + } + builder.setCtx(msg.ctx.toProto()); return builder.build().toByteArray(); } @@ -403,6 +426,8 @@ public final class TbMsg implements Serializable { CustomerId customerId = null; RuleChainId ruleChainId = null; RuleNodeId ruleNodeId = null; + UUID correlationId = null; + Integer partition = null; if (proto.getCustomerIdMSB() != 0L && proto.getCustomerIdLSB() != 0L) { customerId = new CustomerId(new UUID(proto.getCustomerIdMSB(), proto.getCustomerIdLSB())); } @@ -412,6 +437,10 @@ public final class TbMsg implements Serializable { if (proto.getRuleNodeIdMSB() != 0L && proto.getRuleNodeIdLSB() != 0L) { ruleNodeId = new RuleNodeId(new UUID(proto.getRuleNodeIdMSB(), proto.getRuleNodeIdLSB())); } + if (proto.getCorrelationIdMSB() != 0L && proto.getCorrelationIdLSB() != 0L) { + correlationId = new UUID(proto.getCorrelationIdMSB(), proto.getCorrelationIdLSB()); + partition = proto.getPartition(); + } TbMsgProcessingCtx ctx; if (proto.hasCtx()) { @@ -423,7 +452,7 @@ public final class TbMsg implements Serializable { TbMsgDataType dataType = TbMsgDataType.values()[proto.getDataType()]; return new TbMsg(queueName, UUID.fromString(proto.getId()), proto.getTs(), null, proto.getType(), entityId, customerId, - metaData, dataType, proto.getData(), ruleChainId, ruleNodeId, ctx, callback); + metaData, dataType, proto.getData(), ruleChainId, ruleNodeId, correlationId, partition, ctx, callback); } catch (InvalidProtocolBufferException e) { throw new IllegalStateException("Could not parse protobuf for TbMsg", e); } @@ -435,17 +464,17 @@ public final class TbMsg implements Serializable { public TbMsg copyWithRuleChainId(RuleChainId ruleChainId, UUID msgId) { return new TbMsg(this.queueName, msgId, this.ts, this.internalType, this.type, this.originator, this.customerId, - this.metaData, this.dataType, this.data, ruleChainId, null, this.ctx, callback); + this.metaData, this.dataType, this.data, ruleChainId, null, this.correlationId, this.partition, this.ctx, callback); } public TbMsg copyWithRuleNodeId(RuleChainId ruleChainId, RuleNodeId ruleNodeId, UUID msgId) { return new TbMsg(this.queueName, msgId, this.ts, this.internalType, this.type, this.originator, this.customerId, - this.metaData, this.dataType, this.data, ruleChainId, ruleNodeId, this.ctx, callback); + this.metaData, this.dataType, this.data, ruleChainId, ruleNodeId, this.correlationId, this.partition, this.ctx, callback); } public TbMsg copyWithNewCtx() { return new TbMsg(this.queueName, this.id, this.ts, this.internalType, this.type, this.originator, this.customerId, - this.metaData, this.dataType, this.data, ruleChainId, ruleNodeId, this.ctx.copy(), TbMsgCallback.EMPTY); + this.metaData, this.dataType, this.data, ruleChainId, ruleNodeId, this.correlationId, this.partition, this.ctx.copy(), TbMsgCallback.EMPTY); } public TbMsgCallback getCallback() { diff --git a/common/message/src/main/proto/tbmsg.proto b/common/message/src/main/proto/tbmsg.proto index 4c2d110888..fc9265aa14 100644 --- a/common/message/src/main/proto/tbmsg.proto +++ b/common/message/src/main/proto/tbmsg.proto @@ -66,4 +66,8 @@ message TbMsgProto { int64 customerIdLSB = 18; TbMsgProcessingCtxProto ctx = 19; -} \ No newline at end of file + + int64 correlationIdMSB = 20; + int64 correlationIdLSB = 21; + int32 partition = 22; +} diff --git a/common/proto/src/main/proto/queue.proto b/common/proto/src/main/proto/queue.proto index 53a3026424..faacf05968 100644 --- a/common/proto/src/main/proto/queue.proto +++ b/common/proto/src/main/proto/queue.proto @@ -419,6 +419,7 @@ message GetQueueRoutingInfoResponseMsg { string queueName = 5; string queueTopic = 6; int32 partitions = 7; + optional bool duplicateMsgToAllPartitions = 8; } message QueueUpdateMsg { @@ -429,6 +430,7 @@ message QueueUpdateMsg { string queueName = 5; string queueTopic = 6; int32 partitions = 7; + optional bool duplicateMsgToAllPartitions = 8; } message QueueDeleteMsg { diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/common/TbRuleEngineProducerService.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/TbRuleEngineProducerService.java new file mode 100644 index 0000000000..49b40e3a6d --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/TbRuleEngineProducerService.java @@ -0,0 +1,74 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.queue.common; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +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.discovery.PartitionService; + +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class TbRuleEngineProducerService { + + private final PartitionService partitionService; + + public void sendToRuleEngine(TbQueueProducer> producer, + TenantId tenantId, TbMsg tbMsg, TbQueueCallback callback) { + List tpis = partitionService.resolveAll(ServiceType.TB_RULE_ENGINE, tbMsg.getQueueName(), tenantId, tbMsg.getOriginator()); + if (tpis.size() > 1) { + UUID correlationId = UUID.randomUUID(); + for (int i = 0; i < tpis.size(); i++) { + TopicPartitionInfo tpi = tpis.get(i); + Integer partition = tpi.getPartition().orElse(null); + UUID id = i > 0 ? UUID.randomUUID() : tbMsg.getId(); + + tbMsg = tbMsg.toBuilder() + .id(id) + .correlationId(correlationId) + .partition(partition) + .build(); + sendToRuleEngine(producer, tpi, tenantId, tbMsg, i == tpis.size() - 1 ? callback : null); + } + } else { + sendToRuleEngine(producer, tpis.get(0), tenantId, tbMsg, callback); + } + } + + private void sendToRuleEngine(TbQueueProducer> producer, TopicPartitionInfo tpi, + TenantId tenantId, TbMsg tbMsg, TbQueueCallback callback) { + if (log.isTraceEnabled()) { + log.trace("[{}][{}] Pushing to topic {} message {}", tenantId, tbMsg.getOriginator(), tpi.getFullTopicName(), tbMsg); + } + ToRuleEngineMsg msg = ToRuleEngineMsg.newBuilder() + .setTbMsg(TbMsg.toByteString(tbMsg)) + .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) + .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()).build(); + producer.send(tpi, new TbProtoQueueMsg<>(tbMsg.getId(), msg), callback); + } + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/HashPartitionService.java b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/HashPartitionService.java index e5ad2a3622..04d55f91c0 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/HashPartitionService.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/HashPartitionService.java @@ -17,6 +17,7 @@ package org.thingsboard.server.queue.discovery; import com.google.common.hash.HashFunction; import com.google.common.hash.Hashing; +import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEventPublisher; @@ -77,6 +78,7 @@ public class HashPartitionService implements PartitionService { private final ConcurrentMap partitionTopicsMap = new ConcurrentHashMap<>(); private final ConcurrentMap partitionSizesMap = new ConcurrentHashMap<>(); + private final ConcurrentMap queueConfigs = new ConcurrentHashMap<>(); private final ConcurrentMap tenantRoutingInfoMap = new ConcurrentHashMap<>(); @@ -132,6 +134,7 @@ public class HashPartitionService implements PartitionService { QueueKey queueKey = new QueueKey(ServiceType.TB_RULE_ENGINE, queue); partitionTopicsMap.put(queueKey, queue.getQueueTopic()); partitionSizesMap.put(queueKey, queue.getPartitions()); + queueConfigs.put(queueKey, new QueueConfig(queue)); }); } @@ -174,10 +177,12 @@ public class HashPartitionService implements PartitionService { @Override public void updateQueues(List queueUpdateMsgs) { for (TransportProtos.QueueUpdateMsg queueUpdateMsg : queueUpdateMsgs) { - TenantId tenantId = TenantId.fromUUID(new UUID(queueUpdateMsg.getTenantIdMSB(), queueUpdateMsg.getTenantIdLSB())); - QueueKey queueKey = new QueueKey(ServiceType.TB_RULE_ENGINE, queueUpdateMsg.getQueueName(), tenantId); - partitionTopicsMap.put(queueKey, queueUpdateMsg.getQueueTopic()); - partitionSizesMap.put(queueKey, queueUpdateMsg.getPartitions()); + QueueRoutingInfo queueRoutingInfo = new QueueRoutingInfo(queueUpdateMsg); + TenantId tenantId = queueRoutingInfo.getTenantId(); + QueueKey queueKey = new QueueKey(ServiceType.TB_RULE_ENGINE, queueRoutingInfo.getQueueName(), tenantId); + partitionTopicsMap.put(queueKey, queueRoutingInfo.getQueueTopic()); + partitionSizesMap.put(queueKey, queueRoutingInfo.getPartitions()); + queueConfigs.put(queueKey, new QueueConfig(queueRoutingInfo)); if (!tenantId.isSysTenantId()) { tenantRoutingInfoMap.remove(tenantId); } @@ -193,9 +198,7 @@ public class HashPartitionService implements PartitionService { }) .collect(Collectors.toList()); queueKeys.forEach(queueKey -> { - myPartitions.remove(queueKey); - partitionTopicsMap.remove(queueKey); - partitionSizesMap.remove(queueKey); + removeQueue(queueKey); evictTenantInfo(queueKey.getTenantId()); }); if (serviceInfoProvider.isService(ServiceType.TB_RULE_ENGINE)) { @@ -209,14 +212,17 @@ public class HashPartitionService implements PartitionService { List queueKeys = partitionSizesMap.keySet().stream() .filter(queueKey -> tenantId.equals(queueKey.getTenantId())) .collect(Collectors.toList()); - queueKeys.forEach(queueKey -> { - myPartitions.remove(queueKey); - partitionTopicsMap.remove(queueKey); - partitionSizesMap.remove(queueKey); - }); + queueKeys.forEach(this::removeQueue); evictTenantInfo(tenantId); } + private void removeQueue(QueueKey queueKey) { + myPartitions.remove(queueKey); + partitionTopicsMap.remove(queueKey); + partitionSizesMap.remove(queueKey); + queueConfigs.remove(queueKey); + } + @Override public boolean isManagedByCurrentService(TenantId tenantId) { if (serviceInfoProvider.isService(ServiceType.TB_CORE) || !serviceInfoProvider.isService(ServiceType.TB_RULE_ENGINE)) { @@ -254,6 +260,64 @@ public class HashPartitionService implements PartitionService { @Override public TopicPartitionInfo resolve(ServiceType serviceType, String queueName, TenantId tenantId, EntityId entityId) { + QueueKey queueKey = getQueueKey(serviceType, queueName, tenantId); + return resolve(queueKey, entityId); + } + + @Override + public TopicPartitionInfo resolve(ServiceType serviceType, String queueName, TenantId tenantId, EntityId entityId, Integer partition) { + QueueKey queueKey = getQueueKey(serviceType, queueName, tenantId); + if (partition != null) { + return buildTopicPartitionInfo(queueKey, partition); + } else { + return resolve(queueKey, entityId); + } + } + + @Override + public TopicPartitionInfo resolve(ServiceType serviceType, TenantId tenantId, EntityId entityId) { + return resolve(serviceType, null, tenantId, entityId); + } + + @Override + public List resolveAll(ServiceType serviceType, String queueName, TenantId tenantId, EntityId entityId) { + QueueKey queueKey = getQueueKey(serviceType, queueName, tenantId); + TopicPartitionInfo tpi = resolve(queueKey, entityId); + if (serviceType != ServiceType.TB_RULE_ENGINE || tpi.getPartition().isEmpty()) { + return List.of(tpi); + } + + QueueConfig queueConfig = queueConfigs.get(queueKey); + if (queueConfig != null && queueConfig.isDuplicateMsgToAllPartitions()) { + int partition = tpi.getPartition().get(); + Integer partitionsCount = partitionSizesMap.get(queueKey); + + List partitions = new ArrayList<>(partitionsCount); + partitions.add(tpi); + for (int i = 0; i < partitionsCount; i++) { + if (i != partition) { + partitions.add(buildTopicPartitionInfo(queueKey, i, false)); + } + } + return partitions; + } else { + return Collections.singletonList(tpi); + } + } + + private TopicPartitionInfo resolve(QueueKey queueKey, EntityId entityId) { + Integer partitionSize = partitionSizesMap.get(queueKey); + if (partitionSize == null) { + throw new IllegalStateException("Partitions info for queue " + queueKey + " is missing"); + } + + int hash = hash(entityId.getId()); + int partition = Math.abs(hash % partitionSize); + + return buildTopicPartitionInfo(queueKey, partition); + } + + private QueueKey getQueueKey(ServiceType serviceType, String queueName, TenantId tenantId) { TenantId isolatedOrSystemTenantId = getIsolatedOrSystemTenantId(serviceType, tenantId); if (queueName == null) { queueName = MAIN_QUEUE_NAME; @@ -270,12 +334,7 @@ public class HashPartitionService implements PartitionService { log.warn("Using queue {} instead of isolated {} for tenant {}", queueKey, queueName, isolatedOrSystemTenantId); } } - return resolve(queueKey, entityId); - } - - @Override - public TopicPartitionInfo resolve(ServiceType serviceType, TenantId tenantId, EntityId entityId) { - return resolve(serviceType, null, tenantId, entityId); + return queueKey; } @Override @@ -288,18 +347,6 @@ public class HashPartitionService implements PartitionService { } } - private TopicPartitionInfo resolve(QueueKey queueKey, EntityId entityId) { - Integer partitionSize = partitionSizesMap.get(queueKey); - if (partitionSize == null) { - throw new IllegalStateException("Partitions info for queue " + queueKey + " is missing"); - } - - int hash = hash(entityId.getId()); - int partition = Math.abs(hash % partitionSize); - - return buildTopicPartitionInfo(queueKey, partition); - } - @Override public synchronized void recalculatePartitions(ServiceInfo currentService, List otherServices) { log.info("Recalculating partitions"); @@ -478,18 +525,17 @@ public class HashPartitionService implements PartitionService { } private TopicPartitionInfo buildTopicPartitionInfo(QueueKey queueKey, int partition) { - TopicPartitionInfo.TopicPartitionInfoBuilder tpi = TopicPartitionInfo.builder(); - tpi.topic(topicService.buildTopicName(partitionTopicsMap.get(queueKey))); - tpi.partition(partition); - tpi.tenantId(queueKey.getTenantId()); - List partitions = myPartitions.get(queueKey); - if (partitions != null) { - tpi.myPartition(partitions.contains(partition)); - } else { - tpi.myPartition(false); - } - return tpi.build(); + return buildTopicPartitionInfo(queueKey, partition, partitions != null && partitions.contains(partition)); + } + + private TopicPartitionInfo buildTopicPartitionInfo(QueueKey queueKey, int partition, boolean myPartition) { + return TopicPartitionInfo.builder() + .topic(topicService.buildTopicName(partitionTopicsMap.get(queueKey))) + .partition(partition) + .tenantId(queueKey.getTenantId()) + .myPartition(myPartition) + .build(); } private boolean isIsolated(ServiceType serviceType, TenantId tenantId) { @@ -619,4 +665,13 @@ public class HashPartitionService implements PartitionService { return serviceInfos.stream().map(ServiceInfo::getServiceId).collect(Collectors.toList()); } + @Data + public static class QueueConfig { + private boolean duplicateMsgToAllPartitions; + + public QueueConfig(QueueRoutingInfo queueRoutingInfo) { + this.duplicateMsgToAllPartitions = queueRoutingInfo.isDuplicateMsgToAllPartitions(); + } + } + } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/PartitionService.java b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/PartitionService.java index e14a6c3947..b5744981bd 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/PartitionService.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/PartitionService.java @@ -33,8 +33,12 @@ public interface PartitionService { TopicPartitionInfo resolve(ServiceType serviceType, String queueName, TenantId tenantId, EntityId entityId); + TopicPartitionInfo resolve(ServiceType serviceType, String queueName, TenantId tenantId, EntityId entityId, Integer partition); + TopicPartitionInfo resolve(ServiceType serviceType, TenantId tenantId, EntityId entityId); + List resolveAll(ServiceType serviceType, String queueName, TenantId tenantId, EntityId entityId); + boolean isMyPartition(ServiceType serviceType, TenantId tenantId, EntityId entityId); List getMyPartitions(QueueKey queueKey); diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/QueueRoutingInfo.java b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/QueueRoutingInfo.java index 51976d89f3..ba56044bb4 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/QueueRoutingInfo.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/QueueRoutingInfo.java @@ -33,6 +33,7 @@ public class QueueRoutingInfo { private final String queueName; private final String queueTopic; private final int partitions; + private final boolean duplicateMsgToAllPartitions; public QueueRoutingInfo(Queue queue) { this.tenantId = queue.getTenantId(); @@ -40,21 +41,25 @@ public class QueueRoutingInfo { this.queueName = queue.getName(); this.queueTopic = queue.getTopic(); this.partitions = queue.getPartitions(); + this.duplicateMsgToAllPartitions = queue.isDuplicateMsgToAllPartitions(); } public QueueRoutingInfo(GetQueueRoutingInfoResponseMsg routingInfo) { - this.tenantId = new TenantId(new UUID(routingInfo.getTenantIdMSB(), routingInfo.getTenantIdLSB())); + this.tenantId = TenantId.fromUUID(new UUID(routingInfo.getTenantIdMSB(), routingInfo.getTenantIdLSB())); this.queueId = new QueueId(new UUID(routingInfo.getQueueIdMSB(), routingInfo.getQueueIdLSB())); this.queueName = routingInfo.getQueueName(); this.queueTopic = routingInfo.getQueueTopic(); this.partitions = routingInfo.getPartitions(); + this.duplicateMsgToAllPartitions = routingInfo.hasDuplicateMsgToAllPartitions() && routingInfo.getDuplicateMsgToAllPartitions(); } public QueueRoutingInfo(QueueUpdateMsg queueUpdateMsg) { - this.tenantId = new TenantId(new UUID(queueUpdateMsg.getTenantIdMSB(), queueUpdateMsg.getTenantIdLSB())); + this.tenantId = TenantId.fromUUID(new UUID(queueUpdateMsg.getTenantIdMSB(), queueUpdateMsg.getTenantIdLSB())); this.queueId = new QueueId(new UUID(queueUpdateMsg.getQueueIdMSB(), queueUpdateMsg.getQueueIdLSB())); this.queueName = queueUpdateMsg.getQueueName(); this.queueTopic = queueUpdateMsg.getQueueTopic(); this.partitions = queueUpdateMsg.getPartitions(); + this.duplicateMsgToAllPartitions = queueUpdateMsg.hasDuplicateMsgToAllPartitions() && queueUpdateMsg.getDuplicateMsgToAllPartitions(); } + } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaConsumerStatsService.java b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaConsumerStatsService.java index 56440cf4a5..29b8064f04 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaConsumerStatsService.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaConsumerStatsService.java @@ -112,8 +112,8 @@ public class TbKafkaConsumerStatsService { } private boolean isStatsPrintRequired() { - boolean isMyRuleEnginePartition = partitionService.resolve(ServiceType.TB_RULE_ENGINE, TenantId.SYS_TENANT_ID, TenantId.SYS_TENANT_ID).isMyPartition(); - boolean isMyCorePartition = partitionService.resolve(ServiceType.TB_CORE, TenantId.SYS_TENANT_ID, TenantId.SYS_TENANT_ID).isMyPartition(); + boolean isMyRuleEnginePartition = partitionService.isMyPartition(ServiceType.TB_RULE_ENGINE, TenantId.SYS_TENANT_ID, TenantId.SYS_TENANT_ID); + boolean isMyCorePartition = partitionService.isMyPartition(ServiceType.TB_CORE, TenantId.SYS_TENANT_ID, TenantId.SYS_TENANT_ID); return log.isInfoEnabled() && (isMyRuleEnginePartition || isMyCorePartition); } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java index 9312370c34..c0c7339eba 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java @@ -23,6 +23,7 @@ import com.google.gson.Gson; import com.google.gson.JsonObject; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.exception.ExceptionUtils; import org.springframework.beans.factory.annotation.Autowired; @@ -99,6 +100,7 @@ import org.thingsboard.server.queue.TbQueueProducer; import org.thingsboard.server.queue.TbQueueRequestTemplate; import org.thingsboard.server.queue.common.AsyncCallbackTemplate; import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.common.TbRuleEngineProducerService; import org.thingsboard.server.queue.common.consumer.QueueConsumerManager; import org.thingsboard.server.queue.discovery.PartitionService; import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; @@ -131,6 +133,7 @@ import java.util.stream.Collectors; @Slf4j @Service @TbTransportComponent +@RequiredArgsConstructor public class DefaultTransportService extends TransportActivityManager implements TransportService { public static final TransportProtos.SessionEventMsg SESSION_EVENT_MSG_OPEN = TransportProtos.SessionEventMsg.newBuilder() @@ -163,6 +166,7 @@ public class DefaultTransportService extends TransportActivityManager implements private final PartitionService partitionService; private final TbTransportQueueFactory queueProvider; private final TbQueueProducerProvider producerProvider; + private final TbRuleEngineProducerService ruleEngineProducerService; private final TopicService topicService; private final TbServiceInfoProvider serviceInfoProvider; @@ -191,34 +195,6 @@ public class DefaultTransportService extends TransportActivityManager implements private final Map toServerRpcPendingMap = new ConcurrentHashMap<>(); - public DefaultTransportService(PartitionService partitionService, - TbServiceInfoProvider serviceInfoProvider, - TbTransportQueueFactory queueProvider, - TbQueueProducerProvider producerProvider, - TopicService topicService, - StatsFactory statsFactory, - TransportDeviceProfileCache deviceProfileCache, - TransportTenantProfileCache tenantProfileCache, - TransportRateLimitService rateLimitService, - SchedulerComponent scheduler, TransportResourceCache transportResourceCache, - ApplicationEventPublisher eventPublisher, NotificationRuleProcessor notificationRuleProcessor, - EntityLimitsCache entityLimitsCache) { - this.partitionService = partitionService; - this.serviceInfoProvider = serviceInfoProvider; - this.queueProvider = queueProvider; - this.producerProvider = producerProvider; - this.topicService = topicService; - this.statsFactory = statsFactory; - this.deviceProfileCache = deviceProfileCache; - this.tenantProfileCache = tenantProfileCache; - this.rateLimitService = rateLimitService; - this.scheduler = scheduler; - this.transportResourceCache = transportResourceCache; - this.eventPublisher = eventPublisher; - this.notificationRuleProcessor = notificationRuleProcessor; - this.entityLimitsCache = entityLimitsCache; - } - @PostConstruct public void init() { super.init(); @@ -1109,19 +1085,6 @@ public class DefaultTransportService extends TransportActivityManager implements tbCoreMsgProducer.send(tpi, new TbProtoQueueMsg<>(routingKey, msg), wrappedCallback); } - private void sendToRuleEngine(TenantId tenantId, TbMsg tbMsg, TbQueueCallback callback) { - TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_RULE_ENGINE, tbMsg.getQueueName(), tenantId, tbMsg.getOriginator()); - if (log.isTraceEnabled()) { - log.trace("[{}][{}] Pushing to topic {} message {}", tenantId, tbMsg.getOriginator(), tpi.getFullTopicName(), tbMsg); - } - ToRuleEngineMsg msg = ToRuleEngineMsg.newBuilder().setTbMsg(TbMsg.toByteString(tbMsg)) - .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) - .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()).build(); - ruleEngineProducerStats.incrementTotal(); - StatsCallback wrappedCallback = new StatsCallback(callback, ruleEngineProducerStats); - ruleEngineMsgProducer.send(tpi, new TbProtoQueueMsg<>(tbMsg.getId(), msg), wrappedCallback); - } - private void sendToRuleEngine(TenantId tenantId, DeviceId deviceId, CustomerId customerId, TransportProtos.SessionInfoProto sessionInfo, JsonObject json, TbMsgMetaData metaData, TbMsgType tbMsgType, TbQueueCallback callback) { DeviceProfileId deviceProfileId = new DeviceProfileId(new UUID(sessionInfo.getDeviceProfileIdMSB(), sessionInfo.getDeviceProfileIdLSB())); @@ -1139,7 +1102,8 @@ public class DefaultTransportService extends TransportActivityManager implements } TbMsg tbMsg = TbMsg.newMsg(queueName, tbMsgType, deviceId, customerId, metaData, gson.toJson(json), ruleChainId, null); - sendToRuleEngine(tenantId, tbMsg, callback); + ruleEngineProducerService.sendToRuleEngine(ruleEngineMsgProducer, tenantId, tbMsg, new StatsCallback(callback, ruleEngineProducerStats)); + ruleEngineProducerStats.incrementTotal(); } private class TransportTbQueueCallback implements TbQueueCallback { diff --git a/ui-ngx/src/app/modules/home/components/profile/queue/tenant-profile-queues.component.ts b/ui-ngx/src/app/modules/home/components/profile/queue/tenant-profile-queues.component.ts index bee66eef53..a6eda87d4a 100644 --- a/ui-ngx/src/app/modules/home/components/profile/queue/tenant-profile-queues.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/queue/tenant-profile-queues.component.ts @@ -174,7 +174,8 @@ export class TenantProfileQueuesComponent implements ControlValueAccessor, Valid topic: '', additionalInfo: { description: '', - customProperties: '' + customProperties: '', + duplicateMsgToAllPartitions: false } }; this.idMap.push(queue.id); diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant-profile.component.ts b/ui-ngx/src/app/modules/home/components/profile/tenant-profile.component.ts index 5e7ab536a9..9035b3dbaa 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant-profile.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/tenant-profile.component.ts @@ -75,7 +75,8 @@ export class TenantProfileComponent extends EntityComponent { topic: 'tb_rule_engine.main', additionalInfo: { description: '', - customProperties: '' + customProperties: '', + duplicateMsgToAllPartitions: false } }, { @@ -99,7 +100,8 @@ export class TenantProfileComponent extends EntityComponent { }, additionalInfo: { description: '', - customProperties: '' + customProperties: '', + duplicateMsgToAllPartitions: false } }, { @@ -123,7 +125,8 @@ export class TenantProfileComponent extends EntityComponent { }, additionalInfo: { description: '', - customProperties: '' + customProperties: '', + duplicateMsgToAllPartitions: false } } ]; diff --git a/ui-ngx/src/app/modules/home/components/queue/queue-form.component.html b/ui-ngx/src/app/modules/home/components/queue/queue-form.component.html index 7039cbda23..38e2860cd5 100644 --- a/ui-ngx/src/app/modules/home/components/queue/queue-form.component.html +++ b/ui-ngx/src/app/modules/home/components/queue/queue-form.component.html @@ -207,14 +207,19 @@ - - queue.custom-properties - - queue.custom-properties-hint - - - queue.description - - queue.description-hint - +
+ + {{ 'queue.duplicate-msg-to-all-partitions' | translate }} + + + queue.custom-properties + + queue.custom-properties-hint + + + queue.description + + queue.description-hint + +
diff --git a/ui-ngx/src/app/modules/home/components/queue/queue-form.component.ts b/ui-ngx/src/app/modules/home/components/queue/queue-form.component.ts index f54d99e332..c9a09a262d 100644 --- a/ui-ngx/src/app/modules/home/components/queue/queue-form.component.ts +++ b/ui-ngx/src/app/modules/home/components/queue/queue-form.component.ts @@ -118,7 +118,8 @@ export class QueueFormComponent implements ControlValueAccessor, OnInit, OnDestr topic: [''], additionalInfo: this.fb.group({ description: [''], - customProperties: [''] + customProperties: [''], + duplicateMsgToAllPartitions: [false] }) }); this.valueChange$ = this.queueFormGroup.valueChanges.subscribe(() => { @@ -160,6 +161,10 @@ export class QueueFormComponent implements ControlValueAccessor, OnInit, OnDestr this.queueFormGroup.patchValue(this.modelValue, {emitEvent: false}); this.queueFormGroup.get('additionalInfo').get('description') .patchValue(this.modelValue.additionalInfo?.description, {emitEvent: false}); + this.queueFormGroup.get('additionalInfo').get('customProperties') + .patchValue(this.modelValue.additionalInfo?.customProperties, {emitEvent: false}); + this.queueFormGroup.get('additionalInfo').get('duplicateMsgToAllPartitions') + .patchValue(this.modelValue.additionalInfo?.duplicateMsgToAllPartitions, {emitEvent: false}); this.submitStrategyTypeChanged(); if (!this.disabled && !this.queueFormGroup.valid) { this.updateModel(); diff --git a/ui-ngx/src/app/shared/models/queue.models.ts b/ui-ngx/src/app/shared/models/queue.models.ts index 1b750f6880..eb23cc1269 100644 --- a/ui-ngx/src/app/shared/models/queue.models.ts +++ b/ui-ngx/src/app/shared/models/queue.models.ts @@ -108,20 +108,21 @@ export interface QueueInfo extends BaseData, HasTenantId { consumerPerPartition: boolean; pollInterval: number; processingStrategy: { - type: QueueProcessingStrategyTypes, - retries: number, - failurePercentage: number, - pauseBetweenRetries: number, - maxPauseBetweenRetries: number + type: QueueProcessingStrategyTypes; + retries: number; + failurePercentage: number; + pauseBetweenRetries: number; + maxPauseBetweenRetries: number; }; submitStrategy: { - type: QueueSubmitStrategyTypes, - batchSize: number, + type: QueueSubmitStrategyTypes; + batchSize: number; }; tenantId?: TenantId; topic: string; additionalInfo: { description?: string; customProperties?: string; + duplicateMsgToAllPartitions?: boolean; }; } diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index fcb0021ddc..885aaeaefe 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -4277,6 +4277,7 @@ "immediate-processing": "Immediate processing", "consumer-per-partition": "Send message poll for each consumer", "consumer-per-partition-hint": "Enable separate consumer(s) per each partition", + "duplicate-msg-to-all-partitions": "Duplicate message to all partitions", "processing-timeout": "Processing within, ms", "batch-size": "Batch size", "retries": "Number of retries (0 – unlimited)", From 415c99147e46150aa8252fc1f3939055806a78b6 Mon Sep 17 00:00:00 2001 From: YevhenBondarenko Date: Wed, 22 May 2024 12:02:13 +0200 Subject: [PATCH 82/84] added check rate limits for CreateDeviceFromGateway --- .../mqtt/MqttGatewayRateLimitsTest.java | 3 ++- .../DefaultTransportRateLimitService.java | 2 +- .../service/DefaultTransportService.java | 24 +++++++++++++++---- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/MqttGatewayRateLimitsTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/MqttGatewayRateLimitsTest.java index 794d93a44a..a1986ca2c2 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/MqttGatewayRateLimitsTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/MqttGatewayRateLimitsTest.java @@ -81,7 +81,8 @@ public class MqttGatewayRateLimitsTest extends AbstractControllerTest { @Test public void transportGatewayMsgRateLimitTest() throws Exception { - transportGatewayRateLimitTest(profileConfiguration -> profileConfiguration.setTransportGatewayMsgRateLimit("1:600")); + // Device A 2 msgs success ('create device', 'to device actor'), Device B 'create device success' , 'to device actor' - limited + transportGatewayRateLimitTest(profileConfiguration -> profileConfiguration.setTransportGatewayMsgRateLimit("3:600")); } @Test diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitService.java index f0a5d73f11..975dd21fab 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitService.java @@ -84,7 +84,7 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi return TbPair.of(EntityType.DEVICE, true); } - if (!checkEntityRateLimit(dataPoints, getDeviceRateLimits(tenantId, deviceId))) { + if (deviceId != null && !checkEntityRateLimit(dataPoints, getDeviceRateLimits(tenantId, deviceId))) { return TbPair.of(EntityType.DEVICE, false); } return null; diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java index 9312370c34..598a5aa7a6 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java @@ -452,8 +452,13 @@ public class DefaultTransportService extends TransportActivityManager implements @Override public void process(TenantId tenantId, TransportProtos.GetOrCreateDeviceFromGatewayRequestMsg requestMsg, TransportServiceCallback callback) { - TbProtoQueueMsg protoMsg = new TbProtoQueueMsg<>(UUID.randomUUID(), TransportApiRequestMsg.newBuilder().setGetOrCreateDeviceRequestMsg(requestMsg).build()); log.trace("Processing msg: {}", requestMsg); + DeviceId gatewayid = new DeviceId(new UUID(requestMsg.getGatewayIdMSB(), requestMsg.getGatewayIdLSB())); + if (!checkLimits(tenantId, gatewayid, null, requestMsg.getDeviceName(), requestMsg, callback, 0)) { + return; + } + + TbProtoQueueMsg protoMsg = new TbProtoQueueMsg<>(UUID.randomUUID(), TransportApiRequestMsg.newBuilder().setGetOrCreateDeviceRequestMsg(requestMsg).build()); var key = new EntityLimitKey(tenantId, StringUtils.truncate(requestMsg.getDeviceName(), 256)); if (entityLimitsCache.get(key)) { transportCallbackExecutor.submit(() -> callback.onError(new RuntimeException(DataConstants.MAXIMUM_NUMBER_OF_DEVICES_REACHED))); @@ -857,6 +862,14 @@ public class DefaultTransportService extends TransportActivityManager implements gatewayId = new DeviceId(new UUID(sessionInfo.getGatewayIdMSB(), sessionInfo.getGatewayIdLSB())); } + return checkLimits(tenantId, gatewayId, deviceId, sessionInfo.getDeviceName(), msg, callback, dataPoints); + } + + private boolean checkLimits(TenantId tenantId, DeviceId gatewayId, DeviceId deviceId, String deviceName, Object msg, TransportServiceCallback callback, int dataPoints) { + if (log.isTraceEnabled()) { + log.trace("[{}][{}] Processing msg: {}", tenantId, deviceName, msg); + } + var rateLimitedPair = rateLimitService.checkLimits(tenantId, gatewayId, deviceId, dataPoints); if (rateLimitedPair == null) { return true; @@ -868,13 +881,16 @@ public class DefaultTransportService extends TransportActivityManager implements if (rateLimitedEntityType == EntityType.DEVICE || rateLimitedEntityType == EntityType.TENANT) { LimitedApi limitedApi = - rateLimitedEntityType == EntityType.TENANT ? LimitedApi.TRANSPORT_MESSAGES_PER_TENANT : + rateLimitedEntityType == EntityType.TENANT ? LimitedApi.TRANSPORT_MESSAGES_PER_TENANT : rateLimitedPair.getSecond() ? LimitedApi.TRANSPORT_MESSAGES_PER_GATEWAY : LimitedApi.TRANSPORT_MESSAGES_PER_DEVICE; + + EntityId limitLevel = rateLimitedEntityType == EntityType.DEVICE ? deviceId == null ? gatewayId : deviceId : tenantId; + notificationRuleProcessor.process(RateLimitsTrigger.builder() .tenantId(tenantId) .api(limitedApi) - .limitLevel(rateLimitedEntityType == EntityType.DEVICE ? deviceId : tenantId) - .limitLevelEntityName(rateLimitedEntityType == EntityType.DEVICE ? sessionInfo.getDeviceName() : null) + .limitLevel(limitLevel) + .limitLevelEntityName(rateLimitedEntityType == EntityType.DEVICE ? deviceName : null) .build()); } return false; From 5d2b4508cdb47fa01e22a72ee8872d20803b89de Mon Sep 17 00:00:00 2001 From: nick Date: Wed, 22 May 2024 13:35:18 +0300 Subject: [PATCH 83/84] lwm2m: getDefault content Format for Request --- .../DefaultLwM2mDownlinkMsgHandler.java | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/DefaultLwM2mDownlinkMsgHandler.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/DefaultLwM2mDownlinkMsgHandler.java index 15171b168f..77e427fc77 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/DefaultLwM2mDownlinkMsgHandler.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/DefaultLwM2mDownlinkMsgHandler.java @@ -114,6 +114,8 @@ import static org.eclipse.leshan.core.link.lwm2m.attributes.LwM2mAttributes.OBJE import static org.eclipse.leshan.core.link.lwm2m.attributes.LwM2mAttributes.SERVER_URI; import static org.eclipse.leshan.core.link.lwm2m.attributes.LwM2mAttributes.SHORT_SERVER_ID; import static org.eclipse.leshan.core.link.lwm2m.attributes.LwM2mAttributes.STEP; +import static org.eclipse.leshan.core.model.ResourceModel.Type.OBJLNK; +import static org.eclipse.leshan.core.model.ResourceModel.Type.OPAQUE; import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.convertMultiResourceValuesFromRpcBody; import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.createModelsDefault; import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.fromVersionedIdToObjectId; @@ -739,8 +741,21 @@ public class DefaultLwM2mDownlinkMsgHandler extends LwM2MExecutorAwareService im private static ContentFormat getRequestContentFormat(LwM2mClient client, String versionedId, LwM2mModelProvider modelProvider) { LwM2mPath pathIds = new LwM2mPath(fromVersionedIdToObjectId(versionedId)); - if (pathIds.isResourceInstance()) { - return findFirstContentFormatForComp(client.getClientSupportContentFormats(), client.getDefaultContentFormat(), ContentFormat.CBOR, ContentFormat.SENML_CBOR, ContentFormat.SENML_JSON); + if (pathIds.isResourceInstance() || pathIds.isResource()) { + ResourceModel resourceModel = client.getResourceModel(versionedId, modelProvider); + if (resourceModel != null && (pathIds.isResourceInstance() || (pathIds.isResource() && !resourceModel.multiple))) { + ContentFormat[] desiredFormats; + if (OBJLNK.equals(resourceModel.type)) { + desiredFormats = new ContentFormat[]{ContentFormat.LINK, ContentFormat.CBOR, ContentFormat.SENML_CBOR, ContentFormat.SENML_JSON}; + } else if (OPAQUE.equals(resourceModel.type)) { + desiredFormats = new ContentFormat[]{ContentFormat.OPAQUE, ContentFormat.CBOR, ContentFormat.SENML_CBOR, ContentFormat.SENML_JSON}; + } else { + desiredFormats = new ContentFormat[]{ContentFormat.CBOR, ContentFormat.SENML_CBOR, ContentFormat.SENML_JSON}; + } + return findFirstContentFormatForComp(client.getClientSupportContentFormats(), client.getDefaultContentFormat(), desiredFormats); + } else { + return getContentFormatForComplex(client); + } } else { return getContentFormatForComplex(client); } @@ -748,9 +763,9 @@ public class DefaultLwM2mDownlinkMsgHandler extends LwM2MExecutorAwareService im private static ContentFormat getContentFormatForComplex(LwM2mClient client) { if (LwM2m.LwM2mVersion.V1_0.equals(client.getRegistration().getLwM2mVersion())) { - return ContentFormat.TLV; + return client.getDefaultContentFormat(); } else if (LwM2m.LwM2mVersion.V1_1.equals(client.getRegistration().getLwM2mVersion())) { - ContentFormat result = findFirstContentFormatForComp(client.getClientSupportContentFormats(), null, ContentFormat.SENML_CBOR, ContentFormat.SENML_JSON, ContentFormat.TLV, ContentFormat.JSON); + ContentFormat result = findFirstContentFormatForComp(client.getClientSupportContentFormats(), client.getDefaultContentFormat(), ContentFormat.SENML_CBOR, ContentFormat.SENML_JSON, ContentFormat.TLV, ContentFormat.JSON); if (result != null) { return result; } else { From eeb72de9395c4cb1a44c19c55e0a6ec863f88cad Mon Sep 17 00:00:00 2001 From: Andrii Shvaika Date: Wed, 22 May 2024 15:33:13 +0300 Subject: [PATCH 84/84] Fix memory leak with old WS subscriptions --- .../service/ws/DefaultWebSocketService.java | 42 +++++++++++++------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java b/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java index 2a53e9a772..d7091bbad2 100644 --- a/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java +++ b/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java @@ -83,6 +83,7 @@ import org.thingsboard.server.service.ws.telemetry.sub.TelemetrySubscriptionUpda import jakarta.annotation.Nullable; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; + import java.io.IOException; import java.util.ArrayList; import java.util.Collections; @@ -143,6 +144,7 @@ public class DefaultWebSocketService implements WebSocketService { private final ConcurrentMap> customerSubscriptionsMap = new ConcurrentHashMap<>(); private final ConcurrentMap> regularUserSubscriptionsMap = new ConcurrentHashMap<>(); private final ConcurrentMap> publicUserSubscriptionsMap = new ConcurrentHashMap<>(); + private final ConcurrentMap> sessionCmdMap = new ConcurrentHashMap<>(); private ExecutorService executor; private ScheduledExecutorService pingExecutor; @@ -201,9 +203,7 @@ public class DefaultWebSocketService implements WebSocketService { event.getError().orElse(new RuntimeException("No error specified"))); break; case CLOSED: - wsSessionsMap.remove(sessionId); - oldSubService.cancelAllSessionSubscriptions(sessionId); - entityDataSubService.cancelAllSessionSubscriptions(sessionId); + cleanupSessionById(sessionId); processSessionClose(sessionRef); break; } @@ -299,9 +299,7 @@ public class DefaultWebSocketService implements WebSocketService { public void cleanupIfStale(String sessionId) { if (!msgEndpoint.isOpen(sessionId)) { log.info("[{}] Cleaning up stale session ", sessionId); - wsSessionsMap.remove(sessionId); - oldSubService.cancelAllSessionSubscriptions(sessionId); - entityDataSubService.cancelAllSessionSubscriptions(sessionId); + cleanupSessionById(sessionId); } } @@ -451,7 +449,7 @@ public class DefaultWebSocketService implements WebSocketService { TbAttributeSubscription sub = TbAttributeSubscription.builder() .serviceId(serviceId) .sessionId(sessionId) - .subscriptionId(sessionRef.getSessionSubIdSeq().incrementAndGet()) + .subscriptionId(registerNewSessionSubId(sessionId, sessionRef, cmd.getCmdId())) .tenantId(sessionRef.getSecurityCtx().getTenantId()) .entityId(entityId) .queryTs(queryTs) @@ -500,6 +498,13 @@ public class DefaultWebSocketService implements WebSocketService { } } + private int registerNewSessionSubId(String sessionId, WebSocketSessionRef sessionRef, int cmdId) { + var cmdMap = sessionCmdMap.computeIfAbsent(sessionId, id -> new ConcurrentHashMap<>()); + var subId = sessionRef.getSessionSubIdSeq().incrementAndGet(); + cmdMap.put(cmdId, subId); + return subId; + } + private void handleWsHistoryCmd(WebSocketSessionRef sessionRef, GetHistoryCmd cmd) { if (!validateCmd(sessionRef, cmd, () -> { if (cmd.getEntityId() == null || cmd.getEntityId().isEmpty() || cmd.getEntityType() == null || cmd.getEntityType().isEmpty()) { @@ -557,7 +562,7 @@ public class DefaultWebSocketService implements WebSocketService { TbAttributeSubscription sub = TbAttributeSubscription.builder() .serviceId(serviceId) .sessionId(sessionId) - .subscriptionId(sessionRef.getSessionSubIdSeq().incrementAndGet()) + .subscriptionId(registerNewSessionSubId(sessionId, sessionRef, cmd.getCmdId())) .tenantId(sessionRef.getSecurityCtx().getTenantId()) .entityId(entityId) .queryTs(queryTs) @@ -655,7 +660,7 @@ public class DefaultWebSocketService implements WebSocketService { TbTimeSeriesSubscription sub = TbTimeSeriesSubscription.builder() .serviceId(serviceId) .sessionId(sessionId) - .subscriptionId(sessionRef.getSessionSubIdSeq().incrementAndGet()) + .subscriptionId(registerNewSessionSubId(sessionId, sessionRef, cmd.getCmdId())) .tenantId(sessionRef.getSecurityCtx().getTenantId()) .entityId(entityId) .updateProcessor((subscription, update) -> { @@ -710,7 +715,7 @@ public class DefaultWebSocketService implements WebSocketService { TbTimeSeriesSubscription sub = TbTimeSeriesSubscription.builder() .serviceId(serviceId) .sessionId(sessionId) - .subscriptionId(sessionRef.getSessionSubIdSeq().incrementAndGet()) + .subscriptionId(registerNewSessionSubId(sessionId, sessionRef, cmd.getCmdId())) .tenantId(sessionRef.getSecurityCtx().getTenantId()) .entityId(entityId) .updateProcessor((subscription, update) -> { @@ -749,12 +754,25 @@ public class DefaultWebSocketService implements WebSocketService { private void unsubscribe(WebSocketSessionRef sessionRef, SubscriptionCmd cmd, String sessionId) { if (cmd.getEntityId() == null || cmd.getEntityId().isEmpty()) { - oldSubService.cancelAllSessionSubscriptions(sessionId); + log.warn("[{}][{}] Cleanup session due to empty entity id.", sessionId, cmd.getCmdId()); + cleanupSessionById(sessionId); } else { - oldSubService.cancelSubscription(sessionId, cmd.getCmdId()); + Integer subId = sessionCmdMap.getOrDefault(sessionId, Collections.emptyMap()).remove(cmd.getCmdId()); + if (subId == null) { + log.trace("[{}][{}] Failed to lookup subscription id mapping", sessionId, cmd.getCmdId()); + subId = cmd.getCmdId(); + } + oldSubService.cancelSubscription(sessionId, subId); } } + private void cleanupSessionById(String sessionId) { + wsSessionsMap.remove(sessionId); + oldSubService.cancelAllSessionSubscriptions(sessionId); + sessionCmdMap.remove(sessionId); + entityDataSubService.cancelAllSessionSubscriptions(sessionId); + } + private boolean validateSubscriptionCmd(WebSocketSessionRef sessionRef, EntityDataCmd cmd) { return validateCmd(sessionRef, cmd, () -> { if (cmd.getQuery() == null && !cmd.hasAnyCmd()) {