Merge pull request #14108 from ArtemDzhereleiko/AD/geofencing-cf/improvements

Bugfixes & improvements for Geofencing CF
This commit is contained in:
Andrew Shvayka 2025-10-06 17:46:54 +03:00 committed by GitHub
commit e63313a78a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 261 additions and 91 deletions

View File

@ -160,12 +160,16 @@
[tenantId]="data.tenantId"
[entityName]="data.entityName"/>
<div class="tb-form-row space-between flex-1 columns-xs" [class.!hidden]="!isRelatedEntity">
<div tb-hint-tooltip-icon="{{'calculated-fields.hint.zone-group-refresh-interval' | translate}}">{{ 'calculated-fields.zone-group-refresh-interval' | translate }}</div>
<mat-slide-toggle class="mat-slide" formControlName="scheduledUpdateEnabled">
<div tb-hint-tooltip-icon="{{'calculated-fields.hint.zone-group-refresh-interval' | translate}}">
{{ 'calculated-fields.zone-group-refresh-interval' | translate }}
</div>
</mat-slide-toggle>
<div class="flex flex-row items-center justify-start gap-2">
<tb-time-unit-input required
inlineField
requiredText="{{ 'calculated-fields.hint.zone-group-refresh-interval-required' | translate }}"
minErrorText="{{ 'calculated-fields.hint.zone-group-refresh-interval-min' | translate }}"
minErrorText="{{ 'calculated-fields.hint.zone-group-refresh-interval-min' | translate: {min: minAllowedScheduledUpdateIntervalInSecForCF} }}"
[minTime]="minAllowedScheduledUpdateIntervalInSecForCF"
formControlName="scheduledUpdateInterval">
</tb-time-unit-input>

View File

@ -81,6 +81,7 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
}),
arguments: this.fb.control({}),
zoneGroups: this.fb.control({}),
scheduledUpdateEnabled: [true],
scheduledUpdateInterval: [this.minAllowedScheduledUpdateIntervalInSecForCF],
expressionSIMPLE: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]],
expressionSCRIPT: [calculatedFieldDefaultScript],
@ -144,6 +145,7 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
this.applyDialogData();
this.observeTypeChanges();
this.observeZoneChanges();
this.observeScheduledUpdateEnabled();
this.currentEntityFilter = getCalculatedFieldCurrentEntityFilter(this.data.entityName, this.data.entityId);
}
@ -248,6 +250,23 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
this.checkRelatedEntity(this.configFormGroup.get('zoneGroups').value);
}
private observeScheduledUpdateEnabled(): void {
this.configFormGroup.get('scheduledUpdateEnabled').valueChanges
.pipe(takeUntilDestroyed())
.subscribe((value: boolean) =>
this.checkScheduledUpdateEnabled(value)
);
this.checkScheduledUpdateEnabled(this.configFormGroup.get('scheduledUpdateEnabled').value);
}
private checkScheduledUpdateEnabled(value: boolean) {
if (value) {
this.configFormGroup.get('scheduledUpdateInterval').enable({emitEvent: false});
} else {
this.configFormGroup.get('scheduledUpdateInterval').disable({emitEvent: false});
}
}
private checkRelatedEntity(zoneGroups: CalculatedFieldGeofencing) {
this.isRelatedEntity = Object.values(zoneGroups).some(zone => zone.refDynamicSourceConfiguration?.type === ArgumentEntityType.RelationQuery);
}

View File

@ -80,12 +80,13 @@
@if (ArgumentEntityTypeParamsMap.has(entityType)) {
<div class="tb-form-row">
<div class="fixed-title-width tb-required">{{ ArgumentEntityTypeParamsMap.get(entityType).title | translate }}</div>
<tb-entity-autocomplete class="flex flex-1"
<tb-entity-autocomplete class="flex-1"
#entityAutocomplete
formControlName="id"
inlineField
appearance="outline"
[placeholder]="'action.set' | translate"
[required]="true"
required
[entityType]="ArgumentEntityTypeParamsMap.get(entityType).entityType"
(entityChanged)="entityNameSubject.next($event?.name)"/>
</div>
@ -94,76 +95,118 @@
<ng-container [formGroup]="refDynamicSourceFormGroup">
<div class="tb-form-panel stroked" *ngIf="entityType === ArgumentEntityType.RelationQuery">
<mat-expansion-panel class="tb-settings" expanded>
<mat-expansion-panel-header>{{ 'calculated-fields.relation-query' | translate }}*</mat-expansion-panel-header>
<div class="tb-form-row">
<div class="fixed-title-width">{{ 'calculated-fields.direction' | translate }}</div>
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="direction">
@for (direction of GeofencingDirectionList; track direction) {
<mat-option [value]="direction">{{ GeofencingDirectionTranslations.get(direction) | translate }}</mat-option>
<mat-expansion-panel-header>{{ 'calculated-fields.entity-zone-relationship' | translate }}</mat-expansion-panel-header>
<div class="tb-form-table">
<div class="tb-form-table-header">
<div class="tb-form-table-header-cell tb-actions-header"></div>
<div class="tb-form-table-header-cell" translate>calculated-fields.level</div>
<div class="tb-form-table-header-cell flex-1" translate>calculated-fields.direction-level</div>
<div class="tb-form-table-header-cell flex-1 tb-required" translate>calculated-fields.relation-type</div>
<div class="tb-form-table-header-cell tb-actions-header"></div>
</div>
@if (levelsFormArray()?.controls?.length) {
<div class="tb-form-table-body tb-drop-list"
cdkDropList cdkDropListOrientation="vertical"
[cdkDropListDisabled]="!dragEnabled"
(cdkDropListDropped)="keyDrop($event)">
@for (keyControl of levelsFormArray().controls; track trackByKey;) {
<div cdkDrag [cdkDragDisabled]="!dragEnabled" class="tb-draggable-form-table-row">
<div class="tb-form-table-row-cell-buttons">
<button mat-icon-button
type="button"
cdkDragHandle
class="lt-lg:!hidden"
[class.tb-hidden]="!dragEnabled"
matTooltip="{{ 'action.drag' | translate }}"
matTooltipPosition="above">
<mat-icon>drag_indicator</mat-icon>
</button>
</div>
<div class="tb-form-row no-border flex-1" [formGroup]="keyControl">
<div class="level-text">{{ $index+1 }}</div>
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="direction">
@for (direction of GeofencingDirectionList; track direction) {
<mat-option [value]="direction">{{ GeofencingDirectionLevelTranslations.get(direction) | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
<tb-string-autocomplete [fetchOptionsFn]="fetchOptions.bind(this)"
additionalClass="tb-suffix-show-on-hover"
class="flex-1"
appearance="outline"
panelWidth=""
required
[errorText]="'calculated-fields.hint.relation-type-required' | translate"
formControlName="relationType">
</tb-string-autocomplete>
</div>
<div class="tb-form-table-row-cell-buttons">
<button type="button"
mat-icon-button
(click)="removeKey($index)"
matTooltip="{{ 'calculated-fields.delete-level' | translate }}"
matTooltipPosition="above">
<mat-icon>delete</mat-icon>
</button>
</div>
</div>
}
</mat-select>
</mat-form-field>
</div>
} @else {
<span class="tb-prompt flex items-center justify-center">{{ 'calculated-fields.no-level' | translate }}</span>
}
@if (levelsFormArray().errors) {
<tb-error noMargin error="{{ 'calculated-fields.levels-required' | translate }}" style="padding-left: 12px;"></tb-error>
}
</div>
<div class="tb-form-row">
<div class="fixed-title-width tb-required">{{ 'calculated-fields.relation-type' | translate }}</div>
<tb-string-autocomplete [fetchOptionsFn]="fetchOptions.bind(this)"
additionalClass="tb-suffix-show-on-hover"
class="flex-1"
appearance="outline"
panelWidth=""
required
[errorText]="'calculated-fields.hint.relation-type-required' | translate"
formControlName="relationType">
</tb-string-autocomplete>
</div>
<div class="tb-form-row">
<div class="fixed-title-width tb-required">{{ 'calculated-fields.relation-level' | translate }}</div>
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<input matInput type="number" step="1" min="0" formControlName="maxLevel" placeholder="{{ 'action.set' | translate }}"/>
@if (refDynamicSourceFormGroup.get('maxLevel').touched && refDynamicSourceFormGroup.get('maxLevel').hasError('required')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.relation-level-required' | translate"
class="tb-error">
warning
</mat-icon>
} @else if (refDynamicSourceFormGroup.get('maxLevel').touched && refDynamicSourceFormGroup.get('maxLevel').hasError('min')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.relation-level-min' | translate"
class="tb-error">
warning
</mat-icon>
} @else if (refDynamicSourceFormGroup.get('maxLevel').touched && refDynamicSourceFormGroup.get('maxLevel').hasError('max')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.relation-level-max' | translate: {max: maxRelationLevelPerCfArgument}"
class="tb-error">
warning
</mat-icon>
}
</mat-form-field>
</div>
<div class="tb-form-row" [class.!hidden]="!(this.refDynamicSourceFormGroup.get('maxLevel').value > 1)">
<mat-slide-toggle class="mat-slide margin" formControlName="fetchLastLevelOnly">
{{ 'calculated-fields.fetch-last-available-level' | translate }}
</mat-slide-toggle>
<div>
@if (maxRelationLevelPerCfArgument && levelsFormArray().length >= maxRelationLevelPerCfArgument) {
<div class="tb-form-hint tb-primary-fill max-args-warning flex items-center gap-2">
<mat-icon>warning</mat-icon>
<span>{{ 'calculated-fields.max-allowed-levels-error' | translate }}</span>
</div>
} @else {
<button type="button" mat-stroked-button color="primary" (click)="addKey()">
{{ 'calculated-fields.add-level' | translate }}
</button>
}
</div>
</mat-expansion-panel>
</div>
</ng-container>
<ng-container>
<div class="tb-form-row">
<div class="fixed-title-width tb-required" tb-hint-tooltip-icon="{{'calculated-fields.hint.perimeter-attribute-key' | translate}}">
{{ 'calculated-fields.perimeter-attribute-key' | translate }}
@if (entityFilter.singleEntity.id) {
<div class="tb-form-row">
<div class="fixed-title-width tb-required" tb-hint-tooltip-icon="{{'calculated-fields.hint.perimeter-attribute-key' | translate}}">
{{ 'calculated-fields.perimeter-attribute-key' | translate }}
</div>
@if (entityType === ArgumentEntityType.RelationQuery) {
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<input matInput autocomplete="new-name" name="value" formControlName="perimeterKeyName" maxlength="255" placeholder="{{ 'action.set' | translate }}"/>
@if (geofencingFormGroup.get('perimeterKeyName').touched && geofencingFormGroup.get('perimeterKeyName').hasError('required')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.perimeter-attribute-key-required' | translate"
class="tb-error">
warning
</mat-icon>
} @else if (geofencingFormGroup.get('perimeterKeyName').touched && geofencingFormGroup.get('perimeterKeyName').hasError('pattern')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.perimeter-attribute-key-pattern' | translate"
class="tb-error">
warning
</mat-icon>
}
</mat-form-field>
} @else {
<tb-entity-key-autocomplete class="flex-1" formControlName="perimeterKeyName" [dataKeyType]="DataKeyType.attribute" [entityFilter]="entityFilter" [keyScopeType]="AttributeScope.SERVER_SCOPE"/>
}
</div>
<tb-entity-key-autocomplete class="flex-1" formControlName="perimeterKeyName" [dataKeyType]="DataKeyType.attribute" [entityFilter]="entityFilter"/>
</div>
}
<div class="tb-form-row">
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'calculated-fields.hint.report-strategy' | translate}}">{{ 'calculated-fields.report-strategy' | translate }}</div>
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic">

View File

@ -29,6 +29,34 @@ $panel-width: 520px;
}
}
.level-text {
display: flex;
justify-content: center;
width: 25px;
color: rgba(0, 0, 0, 0.54);
}
.tb-form-table {
.tb-form-row {
gap: 12px;
}
.tb-form-table-body {
gap: unset;
}
.tb-form-table-header {
padding: 0;
}
.tb-form-table-header-cell {
&.tb-actions-header {
width: 40px;
min-width: 40px;
}
}
}
.limit-field-row {
@media screen and (max-width: $panel-width) {
display: flex;
@ -48,4 +76,9 @@ $panel-width: 520px;
flex-direction: column;
}
}
tb-entity-autocomplete {
.mat-mdc-form-field-has-icon-suffix .mat-mdc-text-field-wrapper {
padding-right: 0 !important;
}
}
}

View File

@ -16,7 +16,15 @@
import { AfterViewInit, ChangeDetectorRef, Component, Input, OnInit, output, ViewChild } from '@angular/core';
import { TbPopoverComponent } from '@shared/components/popover.component';
import { FormBuilder, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms';
import {
AbstractControl,
FormBuilder,
FormControl,
FormGroup,
UntypedFormArray,
ValidatorFn,
Validators
} from '@angular/forms';
import { charsWithNumRegex, oneSpaceInsideRegex } from '@shared/models/regex.constants';
import {
ArgumentEntityType,
@ -25,14 +33,15 @@ import {
CalculatedFieldGeofencing,
CalculatedFieldGeofencingValue,
CalculatedFieldType,
GeofencingDirectionLevelTranslations,
GeofencingDirectionTranslations,
GeofencingReportStrategy,
GeofencingReportStrategyTranslations,
getCalculatedFieldCurrentEntityFilter
} from '@shared/models/calculated-field.models';
import { debounceTime, delay, distinctUntilChanged, filter, map } from 'rxjs/operators';
import { debounceTime, delay, distinctUntilChanged, map } from 'rxjs/operators';
import { EntityType } from '@shared/models/entity-type.models';
import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { EntityId } from '@shared/models/id/entity-id';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { EntityFilter } from '@shared/models/query/query.models';
@ -44,6 +53,7 @@ import { Store } from '@ngrx/store';
import { EntityAutocompleteComponent } from '@shared/components/entity/entity-autocomplete.component';
import { NULL_UUID } from '@shared/models/id/has-uuid';
import { EntitySearchDirection } from '@shared/models/relation.models';
import { CdkDragDrop } from "@angular/cdk/drag-drop";
@Component({
selector: 'tb-calculated-field-geofencing-zone-groups-panel',
@ -73,12 +83,9 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit
id: ['']
}),
refDynamicSourceConfiguration: this.fb.group({
direction: [EntitySearchDirection.TO],
relationType: ['', [Validators.required]],
maxLevel: [1, [Validators.required, Validators.min(1), Validators.max(this.maxRelationLevelPerCfArgument)]],
fetchLastLevelOnly: [false],
levels: this.fb.array([], [this.levelsRequired()])
}),
perimeterKeyName: ['', [Validators.pattern(oneSpaceInsideRegex)]],
perimeterKeyName: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex)]],
reportStrategy: [GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS],
createRelationsWithMatchedZones: [false],
direction: [EntitySearchDirection.TO],
@ -97,6 +104,8 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit
readonly GeofencingReportStrategyTranslations = GeofencingReportStrategyTranslations;
readonly GeofencingDirectionList = Object.values(EntitySearchDirection) as Array<EntitySearchDirection>;
readonly GeofencingDirectionTranslations = GeofencingDirectionTranslations;
readonly GeofencingDirectionLevelTranslations = GeofencingDirectionLevelTranslations;
readonly AttributeScope = AttributeScope;
private currentEntityFilter: EntityFilter;
@ -107,7 +116,6 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit
private store: Store<AppState>
) {
this.observeMaxLevelChanges();
this.observeEntityFilterChanges();
this.observeEntityTypeChanges();
this.observeUpdatePosition();
@ -131,7 +139,16 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit
if (this.zone.refDynamicSourceConfiguration?.type) {
this.refEntityIdFormGroup.get('entityType').setValue(this.zone.refDynamicSourceConfiguration.type, {emitEvent: false});
}
this.validateFetchLastLevelOnly(this.zone?.refDynamicSourceConfiguration?.maxLevel);
if (this.zone?.refDynamicSourceConfiguration?.levels?.length > 0) {
this.zone.refDynamicSourceConfiguration.levels.forEach(level => {
this.levelsFormArray().push(this.fb.group({
direction: [level.direction],
relationType: [level.relationType, [Validators.required]]
}));
})
} else {
this.addKey();
}
this.validateDirectionAndRelationType(this.zone?.createRelationsWithMatchedZones);
this.validateRefDynamicSourceConfiguration(this.zone?.refEntityId?.entityType || this.zone?.refDynamicSourceConfiguration?.type);
@ -241,17 +258,19 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit
private observeEntityFilterChanges(): void {
merge(
this.refEntityIdFormGroup.get('entityType').valueChanges,
this.refEntityIdFormGroup.get('id').valueChanges.pipe(filter(Boolean)),
this.refEntityIdFormGroup.get('id').valueChanges,
)
.pipe(debounceTime(50), takeUntilDestroyed())
.subscribe(() => this.updateEntityFilter(this.entityType));
this.refEntityIdFormGroup.get('id').valueChanges.pipe(distinctUntilChanged(), takeUntilDestroyed()).subscribe(() => this.geofencingFormGroup.get('perimeterKeyName').reset(''));
}
private observeEntityTypeChanges(): void {
this.refEntityIdFormGroup.get('entityType').valueChanges
.pipe(distinctUntilChanged(), takeUntilDestroyed())
.subscribe(type => {
this.geofencingFormGroup.get('refEntityId').get('id').setValue('');
this.geofencingFormGroup.get('refEntityId').get('id').setValue(null);
const isEntityWithId = type !== ArgumentEntityType.Tenant && type !== ArgumentEntityType.Current && type !== ArgumentEntityType.RelationQuery;
this.geofencingFormGroup.get('refEntityId')
.get('id')[isEntityWithId ? 'enable' : 'disable']();
@ -271,6 +290,12 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit
};
}
private levelsRequired(): ValidatorFn {
return (control: FormControl) => {
return control.value.length ? null : { levelsRequired: true };
};
}
private forbiddenNameValidator(): ValidatorFn {
return (control: FormControl) => {
const trimmedValue = control.value.trim().toLowerCase();
@ -282,10 +307,40 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit
private observeUpdatePosition(): void {
merge(
this.refEntityIdFormGroup.get('entityType').valueChanges,
this.refEntityIdFormGroup.get('id').valueChanges,
this.geofencingFormGroup.get('createRelationsWithMatchedZones').valueChanges
)
.pipe(delay(50), takeUntilDestroyed())
.subscribe(() => this.popover.updatePosition());
}
levelsFormArray(): UntypedFormArray {
return this.refDynamicSourceFormGroup.get('levels') as UntypedFormArray;
}
trackByKey(index: number, keyControl: AbstractControl): any {
return keyControl;
}
removeKey(index: number) {
this.levelsFormArray().removeAt(index);
}
addKey() {
this.levelsFormArray().push(this.fb.group({
direction: [EntitySearchDirection.TO],
relationType: ['', [Validators.required]]
}));
}
keyDrop(event: CdkDragDrop<string[]>) {
const keysArray = this.levelsFormArray();
const key = keysArray.at(event.previousIndex);
keysArray.removeAt(event.previousIndex);
keysArray.insert(event.currentIndex, key);
}
get dragEnabled(): boolean {
return this.levelsFormArray().controls.length > 1;
}
}

View File

@ -133,6 +133,7 @@ export class EntityKeyAutocompleteComponent implements ControlValueAccessor, Val
if (filterChanged || keyScopeChanged || keyTypeChanged) {
this.keyControl.setValue('', {emitEvent: false});
this.cachedResult = null;
}
}

View File

@ -73,7 +73,7 @@ export enum ArgumentEntityType {
Asset = 'ASSET',
Customer = 'CUSTOMER',
Tenant = 'TENANT',
RelationQuery = 'RELATION_QUERY',
RelationQuery = 'RELATION_PATH_QUERY',
}
export const ArgumentEntityTypeTranslations = new Map<ArgumentEntityType, string>(
@ -108,6 +108,13 @@ export const GeofencingDirectionTranslations = new Map<EntitySearchDirection, st
]
)
export const GeofencingDirectionLevelTranslations = new Map<EntitySearchDirection, string>(
[
[EntitySearchDirection.FROM, 'calculated-fields.direction-down'],
[EntitySearchDirection.TO, 'calculated-fields.direction-up'],
]
)
export enum ArgumentType {
Attribute = 'ATTRIBUTE',
LatestTelemetry = 'TS_LATEST',
@ -167,10 +174,7 @@ export interface CalculatedFieldGeofencing {
export interface RefDynamicSourceConfiguration {
type?: ArgumentEntityType.RelationQuery;
direction: EntitySearchDirection;
relationType: string;
maxLevel: number;
fetchLastLevelOnly?: boolean;
levels?: Array<{direction: EntitySearchDirection; relationType: string;}>;
}
export interface CalculatedFieldGeofencingValue extends CalculatedFieldGeofencing {

View File

@ -1122,17 +1122,28 @@
"report-presence-status-only": "Presence status only",
"report-transition-event-and-presence": "Presence status and transition events",
"perimeter-attribute-key": "Perimeter attribute key",
"relation-query": "Relations query",
"direction": "Direction",
"direction-from": "From source entity",
"direction-to": "To source entity",
"perimeter-attribute-key-required": "Perimeter attribute key is required.",
"perimeter-attribute-key-pattern": "Perimeter attribute key is invalid.",
"entity-zone-relationship": "Path from Entity to Zones *",
"direction": "Relation direction",
"direction-from": "From entity to zone",
"direction-to": "From zone to entity",
"relation-type": "Relation type",
"create-relation-with-matched-zones": "Create relations with matched zones",
"create-relation-with-matched-zones": "Create relations for source entity with matched zones",
"relation-level": "Relation level",
"fetch-last-available-level": "Fetch last available level only",
"zone-group-refresh-interval": "Zone groups refresh interval",
"copy-zone-group-name": "Copy zone group name",
"open-details-page": "Open entity details page",
"level": "Level",
"direction-level": "Direction",
"direction-up": "Up",
"direction-down": "Down",
"add-level": "Add level",
"delete-level": "Delete level",
"no-level": "No level configured",
"levels-required": "At least one level must be configured.",
"max-allowed-levels-error": "Relation level exceeds the maximum allowed.",
"hint": {
"arguments-simple-with-rolling": "Simple type calculated field should not contain keys with time series rolling type.",
"arguments-empty": "Arguments should not be empty.",
@ -1158,7 +1169,7 @@
"entity-coordinates": "Specify the time series keys that provide entity GPS coordinates (latitude and longitude).",
"geofencing-zone-groups": "Define one or more geofencing zones groups to check (e.g. 'allowedZones', 'restrictedZones'). Each group must have a unique name, which is used as a prefix for calculated field output telemetry keys.",
"perimeter-attribute-key": "Set the attribute key that contains the geofencing zone perimeter definition. The perimeter is always taken from server-side attributes of the zone entity.",
"report-strategy": "Presence status reports whether the entity is currently INSIDE or OUTSIDE the zone group.Transition events report when the entity ENTERED or LEFT the zone group.",
"report-strategy": "Presence status reports whether the entity is currently INSIDE or OUTSIDE the zone group. Transition events report when the entity ENTERED or LEFT the zone group.",
"create-relation-with-matched-zones": "Automatically create and maintain relations between the entity and the zones it is currently inside. Relations are removed when the entity leaves a zone and created when it enters a new one.",
"relation-type-required": "Relation type is required.",
"relation-level-required": "Relation level is required.",
@ -1167,9 +1178,9 @@
"geofencing-empty": "At least one zone group must be configured.",
"geofencing-entity-not-found": "Geofencing target entity not found.",
"max-geofencing-zone": "Maximum number of geofencing zones reached.",
"zone-group-refresh-interval": "Defines how often zone groups configured via related entities are refreshed. Set to 0 to disable scheduled refresh.",
"zone-group-refresh-interval": "Defines how often zone groups configured via related entities are refreshed.",
"zone-group-refresh-interval-required": "Zone groups refresh interval is required.",
"zone-group-refresh-interval-min": "Zone group refresh interval is below the minimum allowed system interval."
"zone-group-refresh-interval-min": "Zone group refresh interval should be at least {{ min }} second."
}
},
"ai-models": {