diff --git a/ui-ngx/src/app/modules/common/modules-map.ts b/ui-ngx/src/app/modules/common/modules-map.ts
index a9f2dc4cec..0cc3f5bb2f 100644
--- a/ui-ngx/src/app/modules/common/modules-map.ts
+++ b/ui-ngx/src/app/modules/common/modules-map.ts
@@ -331,11 +331,14 @@ import * as AssetProfileComponent from '@home/components/profile/asset-profile.c
import * as AssetProfileDialogComponent from '@home/components/profile/asset-profile-dialog.component';
import * as AssetProfileAutocompleteComponent from '@home/components/profile/asset-profile-autocomplete.component';
import * as RuleChainSelectComponent from '@shared/components/rule-chain/rule-chain-select.component';
+import * as TimezoneComponent from '@shared/components/time/timezone.component';
+import * as TimezonePanelComponent from '@shared/components/time/timezone-panel.component';
+import * as DatapointsLimitComponent from '@shared/components/time/datapoints-limit.component';
+import * as AggregationTypeSelectComponent from '@shared/components/time/aggregation/aggregation-type-select.component';
+import * as AggregationOptionsConfigComponent from '@shared/components/time/aggregation/aggregation-options-config-panel.component';
+import * as IntervalOptionsConfigPanelComponent from '@shared/components/time/interval-options-config-panel.component';
import { IModulesMap } from '@modules/common/modules-map.models';
-import { TimezoneComponent } from '@shared/components/time/timezone.component';
-import { TimezonePanelComponent } from '@shared/components/time/timezone-panel.component';
-import { DatapointsLimitComponent } from '@shared/components/time/datapoints-limit.component';
import { Observable, map, of } from 'rxjs';
import { getFlexLayout } from '@shared/legacy/flex-layout.models';
import { isJSResourceUrl } from '@shared/public-api';
@@ -470,7 +473,10 @@ class ModulesMap implements IModulesMap {
'@shared/components/time/timezone-select.component': TimezoneSelectComponent,
'@shared/components/time/timezone.component': TimezoneComponent,
'@shared/components/time/timezone-panel.component': TimezonePanelComponent,
- '@shared/components/time/datapoints-limit': DatapointsLimitComponent,
+ '@shared/components/time/datapoints-limit.component': DatapointsLimitComponent,
+ '@shared/components/time/aggregation/aggregation-type-select.component': AggregationTypeSelectComponent,
+ '@shared/components/time/aggregation/aggregation-options-config-panel.component': AggregationOptionsConfigComponent,
+ '@shared/components/time/interval-options-config-panel.component': IntervalOptionsConfigPanelComponent,
'@shared/components/value-input.component': ValueInputComponent,
'@shared/components/dashboard-autocomplete.component': DashboardAutocompleteComponent,
'@shared/components/entity/entity-subtype-autocomplete.component': EntitySubTypeAutocompleteComponent,
diff --git a/ui-ngx/src/app/shared/components/time/aggregation/aggregation-options-config-panel.component.html b/ui-ngx/src/app/shared/components/time/aggregation/aggregation-options-config-panel.component.html
new file mode 100644
index 0000000000..c626c8d615
--- /dev/null
+++ b/ui-ngx/src/app/shared/components/time/aggregation/aggregation-options-config-panel.component.html
@@ -0,0 +1,47 @@
+
+
diff --git a/ui-ngx/src/app/shared/components/time/aggregation/aggregation-options-config-panel.component.scss b/ui-ngx/src/app/shared/components/time/aggregation/aggregation-options-config-panel.component.scss
new file mode 100644
index 0000000000..903097ba6b
--- /dev/null
+++ b/ui-ngx/src/app/shared/components/time/aggregation/aggregation-options-config-panel.component.scss
@@ -0,0 +1,44 @@
+/**
+ * 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.
+ */
+:host {
+ .tb-aggregation-options-form {
+ height: 100%;
+ max-width: 350px;
+
+ .tb-form-table {
+ overflow: hidden;
+ }
+
+ .tb-form-table-body {
+ overflow-y: auto;
+ }
+
+ .tb-form-hint {
+ flex-shrink: 0;
+ }
+
+ .mdc-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+
+ .mat-mdc-list-item.mdc-list-item--with-leading-checkbox.mdc-list-item--with-one-line {
+ height: 40px;
+ }
+ }
+ }
+}
+
diff --git a/ui-ngx/src/app/shared/components/time/aggregation/aggregation-options-config-panel.component.ts b/ui-ngx/src/app/shared/components/time/aggregation/aggregation-options-config-panel.component.ts
new file mode 100644
index 0000000000..c206f712b9
--- /dev/null
+++ b/ui-ngx/src/app/shared/components/time/aggregation/aggregation-options-config-panel.component.ts
@@ -0,0 +1,68 @@
+///
+/// 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, Input, OnInit } from '@angular/core';
+import { aggregationTranslations, AggregationType } from '@shared/models/time/time.models';
+import { FormBuilder, FormGroup } from '@angular/forms';
+import { TbPopoverComponent } from '@shared/components/popover.component';
+
+@Component({
+ selector: 'tb-aggregation-options-config-panel',
+ templateUrl: './aggregation-options-config-panel.component.html',
+ styleUrls: ['./aggregation-options-config-panel.component.scss']
+})
+export class AggregationOptionsConfigPanelComponent implements OnInit {
+
+ @Input()
+ allowedAggregationTypes: Array;
+
+ @Input()
+ onClose: (result: Array | null) => void;
+
+ @Input()
+ popoverComponent: TbPopoverComponent;
+
+ aggregationOptionsConfigForm: FormGroup;
+
+ aggregationTypes = AggregationType;
+
+ allAggregationTypes: Array = Object.values(AggregationType);
+
+ aggregationTypesTranslations = aggregationTranslations;
+
+ constructor(private fb: FormBuilder) {}
+
+ ngOnInit(): void {
+ this.aggregationOptionsConfigForm = this.fb.group({
+ allowedAggregationTypes: [this.allowedAggregationTypes?.length ? this.allowedAggregationTypes : this.allAggregationTypes]
+ });
+ }
+
+ update() {
+ if (this.onClose) {
+ const allowedAggregationTypes = this.aggregationOptionsConfigForm.get('allowedAggregationTypes').value;
+ // if full list selected returns empty for optimization
+ this.onClose(allowedAggregationTypes?.length < this.allAggregationTypes.length ? allowedAggregationTypes : []);
+ }
+ }
+
+ cancel() {
+ if (this.onClose) {
+ this.onClose(null);
+ }
+ }
+
+}
diff --git a/ui-ngx/src/app/shared/components/time/aggregation/aggregation-type-select.component.html b/ui-ngx/src/app/shared/components/time/aggregation/aggregation-type-select.component.html
new file mode 100644
index 0000000000..205e25cdf5
--- /dev/null
+++ b/ui-ngx/src/app/shared/components/time/aggregation/aggregation-type-select.component.html
@@ -0,0 +1,30 @@
+
+
+ {{ label }}
+
+
+ {{ displayAggregationTypeFn(type) }}
+
+
+
+
+
+
diff --git a/ui-ngx/src/app/shared/components/time/aggregation/aggregation-type-select.component.scss b/ui-ngx/src/app/shared/components/time/aggregation/aggregation-type-select.component.scss
new file mode 100644
index 0000000000..9cb0a0ea09
--- /dev/null
+++ b/ui-ngx/src/app/shared/components/time/aggregation/aggregation-type-select.component.scss
@@ -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.
+ */
+:host {
+ .mat-mdc-form-field {
+ width: 100%;
+ }
+}
+
+:host ::ng-deep {
+ .mat-mdc-form-field-infix {
+ width: 100%;
+ }
+ .mat-mdc-select-value {
+ min-width: 100px;
+ }
+ .mat-mdc-form-field-has-icon-suffix {
+ .mat-mdc-text-field-wrapper:has(.mat-mdc-form-field-icon-suffix:empty) {
+ padding-right: 12px;
+ }
+ .mat-mdc-form-field-icon-suffix:empty {
+ padding: 0;
+ }
+ }
+}
diff --git a/ui-ngx/src/app/shared/components/time/aggregation/aggregation-type-select.component.ts b/ui-ngx/src/app/shared/components/time/aggregation/aggregation-type-select.component.ts
new file mode 100644
index 0000000000..a746132bc3
--- /dev/null
+++ b/ui-ngx/src/app/shared/components/time/aggregation/aggregation-type-select.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, forwardRef, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core';
+import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR } from '@angular/forms';
+import { TranslateService } from '@ngx-translate/core';
+import { coerceBoolean } from '@shared/decorators/coercion';
+import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field';
+import { aggregationTranslations, AggregationType } from '@shared/models/time/time.models';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { isEqual } from '@core/utils';
+
+@Component({
+ selector: 'tb-aggregation-type-select',
+ templateUrl: './aggregation-type-select.component.html',
+ styleUrls: ['./aggregation-type-select.component.scss'],
+ providers: [{
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => AggregationTypeSelectComponent),
+ multi: true
+ }]
+})
+export class AggregationTypeSelectComponent implements ControlValueAccessor, OnInit, OnChanges {
+
+ aggregationTypeFormGroup: FormGroup;
+
+ modelValue: AggregationType | null;
+
+ @Input()
+ allowedAggregationTypes: Array;
+
+ @Input()
+ @coerceBoolean()
+ required: boolean;
+
+ @Input()
+ disabled: boolean;
+
+ @Input()
+ @coerceBoolean()
+ displayLabel = true;
+
+ @Input()
+ labelText: string;
+
+ get label(): string {
+ if (this.labelText && this.labelText.length) {
+ return this.labelText;
+ }
+ return this.defaultLabel;
+ }
+
+ @Input()
+ subscriptSizing: SubscriptSizing = 'fixed';
+
+ @Input()
+ appearance: MatFormFieldAppearance = 'fill';
+
+ aggregationTypes: Array;
+
+ private defaultLabel = this.translate.instant('aggregation.aggregation');
+
+ private allAggregationTypes: Array = Object.values(AggregationType);
+
+ private propagateChange = (v: any) => { };
+
+ constructor(private translate: TranslateService,
+ private fb: FormBuilder) {
+ this.aggregationTypeFormGroup = this.fb.group({
+ aggregationType: [null]
+ });
+ this.aggregationTypeFormGroup.get('aggregationType').valueChanges.pipe(
+ takeUntilDestroyed()
+ ).subscribe(
+ (value) => {
+ let modelValue;
+ if (!value) {
+ modelValue = null;
+ } else {
+ modelValue = value;
+ }
+ this.updateView(modelValue);
+ }
+ );
+ }
+
+ registerOnChange(fn: any): void {
+ this.propagateChange = fn;
+ }
+
+ registerOnTouched(fn: any): void {
+ }
+
+ ngOnInit() {
+ this.aggregationTypes = this.allowedAggregationTypes?.length ? this.allowedAggregationTypes : this.allAggregationTypes;
+ }
+
+ ngOnChanges({allowedAggregationTypes}: SimpleChanges): void {
+ if (allowedAggregationTypes && !allowedAggregationTypes.firstChange && !isEqual(allowedAggregationTypes.currentValue, allowedAggregationTypes.previousValue)) {
+ this.aggregationTypes = this.allowedAggregationTypes?.length ? this.allowedAggregationTypes : this.allAggregationTypes;
+ const currentAggregationType: AggregationType = this.aggregationTypeFormGroup.get('aggregationType').value;
+ if (currentAggregationType && !this.aggregationTypes.includes(currentAggregationType)) {
+ this.aggregationTypeFormGroup.get('aggregationType').patchValue(this.aggregationTypes[0], {emitEvent: true});
+ }
+ }
+ }
+
+ setDisabledState(isDisabled: boolean): void {
+ this.disabled = isDisabled;
+ if (this.disabled) {
+ this.aggregationTypeFormGroup.disable();
+ } else {
+ this.aggregationTypeFormGroup.enable();
+ }
+ }
+
+ writeValue(value: AggregationType | null): void {
+ let aggregationType: AggregationType;
+ if (value && this.allowedAggregationTypes?.length && !this.allowedAggregationTypes.includes(value)) {
+ aggregationType = this.allowedAggregationTypes[0];
+ } else {
+ aggregationType = value;
+ }
+ this.modelValue = aggregationType;
+ this.aggregationTypeFormGroup.get('aggregationType').patchValue(aggregationType, {emitEvent: false});
+ }
+
+ updateView(value: AggregationType | null) {
+ if (this.modelValue !== value) {
+ this.modelValue = value;
+ this.propagateChange(this.modelValue);
+ }
+ }
+
+ displayAggregationTypeFn(aggregationType?: AggregationType | null): string | undefined {
+ if (aggregationType) {
+ return this.translate.instant(aggregationTranslations.get(aggregationType));
+ } else {
+ return '';
+ }
+ }
+
+}
diff --git a/ui-ngx/src/app/shared/components/time/aggregation/grouping-interval-options.component.html b/ui-ngx/src/app/shared/components/time/aggregation/grouping-interval-options.component.html
new file mode 100644
index 0000000000..9854427ba6
--- /dev/null
+++ b/ui-ngx/src/app/shared/components/time/aggregation/grouping-interval-options.component.html
@@ -0,0 +1,41 @@
+
+
+
+
+
+ {{ 'timewindow.all' | translate }}
+
+
+ {{ interval.name | translate:interval.translateParams }}
+
+
+
+
+
+ --
+
+ {{ interval.name | translate:interval.translateParams }}
+
+
+
+
diff --git a/ui-ngx/src/app/shared/components/time/aggregation/grouping-interval-options.component.scss b/ui-ngx/src/app/shared/components/time/aggregation/grouping-interval-options.component.scss
new file mode 100644
index 0000000000..15fa21f554
--- /dev/null
+++ b/ui-ngx/src/app/shared/components/time/aggregation/grouping-interval-options.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.
+ */
+@import '../../../../../scss/constants';
+
+:host {
+ .agg-interval {
+ max-width: 180px;
+ }
+
+ @media #{$mat-xs} {
+ min-width: 0;
+ width: 100%;
+ }
+}
+
diff --git a/ui-ngx/src/app/shared/components/time/aggregation/grouping-interval-options.component.ts b/ui-ngx/src/app/shared/components/time/aggregation/grouping-interval-options.component.ts
new file mode 100644
index 0000000000..304e885995
--- /dev/null
+++ b/ui-ngx/src/app/shared/components/time/aggregation/grouping-interval-options.component.ts
@@ -0,0 +1,167 @@
+///
+/// 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, forwardRef, Input, OnInit } from '@angular/core';
+import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR } from '@angular/forms';
+import { TimeService } from '@core/services/time.service';
+import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field';
+import { coerceBoolean, coerceNumber } from '@shared/decorators/coercion';
+import {
+ Interval,
+ intervalValuesToTimeIntervals,
+ TimeInterval,
+ TimewindowAggIntervalOptions
+} from '@shared/models/time/time.models';
+import { isDefined } from '@core/utils';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+
+@Component({
+ selector: 'tb-grouping-interval-options',
+ templateUrl: './grouping-interval-options.component.html',
+ styleUrls: ['./grouping-interval-options.component.scss'],
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => GroupingIntervalOptionsComponent),
+ multi: true
+ }
+ ]
+})
+export class GroupingIntervalOptionsComponent implements OnInit, ControlValueAccessor {
+
+ @Input()
+ @coerceNumber()
+ min: number;
+
+ @Input()
+ @coerceNumber()
+ max: number;
+
+ @Input()
+ @coerceBoolean()
+ useCalendarIntervals = false;
+
+ @Input() disabled: boolean;
+
+ @Input()
+ subscriptSizing: SubscriptSizing = 'fixed';
+
+ @Input()
+ appearance: MatFormFieldAppearance = 'fill';
+
+ allIntervals: Array;
+ allIntervalValues: Array;
+ selectedIntervals: Array;
+
+ timeintervalFormGroup: FormGroup;
+
+ private modelValue: TimewindowAggIntervalOptions;
+ private rendered = false;
+ private propagateChangeValue: any;
+
+ private propagateChange = (value: any) => {
+ this.propagateChangeValue = value;
+ };
+
+ constructor(private timeService: TimeService,
+ private fb: FormBuilder) {
+ this.timeintervalFormGroup = this.fb.group({
+ aggIntervals: [ [] ],
+ defaultAggInterval: [ null ],
+ });
+ this.timeintervalFormGroup.get('aggIntervals').valueChanges.pipe(
+ takeUntilDestroyed()
+ ).subscribe(selectedIntervalValues => this.setSelectedIntervals(selectedIntervalValues));
+ this.timeintervalFormGroup.valueChanges.pipe(
+ takeUntilDestroyed()
+ ).subscribe(() => this.updateView());
+ }
+
+ ngOnInit(): void {
+ this.updateIntervalsList();
+ }
+
+ registerOnChange(fn: any): void {
+ this.propagateChange = fn;
+ if (isDefined(this.propagateChangeValue)) {
+ this.propagateChange(this.propagateChangeValue);
+ }
+ }
+
+ registerOnTouched(fn: any): void {
+ }
+
+ setDisabledState(isDisabled: boolean): void {
+ this.disabled = isDisabled;
+ if (this.disabled) {
+ this.timeintervalFormGroup.disable({emitEvent: false});
+ } else {
+ this.timeintervalFormGroup.enable({emitEvent: false});
+ }
+ }
+
+ writeValue(intervalOptions: TimewindowAggIntervalOptions): void {
+ this.modelValue = intervalOptions;
+ this.rendered = true;
+ this.timeintervalFormGroup.get('defaultAggInterval').patchValue(intervalOptions.defaultAggInterval, { emitEvent: false });
+ this.setIntervals(intervalOptions.aggIntervals);
+ }
+
+ private updateIntervalsList() {
+ this.allIntervals = this.timeService.getIntervals(this.min, this.max, this.useCalendarIntervals);
+ this.allIntervalValues = this.allIntervals.map(interval => interval.value);
+ }
+
+ private setIntervals(intervals: Array) {
+ const selectedIntervals = !intervals?.length ? this.allIntervalValues : intervals;
+ this.timeintervalFormGroup.get('aggIntervals').patchValue(
+ selectedIntervals,
+ {emitEvent: false});
+ this.setSelectedIntervals(selectedIntervals);
+ }
+
+ private setSelectedIntervals(selectedIntervalValues: Array) {
+ if (!selectedIntervalValues.length || selectedIntervalValues.length === this.allIntervalValues.length) {
+ this.selectedIntervals = this.allIntervals;
+ } else {
+ this.selectedIntervals = intervalValuesToTimeIntervals(selectedIntervalValues);
+ }
+ const defaultInterval: Interval = this.timeintervalFormGroup.get('defaultAggInterval').value;
+ if (defaultInterval && !selectedIntervalValues.includes(defaultInterval)) {
+ this.timeintervalFormGroup.get('defaultAggInterval').patchValue(null);
+ }
+ }
+
+ private updateView() {
+ if (!this.rendered) {
+ return;
+ }
+ let selectedIntervals: Array;
+ const intervals: Array = this.timeintervalFormGroup.get('aggIntervals').value;
+
+ if (intervals.length < this.allIntervals.length) {
+ selectedIntervals = intervals;
+ } else {
+ selectedIntervals = [];
+ }
+ this.modelValue = {
+ aggIntervals: selectedIntervals,
+ defaultAggInterval: this.timeintervalFormGroup.get('defaultAggInterval').value
+ };
+ this.propagateChange(this.modelValue);
+ }
+
+}
diff --git a/ui-ngx/src/app/shared/components/time/interval-options-config-panel.component.html b/ui-ngx/src/app/shared/components/time/interval-options-config-panel.component.html
new file mode 100644
index 0000000000..c32afb983a
--- /dev/null
+++ b/ui-ngx/src/app/shared/components/time/interval-options-config-panel.component.html
@@ -0,0 +1,85 @@
+
+
diff --git a/ui-ngx/src/app/shared/components/time/interval-options-config-panel.component.scss b/ui-ngx/src/app/shared/components/time/interval-options-config-panel.component.scss
new file mode 100644
index 0000000000..26747f9006
--- /dev/null
+++ b/ui-ngx/src/app/shared/components/time/interval-options-config-panel.component.scss
@@ -0,0 +1,71 @@
+/**
+ * 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 {
+ .tb-interval-options-form {
+ height: 100%;
+
+ .tb-form-table {
+ overflow: hidden;
+ }
+
+ .tb-form-table-body {
+ overflow-y: auto;
+ }
+
+ .tb-form-table-header-cell, .tb-form-table-row-cell {
+ &.tb-interval {
+ flex: 1 1 30%;
+ min-width: calc(30% - 8px);
+ @media #{$mat-gt-md} {
+ min-width: calc(30% - 12px);
+ }
+ }
+ &.tb-agg-interval {
+ flex: 1 1 70%;
+ min-width: 70%;
+ }
+ }
+
+ .tb-form-table-header-cell.tb-agg-interval-header {
+ flex: 1 1 35%;
+ width: 35%;
+ }
+
+ .tb-form-hint {
+ flex-shrink: 0;
+ }
+
+ ::ng-deep {
+ .tb-interval-option {
+ display: block;
+
+ .mdc-form-field {
+ width: 100%;
+ }
+
+ .mdc-label {
+ overflow: hidden;
+ mat-label {
+ display: block;
+ }
+ }
+ }
+ }
+ }
+}
+
diff --git a/ui-ngx/src/app/shared/components/time/interval-options-config-panel.component.ts b/ui-ngx/src/app/shared/components/time/interval-options-config-panel.component.ts
new file mode 100644
index 0000000000..38e96d3f87
--- /dev/null
+++ b/ui-ngx/src/app/shared/components/time/interval-options-config-panel.component.ts
@@ -0,0 +1,231 @@
+///
+/// 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, DestroyRef, inject, Input, OnInit } from '@angular/core';
+import {
+ HistoryWindowType,
+ QuickTimeInterval, quickTimeIntervalPeriod,
+ QuickTimeIntervalTranslationMap,
+ RealtimeWindowType,
+ TimewindowAggIntervalOptions,
+ TimewindowAggIntervalsConfig,
+ TimewindowInterval,
+ TimewindowIntervalOption,
+ TimewindowType
+} from '@shared/models/time/time.models';
+import { AbstractControl, FormBuilder, FormGroup, UntypedFormArray } from '@angular/forms';
+import { TbPopoverComponent } from '@shared/components/popover.component';
+import { TimeService } from '@core/services/time.service';
+import { coerceBoolean } from '@shared/decorators/coercion';
+import { TranslateService } from '@ngx-translate/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+
+export interface IntervalOptionsConfigPanelData {
+ allowedIntervals: Array;
+ aggIntervalsConfig: TimewindowAggIntervalsConfig
+}
+
+@Component({
+ selector: 'tb-interval-options-config-panel',
+ templateUrl: './interval-options-config-panel.component.html',
+ styleUrls: ['./interval-options-config-panel.component.scss']
+})
+export class IntervalOptionsConfigPanelComponent implements OnInit {
+
+ @Input()
+ @coerceBoolean()
+ aggregation = false;
+
+ @Input()
+ allowedIntervals: Array;
+
+ @Input()
+ aggIntervalsConfig: TimewindowAggIntervalsConfig;
+
+ @Input()
+ intervalType: RealtimeWindowType | HistoryWindowType;
+
+ @Input()
+ timewindowType: TimewindowType;
+
+ @Input()
+ onClose: (result: IntervalOptionsConfigPanelData | null) => void;
+
+ @Input()
+ popoverComponent: TbPopoverComponent;
+
+ intervalOptionsConfigForm: FormGroup;
+
+ allIntervals: Array;
+ allIntervalValues: Array
+
+ private timeIntervalTranslationMap = QuickTimeIntervalTranslationMap;
+
+ private destroyRef = inject(DestroyRef);
+
+ constructor(private fb: FormBuilder,
+ private timeService: TimeService,
+ private translate: TranslateService) {}
+
+ ngOnInit(): void {
+ if (this.intervalType === RealtimeWindowType.LAST_INTERVAL ||
+ this.intervalType === HistoryWindowType.LAST_INTERVAL) {
+ this.allIntervals = this.timeService.getIntervals(undefined, undefined, false);
+ this.allIntervalValues = this.allIntervals.map(interval => interval.value);
+ } else {
+ this.allIntervalValues = this.getQuickIntervals();
+ this.allIntervals = this.allIntervalValues.map(interval => ({
+ name: this.timeIntervalTranslationMap.get(interval as QuickTimeInterval),
+ value: interval
+ }));
+ }
+
+ this.intervalOptionsConfigForm = this.fb.group({
+ intervals: this.fb.array([])
+ });
+
+ const intervalControls: Array = [];
+ for (const interval of this.allIntervals) {
+ const intervalConfig: TimewindowAggIntervalOptions = this.aggIntervalsConfig?.hasOwnProperty(interval.value)
+ ? this.aggIntervalsConfig[interval.value]
+ : null;
+ const intervalEnabled = this.allowedIntervals?.length ? this.allowedIntervals.includes(interval.value) : true;
+ const intervalControl = this.fb.group({
+ name: [this.translate.instant(interval.name, interval.translateParams)],
+ value: [interval.value],
+ enabled: [intervalEnabled],
+ aggIntervalsConfig: [{value: {
+ aggIntervals: intervalConfig?.aggIntervals ? intervalConfig.aggIntervals : [],
+ defaultAggInterval: intervalConfig?.defaultAggInterval ? intervalConfig.defaultAggInterval : null
+ }, disabled: !(intervalEnabled && this.aggregation)}]
+ });
+ if (this.aggregation) {
+ intervalControl.get('enabled').valueChanges.pipe(
+ takeUntilDestroyed(this.destroyRef)
+ ).subscribe((intervalEnabled) => {
+ if (intervalEnabled) {
+ intervalControl.get('aggIntervalsConfig').enable({emitEvent: false});
+ } else {
+ intervalControl.get('aggIntervalsConfig').disable({emitEvent: false});
+ }
+ });
+ }
+ intervalControls.push(intervalControl);
+ }
+ this.intervalOptionsConfigForm.setControl('intervals', this.fb.array(intervalControls), {emitEvent: false});
+ }
+
+ get intervalsFormArray(): UntypedFormArray {
+ return this.intervalOptionsConfigForm.get('intervals') as UntypedFormArray;
+ }
+
+ minAggInterval(interval: TimewindowInterval) {
+ return this.timeService.minIntervalLimit(this.getIntervalMs(interval));
+ }
+
+ maxAggInterval(interval: TimewindowInterval) {
+ return this.timeService.maxIntervalLimit(this.getIntervalMs(interval));
+ }
+
+ private getIntervalMs(interval: TimewindowInterval): number {
+ if (this.intervalType === RealtimeWindowType.INTERVAL ||
+ this.intervalType === HistoryWindowType.INTERVAL) {
+ return quickTimeIntervalPeriod(interval as QuickTimeInterval);
+ }
+ return interval as number;
+ }
+
+ trackByElement(i: number, item: any) {
+ return item;
+ }
+
+ update() {
+ if (this.onClose) {
+ const allowedIntervals: Array = [];
+ const aggIntervalsConfig: TimewindowAggIntervalsConfig = {};
+ const intervalOptionsConfig = this.intervalOptionsConfigForm.get('intervals').value;
+ for (const interval of intervalOptionsConfig) {
+ if (interval.enabled) {
+ allowedIntervals.push(interval.value);
+ if (this.aggregation && (interval.aggIntervalsConfig.aggIntervals.length || interval.aggIntervalsConfig.defaultAggInterval)) {
+ const intervalParams: TimewindowAggIntervalOptions = {};
+ if (interval.aggIntervalsConfig.aggIntervals.length) {
+ intervalParams.aggIntervals = interval.aggIntervalsConfig.aggIntervals;
+ }
+ if (interval.aggIntervalsConfig.defaultAggInterval) {
+ intervalParams.defaultAggInterval = interval.aggIntervalsConfig.defaultAggInterval;
+ }
+ aggIntervalsConfig[interval.value] = intervalParams;
+ }
+ }
+ }
+ this.onClose({
+ // if full list selected returns empty for optimization
+ allowedIntervals: allowedIntervals?.length < this.allIntervals.length ? allowedIntervals : [],
+ aggIntervalsConfig
+ });
+ }
+ }
+
+ cancel() {
+ if (this.onClose) {
+ this.onClose(null);
+ }
+ }
+
+ reset() {
+ const intervalControls = this.intervalsFormArray.controls;
+ for (const interval of intervalControls) {
+ interval.patchValue({
+ enabled: true,
+ aggIntervalsConfig: {
+ aggIntervals: [],
+ defaultAggInterval: null
+ }
+ });
+ }
+ this.intervalOptionsConfigForm.markAsDirty();
+ }
+
+ private getQuickIntervals(): Array {
+ const allQuickIntervals = Object.values(QuickTimeInterval);
+ if (this.timewindowType === TimewindowType.REALTIME) {
+ return allQuickIntervals.filter(interval => interval.startsWith('CURRENT_'));
+ }
+ return allQuickIntervals;
+ }
+
+ getIndeterminate(): boolean {
+ const enabledIntervals = this.intervalsFormArray.value.filter(interval => interval.enabled);
+ return enabledIntervals.length !== 0 && enabledIntervals.length !== this.allIntervals.length;
+ }
+
+ enableDisableIntervals(allEnabled: boolean) {
+ const intervalControls = this.intervalsFormArray.controls;
+ for (const interval of intervalControls) {
+ interval.patchValue({
+ enabled: allEnabled
+ });
+ }
+ this.intervalOptionsConfigForm.markAsDirty();
+ }
+
+ getChecked(): boolean {
+ const intervals = this.intervalsFormArray.value;
+ return intervals.every(interval => interval.enabled);
+ }
+
+}
diff --git a/ui-ngx/src/app/shared/components/time/quick-time-interval.component.html b/ui-ngx/src/app/shared/components/time/quick-time-interval.component.html
index 40f39118c8..b7cb004a75 100644
--- a/ui-ngx/src/app/shared/components/time/quick-time-interval.component.html
+++ b/ui-ngx/src/app/shared/components/time/quick-time-interval.component.html
@@ -15,13 +15,16 @@
limitations under the License.
-->
-
+
timewindow.interval
-
+
{{ timeIntervalTranslationMap.get(interval) | translate}}
+
+
+
diff --git a/ui-ngx/src/app/shared/components/time/quick-time-interval.component.scss b/ui-ngx/src/app/shared/components/time/quick-time-interval.component.scss
index de583225fb..e9b79fbf4c 100644
--- a/ui-ngx/src/app/shared/components/time/quick-time-interval.component.scss
+++ b/ui-ngx/src/app/shared/components/time/quick-time-interval.component.scss
@@ -16,7 +16,7 @@
@import '../../../../scss/constants';
:host {
- min-width: 355px;
+ min-width: 300px;
display: block;
@media #{$mat-xs} {
@@ -24,3 +24,14 @@
width: 100%;
}
}
+
+:host ::ng-deep {
+ .mat-mdc-form-field-has-icon-suffix {
+ .mat-mdc-text-field-wrapper:has(.mat-mdc-form-field-icon-suffix:empty) {
+ padding-right: 12px;
+ }
+ .mat-mdc-form-field-icon-suffix:empty {
+ padding: 0;
+ }
+ }
+ }
diff --git a/ui-ngx/src/app/shared/components/time/quick-time-interval.component.ts b/ui-ngx/src/app/shared/components/time/quick-time-interval.component.ts
index 87b3687d0e..2117ed6f67 100644
--- a/ui-ngx/src/app/shared/components/time/quick-time-interval.component.ts
+++ b/ui-ngx/src/app/shared/components/time/quick-time-interval.component.ts
@@ -14,11 +14,13 @@
/// limitations under the License.
///
-import { Component, forwardRef, Input, OnInit } from '@angular/core';
-import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
+import { Component, forwardRef, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core';
+import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR } from '@angular/forms';
import { QuickTimeInterval, QuickTimeIntervalTranslationMap } from '@shared/models/time/time.models';
import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field';
import { coerceBoolean } from '@shared/decorators/coercion';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { isEqual } from '@core/utils';
@Component({
selector: 'tb-quick-time-interval',
@@ -32,9 +34,9 @@ import { coerceBoolean } from '@shared/decorators/coercion';
}
]
})
-export class QuickTimeIntervalComponent implements OnInit, ControlValueAccessor {
+export class QuickTimeIntervalComponent implements OnInit, ControlValueAccessor, OnChanges {
- private allIntervals = Object.values(QuickTimeInterval);
+ private allIntervals = Object.values(QuickTimeInterval) as QuickTimeInterval[];
modelValue: QuickTimeInterval;
timeIntervalTranslationMap = QuickTimeIntervalTranslationMap;
@@ -49,25 +51,53 @@ export class QuickTimeIntervalComponent implements OnInit, ControlValueAccessor
@Input() onlyCurrentInterval = false;
+ @Input()
+ allowedIntervals: Array
+
@Input()
subscriptSizing: SubscriptSizing = 'fixed';
@Input()
appearance: MatFormFieldAppearance = 'fill';
+ intervals: Array;
+
+ private allAvailableIntervals: Array;
+
+ quickIntervalFormGroup: FormGroup;
+
private propagateChange = (_: any) => {};
- constructor() {
+ constructor(private fb: FormBuilder) {
+ this.quickIntervalFormGroup = this.fb.group({
+ interval: [ null ]
+ });
+ this.quickIntervalFormGroup.get('interval').valueChanges.pipe(
+ takeUntilDestroyed()
+ ).subscribe((value) => {
+ let modelValue;
+ if (!value) {
+ modelValue = null;
+ } else {
+ modelValue = value;
+ }
+ this.updateView(modelValue);
+ });
}
- get intervals() {
- if (this.onlyCurrentInterval) {
- return this.allIntervals.filter(interval => interval.startsWith('CURRENT_'));
- }
- return this.allIntervals;
+ ngOnInit(): void {
+ this.allAvailableIntervals = this.getAllAvailableIntervals();
+ this.intervals = this.allowedIntervals?.length ? this.allowedIntervals : this.allAvailableIntervals;
}
- ngOnInit(): void {
+ ngOnChanges({allowedIntervals}: SimpleChanges): void {
+ if (allowedIntervals && !allowedIntervals.firstChange && !isEqual(allowedIntervals.currentValue, allowedIntervals.previousValue)) {
+ this.intervals = this.allowedIntervals?.length ? this.allowedIntervals : this.allAvailableIntervals;
+ const currentInterval: QuickTimeInterval = this.quickIntervalFormGroup.get('interval').value;
+ if (currentInterval && !this.intervals.includes(currentInterval)) {
+ this.quickIntervalFormGroup.get('interval').patchValue(this.intervals[0], {emitEvent: true});
+ }
+ }
}
registerOnChange(fn: any): void {
@@ -79,13 +109,35 @@ export class QuickTimeIntervalComponent implements OnInit, ControlValueAccessor
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
+ if (this.disabled) {
+ this.quickIntervalFormGroup.disable({emitEvent: false});
+ } else {
+ this.quickIntervalFormGroup.enable({emitEvent: false});
+ }
}
- writeValue(interval: QuickTimeInterval): void {
+ writeValue(value: QuickTimeInterval): void {
+ let interval: QuickTimeInterval;
+ if (value && this.allowedIntervals?.length && !this.allowedIntervals.includes(value)) {
+ interval = this.allowedIntervals[0];
+ } else {
+ interval = value;
+ }
this.modelValue = interval;
+ this.quickIntervalFormGroup.get('interval').patchValue(interval, {emitEvent: false});
+ }
+
+ updateView(value: QuickTimeInterval | null) {
+ if (this.modelValue !== value) {
+ this.modelValue = value;
+ this.propagateChange(this.modelValue);
+ }
}
- onIntervalChange() {
- this.propagateChange(this.modelValue);
+ private getAllAvailableIntervals() {
+ if (this.onlyCurrentInterval) {
+ return this.allIntervals.filter(interval => interval.startsWith('CURRENT_'));
+ }
+ return this.allIntervals;
}
}
diff --git a/ui-ngx/src/app/shared/components/time/timeinterval.component.html b/ui-ngx/src/app/shared/components/time/timeinterval.component.html
index 57e6ca0808..e9914c6068 100644
--- a/ui-ngx/src/app/shared/components/time/timeinterval.component.html
+++ b/ui-ngx/src/app/shared/components/time/timeinterval.component.html
@@ -25,6 +25,9 @@
{{ interval.name | translate:interval.translateParams }}
+
+
+