diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html
index fcc000bf98..1faeb6adf6 100644
--- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html
+++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html
@@ -44,13 +44,7 @@
@if (simpleMode) {
@if (hiddenName) {
-
- {{ 'calculated-fields.decimals-by-default' | translate }}
-
- @if (outputForm.get('decimalsByDefault').errors && outputForm.get('decimalsByDefault').touched) {
- {{ 'calculated-fields.hint.decimals-range' | translate }}
- }
-
+
} @else {
@@ -77,15 +71,18 @@
}
-
- {{ 'calculated-fields.decimals-by-default' | translate }}
-
- @if (outputForm.get('decimalsByDefault').errors && outputForm.get('decimalsByDefault').touched) {
- {{ 'calculated-fields.hint.decimals-range' | translate }}
- }
-
+
}
}
+
+
+ {{ 'calculated-fields.decimals-by-default' | translate }}
+
+ @if (outputForm.get('decimalsByDefault').errors && outputForm.get('decimalsByDefault').touched) {
+ {{ 'calculated-fields.hint.decimals-range' | translate }}
+ }
+
+
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component.html
new file mode 100644
index 0000000000..5c2985321a
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component.html
@@ -0,0 +1,168 @@
+
+
+
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component.scss
new file mode 100644
index 0000000000..ae692b6f93
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component.scss
@@ -0,0 +1,31 @@
+/**
+ * Copyright © 2016-2025 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES 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';
+
+$panel-width: 520px;
+
+:host {
+ display: flex;
+ width: $panel-width;
+ max-width: 100%;
+ max-height: 80vh;
+
+ .fixed-title-width {
+ @media #{$mat-xs} {
+ min-width: 120px;
+ }
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component.ts
new file mode 100644
index 0000000000..6fb296a161
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component.ts
@@ -0,0 +1,176 @@
+///
+/// Copyright © 2016-2025 The Thingsboard Authors
+///
+/// Licensed under the Apache License, Version 2.0 (the "License");
+/// you may not use this file except in compliance with the License.
+/// You may obtain a copy of the License at
+///
+/// http://www.apache.org/licenses/LICENSE-2.0
+///
+/// Unless required by applicable law or agreed to in writing, software
+/// distributed under the License is distributed on an "AS IS" BASIS,
+/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+/// See the License for the specific language governing permissions and
+/// limitations under the License.
+///
+
+import { Component, Input, OnInit, output } from '@angular/core';
+import { TbPopoverComponent } from '@shared/components/popover.component';
+import { FormBuilder, FormControl, ValidatorFn, Validators } from '@angular/forms';
+import { charsWithNumRegex } from '@shared/models/regex.constants';
+import {
+ AggFunction,
+ AggFunctionTranslations,
+ AggInputType,
+ AggInputTypeTranslations,
+ CalculatedFieldAggMetricValue
+} from '@shared/models/calculated-field.models';
+import { delay, map } from 'rxjs/operators';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { EntityFilter } from '@shared/models/query/query.models';
+import { merge, Observable, of } from 'rxjs';
+import { ScriptLanguage } from '@shared/models/rule-node.models';
+import { TbEditorCompleter } from '@shared/models/ace/completion.models';
+import { AceHighlightRules } from '@shared/models/ace/ace.models';
+
+interface CalculatedFieldAggMetricValuePanel extends CalculatedFieldAggMetricValue {
+ allowFilter: boolean;
+}
+
+@Component({
+ selector: 'tb-calculated-field-metrics-panel',
+ templateUrl: './calculated-field-metrics-panel.component.html',
+ styleUrls: ['./calculated-field-metrics-panel.component.scss']
+})
+export class CalculatedFieldMetricsPanelComponent implements OnInit {
+
+ @Input() buttonTitle: string;
+ @Input() metric: CalculatedFieldAggMetricValue;
+ @Input() usedNames: string[];
+ @Input() arguments: Array;
+ @Input() editorCompleter: TbEditorCompleter;
+ @Input() highlightRules: AceHighlightRules;
+
+ metricDataApplied = output();
+ filterExpanded = false;
+ functionArgs: Array
+
+ metricForm = this.fb.group({
+ name: ['', [Validators.required, this.uniqNameRequired(), this.forbiddenNameValidator(), Validators.pattern(charsWithNumRegex), Validators.maxLength(255)]],
+ function: [AggFunction.AVG],
+ allowFilter: [false],
+ filter: ['', Validators.required],
+ input: this.fb.group({
+ type: [AggInputType.key],
+ key: ['', Validators.required],
+ function: ['', Validators.required],
+ })
+ });
+
+ entityFilter: EntityFilter;
+
+ readonly AggFunctions = Object.values(AggFunction) as AggFunction[];
+ readonly AggFunctionTranslations = AggFunctionTranslations;
+ readonly ScriptLanguage = ScriptLanguage;
+ readonly AggInputType = AggInputType;
+ readonly AggInputTypes = Object.values(AggInputType) as AggInputType[];
+ readonly AggInputTypeTranslations = AggInputTypeTranslations;
+
+ constructor(
+ private fb: FormBuilder,
+ private popover: TbPopoverComponent
+ ) {
+ this.observeUpdatePosition();
+ this.observeFilterAllowChange();
+ this.observeInputTypeChange();
+ }
+
+ ngOnInit(): void {
+ const data: CalculatedFieldAggMetricValuePanel = {
+ ...this.metric,
+ allowFilter: !!this.metric.filter,
+ }
+ this.metricForm.patchValue(data, {emitEvent: false});
+
+ this.validateFilter(data.allowFilter);
+ this.validateInputTypeFilter(data.input?.type ?? AggInputType.key);
+
+ this.functionArgs = ['ctx', ...this.arguments];
+ }
+
+ fetchOptions(searchText: string): Observable> {
+ const search = searchText ? searchText?.toLowerCase() : '';
+ return of(this.arguments).pipe(map(name => name?.filter(option => option.toLowerCase().includes(search))));
+ }
+
+ private observeFilterAllowChange(): void {
+ this.metricForm.get('allowFilter').valueChanges
+ .pipe(takeUntilDestroyed())
+ .subscribe(value => this.validateFilter(value));
+ }
+
+ private observeInputTypeChange(): void {
+ this.metricForm.get('input.type').valueChanges
+ .pipe(takeUntilDestroyed())
+ .subscribe(value => this.validateInputTypeFilter(value));
+ }
+
+ private validateFilter(allowFilter = false): void {
+ if (allowFilter) {
+ this.metricForm.get('filter').enable({emitEvent: false});
+ } else {
+ this.metricForm.get('filter').disable({emitEvent: false});
+ }
+ this.filterExpanded = allowFilter;
+ }
+
+ private validateInputTypeFilter(value: AggInputType): void {
+ const inputForm = this.metricForm.get('input');
+ if (value === AggInputType.key) {
+ inputForm.get('key').enable({emitEvent: false});
+ inputForm.get('function').disable({emitEvent: false});
+ } else {
+ inputForm.get('key').disable({emitEvent: false});
+ inputForm.get('function').enable({emitEvent: false});
+ }
+ }
+
+ saveZone(): void {
+ const value = this.metricForm.value as CalculatedFieldAggMetricValuePanel;
+ if (!value.allowFilter) {
+ delete value.filter;
+ }
+ delete value.allowFilter;
+ this.metricDataApplied.emit(value);
+ }
+
+ cancel(): void {
+ this.popover.hide();
+ }
+
+ private uniqNameRequired(): ValidatorFn {
+ return (control: FormControl) => {
+ const newName = control.value.trim().toLowerCase();
+ const isDuplicate = this.usedNames?.some(name => name.toLowerCase() === newName);
+
+ return isDuplicate ? { duplicateName: true } : null;
+ };
+ }
+
+ private forbiddenNameValidator(): ValidatorFn {
+ return (control: FormControl) => {
+ const trimmedValue = control.value.trim().toLowerCase();
+ const forbiddenNames = ['ctx', 'e', 'pi'];
+ return forbiddenNames.includes(trimmedValue) ? { forbiddenName: true } : null;
+ };
+ }
+
+ private observeUpdatePosition(): void {
+ merge(
+ this.metricForm.get('allowFilter').valueChanges,
+ this.metricForm.get('input.type').valueChanges
+ )
+ .pipe(delay(50), takeUntilDestroyed())
+ .subscribe(() => this.popover.updatePosition());
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-table.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-table.component.html
new file mode 100644
index 0000000000..481ad9ee13
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-table.component.html
@@ -0,0 +1,111 @@
+
+
+
+
+
+ @if (maxArgumentsPerCF && metricsFormArray.length >= maxArgumentsPerCF) {
+
+ warning
+ {{ 'calculated-fields.metrics.max-metrics' | translate }}
+
+ }
+
+
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-table.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-table.component.scss
new file mode 100644
index 0000000000..430958d0f4
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-table.component.scss
@@ -0,0 +1,76 @@
+/**
+ * Copyright © 2016-2025 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+:host {
+ .arguments-table {
+ min-height: 108px;
+
+ &-with-error {
+ min-height: 150px;
+ }
+
+ .mat-mdc-table {
+ table-layout: fixed;
+ }
+
+ .key-text {
+ font-size: 13px;
+ }
+
+ .copy-argument-name {
+ visibility: hidden;
+ transition: visibility 0.1s;
+ }
+
+ .argument-name-cell:hover {
+ .copy-argument-name {
+ visibility: visible;
+ }
+ }
+ }
+
+ .max-args-warning {
+ .mat-icon {
+ color: #FAA405;
+ }
+ }
+
+ .tb-form-table-row-cell-buttons {
+ --mat-badge-legacy-small-size-container-size: 8px;
+ --mat-badge-small-size-container-overlap-offset: -5px;
+ --mat-badge-small-size-text-size: 0;
+ }
+}
+
+:host ::ng-deep {
+ .arguments-table:not(.arguments-table-with-error) {
+ .mdc-data-table__row:last-child .mat-mdc-cell {
+ border-bottom: none;
+ }
+ }
+
+ .arguments-table {
+ .mat-mdc-header-row.mat-row-select .mat-mdc-header-cell.entity-type-header {
+ padding: 0 28px 0 0;
+ }
+ }
+
+ .copy-argument-name {
+ .mat-icon {
+ font-size: 16px;
+ padding: 4px;
+ }
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-table.component.ts
new file mode 100644
index 0000000000..2e4ac30165
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-table.component.ts
@@ -0,0 +1,242 @@
+///
+/// Copyright © 2016-2025 The Thingsboard Authors
+///
+/// Licensed under the Apache License, Version 2.0 (the "License");
+/// you may not use this file except in compliance with the License.
+/// You may obtain a copy of the License at
+///
+/// http://www.apache.org/licenses/LICENSE-2.0
+///
+/// Unless required by applicable law or agreed to in writing, software
+/// distributed under the License is distributed on an "AS IS" BASIS,
+/// WITHOUT WARRANTIES 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,
+ DestroyRef,
+ forwardRef,
+ Input,
+ Renderer2,
+ ViewChild,
+ ViewContainerRef,
+} from '@angular/core';
+import {
+ ControlValueAccessor,
+ FormBuilder,
+ NG_VALIDATORS,
+ NG_VALUE_ACCESSOR,
+ ValidationErrors,
+ Validator,
+} from '@angular/forms';
+import {
+ AggFunctionTranslations,
+ AggInputTypeTranslations,
+ CalculatedFieldAggMetric,
+ CalculatedFieldAggMetricValue,
+} from '@shared/models/calculated-field.models';
+import { MatButton } from '@angular/material/button';
+import { TbPopoverService } from '@shared/components/popover.service';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { isDefinedAndNotNull, isEqual } from '@core/utils';
+import { TbPopoverComponent } from '@shared/components/popover.component';
+import { TbTableDatasource } from '@shared/components/table/table-datasource.abstract';
+import { MatSort, SortDirection } from '@angular/material/sort';
+import { getCurrentAuthState } from '@core/auth/auth.selectors';
+import { Store } from '@ngrx/store';
+import { AppState } from '@core/core.state';
+import {
+ CalculatedFieldMetricsPanelComponent
+} from '@home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component';
+import { TbEditorCompleter } from '@shared/models/ace/completion.models';
+import { AceHighlightRules } from '@shared/models/ace/ace.models';
+
+@Component({
+ selector: 'tb-calculated-field-metrics-table',
+ templateUrl: './calculated-field-metrics-table.component.html',
+ styleUrls: [`calculated-field-metrics-table.component.scss`],
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => CalculatedFieldMetricsTableComponent),
+ multi: true
+ },
+ {
+ provide: NG_VALIDATORS,
+ useExisting: forwardRef(() => CalculatedFieldMetricsTableComponent),
+ multi: true
+ }
+ ],
+})
+export class CalculatedFieldMetricsTableComponent implements ControlValueAccessor, Validator, AfterViewInit {
+
+ @Input() arguments: Array;
+ @Input() editorCompleter: TbEditorCompleter;
+ @Input() highlightRules: AceHighlightRules;
+
+ @ViewChild(MatSort, { static: true }) sort: MatSort;
+
+ errorText = '';
+ metricsFormArray = this.fb.array([]);
+ sortOrder = { direction: 'asc' as SortDirection, property: '' };
+ dataSource = new CalculatedFieldMetricsDatasource();
+
+ displayColumns = ['name', 'function', 'filter', 'valueSource', 'actions']
+
+ readonly AggFunctionTranslations = AggFunctionTranslations;
+ readonly AggInputTypeTranslations = AggInputTypeTranslations;
+ readonly maxArgumentsPerCF = getCurrentAuthState(this.store).maxArgumentsPerCF - 2;
+
+ private popoverComponent: TbPopoverComponent;
+ private propagateChange: (zonesObj: Record) => void = () => {};
+
+ constructor(
+ private fb: FormBuilder,
+ private popoverService: TbPopoverService,
+ private viewContainerRef: ViewContainerRef,
+ private cd: ChangeDetectorRef,
+ private renderer: Renderer2,
+ private destroyRef: DestroyRef,
+ private store: Store
+ ) {
+ this.metricsFormArray.valueChanges.pipe(takeUntilDestroyed()).subscribe(value => {
+ this.updateDataSource(value);
+ this.propagateChange(this.getMetricsObject(value));
+ });
+ }
+
+ ngAfterViewInit(): void {
+ this.sort.sortChange.asObservable().pipe(
+ takeUntilDestroyed(this.destroyRef)
+ ).subscribe(() => {
+ this.sortOrder.property = this.sort.active;
+ this.sortOrder.direction = this.sort.direction;
+ this.updateDataSource(this.metricsFormArray.value);
+ });
+ }
+
+ registerOnChange(fn: (zonesObj: Record) => void): void {
+ this.propagateChange = fn;
+ }
+
+ registerOnTouched(_fn: any): void {}
+
+ validate(): ValidationErrors | null {
+ this.updateErrorText();
+ return this.errorText ? { metricsFormArray: false } : null;
+ }
+
+ onDelete($event: Event, metric: CalculatedFieldAggMetricValue): void {
+ $event.stopPropagation();
+ const index = this.metricsFormArray.controls.findIndex(control => isEqual(control.value, metric));
+ this.metricsFormArray.removeAt(index);
+ this.metricsFormArray.markAsDirty();
+ }
+
+ manageMetrics($event: Event, matButton: MatButton, metric = {} as CalculatedFieldAggMetricValue): void {
+ $event?.stopPropagation();
+ if (this.popoverComponent && !this.popoverComponent.tbHidden) {
+ this.popoverComponent.hide();
+ }
+ const trigger = matButton._elementRef.nativeElement;
+ if (this.popoverService.hasPopover(trigger)) {
+ this.popoverService.hidePopover(trigger);
+ } else {
+ const index = this.metricsFormArray.controls.findIndex(control => isEqual(control.value, metric));
+ const isExists = index !== -1;
+ const ctx = {
+ index,
+ metric,
+ buttonTitle: isExists ? 'action.apply' : 'action.add',
+ usedNames: this.metricsFormArray.value.map(({ name }) => name).filter(name => name !== metric.name),
+ arguments: this.arguments,
+ editorCompleter: this.editorCompleter,
+ highlightRules: this.highlightRules
+ };
+ this.popoverComponent = this.popoverService.displayPopover({
+ trigger,
+ renderer: this.renderer,
+ componentType: CalculatedFieldMetricsPanelComponent,
+ hostView: this.viewContainerRef,
+ preferredPlacement: isExists ? ['leftOnly', 'leftTopOnly', 'leftBottomOnly'] : ['rightOnly', 'rightTopOnly', 'rightBottomOnly'],
+ context: ctx,
+ isModal: true
+ });
+ this.popoverComponent.tbComponentRef.instance.metricDataApplied.subscribe((value) => {
+ this.popoverComponent.hide();
+ if (isExists) {
+ this.metricsFormArray.at(index).setValue(value);
+ } else {
+ this.metricsFormArray.push(this.fb.control(value));
+ }
+ this.cd.markForCheck();
+ });
+ }
+ }
+
+ private updateDataSource(value: CalculatedFieldAggMetricValue[]): void {
+ const sortedValue = this.sortData(value);
+ this.dataSource.loadData(sortedValue);
+ }
+
+ private updateErrorText(): void {
+ if (!this.metricsFormArray.controls.length) {
+ this.errorText = 'calculated-fields.metrics.metrics-empty';
+ } else {
+ this.errorText = '';
+ }
+ }
+
+ private getMetricsObject(value: CalculatedFieldAggMetricValue[]): Record {
+ return value.reduce((acc, metricValue) => {
+ const { name, ...metric } = metricValue;
+ acc[name] = metric;
+ return acc;
+ }, {} as Record);
+ }
+
+ writeValue(metrics: Record): void {
+ this.metricsFormArray.clear();
+ this.populateZonesFormArray(metrics);
+ }
+
+ private populateZonesFormArray(metrics: Record): void {
+ Object.keys(metrics).forEach(key => {
+ const value: CalculatedFieldAggMetricValue = {
+ ...metrics[key],
+ name: key
+ };
+ this.metricsFormArray.push(this.fb.control(value), { emitEvent: false });
+ });
+ this.metricsFormArray.updateValueAndValidity();
+ }
+
+ private getSortValue(metric: CalculatedFieldAggMetricValue, column: string): string {
+ switch (column) {
+ case 'function':
+ return metric.function;
+ case 'filter':
+ return isDefinedAndNotNull(metric.filter).toString();
+ default:
+ return metric.name;
+ }
+ }
+
+ private sortData(data: CalculatedFieldAggMetricValue[]): CalculatedFieldAggMetricValue[] {
+ return data.sort((a, b) => {
+ const valA = this.getSortValue(a, this.sortOrder.property) ?? '';
+ const valB = this.getSortValue(b, this.sortOrder.property) ?? '';
+ return (this.sortOrder.direction === 'asc' ? 1 : -1) * valA.localeCompare(valB);
+ });
+ }
+}
+
+class CalculatedFieldMetricsDatasource extends TbTableDatasource {
+ constructor() {
+ super();
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.html
index 1988141c2a..ce2d22fbbc 100644
--- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.html
+++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.html
@@ -51,8 +51,13 @@