diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.html
index 1b45b7f245..2907472143 100644
--- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.html
+++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.html
@@ -71,7 +71,7 @@
labelText="{{ 'calculated-fields.aggregate-interval-value' | translate }}"
minErrorText="{{ 'calculated-fields.aggregate-interval-value-min' | translate : {sec: minAggregationIntervalInSecForCF} }}"
requiredText="{{ 'calculated-fields.aggregate-interval-value-required' | translate }}"
- stepMultipleOfErrorText="Must be 1 day"
+ stepMultipleOfErrorText="{{ 'calculated-fields.aggregate-interval-value-step-multiple-of' | translate }}"
formControlName="durationSec">
}
@@ -84,15 +84,20 @@
@if (entityAggregationConfiguration.get('interval.allowOffsetSec').value) {
+
+ {{ hint }}
+
}
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.ts
index 81fb709f99..8da5c47606 100644
--- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.ts
+++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.ts
@@ -35,19 +35,29 @@ import {
notEmptyObjectValidator,
OutputType
} from '@shared/models/calculated-field.models';
-import { map } from 'rxjs/operators';
+import { filter, map } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
-import { DAY, HOUR, MINUTE, SECOND } from '@shared/models/time/time.models';
+import { AVG_MONTH, AVG_QUARTER, DAY, HOUR, MINUTE, SECOND, YEAR } from '@shared/models/time/time.models';
import { isDefinedAndNotNull } from '@core/utils';
import { getCurrentAuthState } from '@core/auth/auth.selectors';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
+import { merge } from 'rxjs';
+import { TranslateService } from '@ngx-translate/core';
+import _moment from 'moment';
interface CalculatedFieldEntityAggregationConfigurationValue extends CalculatedFieldEntityAggregationConfiguration {
interval: AggInterval & {allowOffsetSec?: boolean};
allowWatermark: boolean;
}
+enum TimeCategory {
+ SECONDS = 'SECONDS',
+ MINUTES = 'MINUTES',
+ HOURS = 'HOURS',
+ DAYS = 'DAYS'
+}
+
@Component({
selector: 'tb-entity-aggregation-component',
templateUrl: './entity-aggregation-component.component.html',
@@ -86,7 +96,7 @@ export class EntityAggregationComponentComponent implements ControlValueAccessor
tz: ['', Validators.required],
durationSec: [this.minAggregationIntervalInSecForCF, Validators.required],
allowOffsetSec: [false],
- offsetSec: [MINUTE/SECOND, Validators.required],
+ offsetSec: [this.minAggregationIntervalInSecForCF > 60 ? MINUTE / SECOND : 1, Validators.required],
}),
allowWatermark: [false],
watermark: this.fb.group({
@@ -105,10 +115,13 @@ export class EntityAggregationComponentComponent implements ControlValueAccessor
AggIntervalTypes = Object.values(AggIntervalType) as AggIntervalType[];
AggIntervalTypeTranslations = AggIntervalTypeTranslations;
+ hint: string;
+
private propagateChange: (config: CalculatedFieldEntityAggregationConfiguration) => void = () => { };
constructor(private fb: FormBuilder,
- private store: Store) {
+ private store: Store,
+ private translate: TranslateService,) {
this.entityAggregationConfiguration.get('interval.type').valueChanges.pipe(
takeUntilDestroyed()
@@ -128,6 +141,18 @@ export class EntityAggregationComponentComponent implements ControlValueAccessor
this.checkWatermark(allow);
});
+ merge(
+ this.entityAggregationConfiguration.get('interval.type').valueChanges,
+ this.entityAggregationConfiguration.get('interval.durationSec').valueChanges,
+ this.entityAggregationConfiguration.get('interval.offsetSec').valueChanges,
+ this.entityAggregationConfiguration.get('interval.allowOffsetSec').valueChanges,
+ ).pipe(
+ filter(() => this.entityAggregationConfiguration.get('interval.allowOffsetSec').value),
+ takeUntilDestroyed()
+ ).subscribe(() => {
+ this.updatedOffsetHint();
+ });
+
this.entityAggregationConfiguration.valueChanges.pipe(
takeUntilDestroyed()
).subscribe((value: CalculatedFieldEntityAggregationConfigurationValue) => {
@@ -149,6 +174,7 @@ export class EntityAggregationComponentComponent implements ControlValueAccessor
this.checkAggIntervalType(this.entityAggregationConfiguration.get('interval.type').value);
this.checkIntervalDuration(this.entityAggregationConfiguration.get('interval.allowOffsetSec').value);
this.checkWatermark(this.entityAggregationConfiguration.get('allowWatermark').value);
+ this.updatedOffsetHint();
setTimeout(() => {
this.entityAggregationConfiguration.get('arguments').updateValueAndValidity({onlySelf: true});
});
@@ -171,6 +197,26 @@ export class EntityAggregationComponentComponent implements ControlValueAccessor
}
}
+ get maxOffsetTime(): number {
+ switch (this.entityAggregationConfiguration.get('interval.type').value as AggIntervalType) {
+ case AggIntervalType.HOUR:
+ return HOUR / SECOND - 1;
+ case AggIntervalType.DAY:
+ return DAY / SECOND - 1;
+ case AggIntervalType.WEEK:
+ case AggIntervalType.WEEK_SUN_SAT:
+ return 7 * DAY / SECOND - 1;
+ case AggIntervalType.MONTH:
+ return AVG_MONTH / SECOND;
+ case AggIntervalType.QUARTER:
+ return AVG_QUARTER / SECOND - 1;
+ case AggIntervalType.YEAR:
+ return YEAR / SECOND - 1;
+ case AggIntervalType.CUSTOM:
+ return this.entityAggregationConfiguration.get('interval.durationSec').value - 1;
+ }
+ }
+
private updatedModel(value: CalculatedFieldEntityAggregationConfigurationValue): void {
value.type = CalculatedFieldType.ENTITY_AGGREGATION;
if (!value.interval.allowOffsetSec) {
@@ -197,6 +243,7 @@ export class EntityAggregationComponentComponent implements ControlValueAccessor
this.entityAggregationConfiguration.get('interval.offsetSec').enable({emitEvent: false});
} else {
this.entityAggregationConfiguration.get('interval.offsetSec').disable({emitEvent: false});
+ this.hint = '';
}
}
@@ -207,4 +254,158 @@ export class EntityAggregationComponentComponent implements ControlValueAccessor
this.entityAggregationConfiguration.get('watermark').disable({emitEvent: false});
}
}
+
+ private updatedOffsetHint(): void {
+ const offset = this.entityAggregationConfiguration.get('interval.offsetSec').value;
+ const intervalType = this.entityAggregationConfiguration.get('interval.type').value as AggIntervalType;
+ const durationSec = this.entityAggregationConfiguration.get('interval.durationSec').value;
+ const offsetCategory = this.getTimeCategory(offset);
+ const now = _moment.utc();
+ let interval: string = '';
+ if (intervalType === AggIntervalType.CUSTOM) {
+ const durationSecCategory = this.getTimeCategory(durationSec);
+ const formatString = this.getCustomFormatString(offsetCategory, durationSecCategory);
+ const intervals: string[] = [];
+ let allInterval = durationSec >= HOUR*6/SECOND && durationSec < DAY/SECOND;
+ now.startOf('year').add(offset, 'seconds');
+
+ let repeat = 2;
+ if (allInterval) {
+ repeat = Math.floor(DAY/SECOND/durationSec);
+ if (repeat > 4) {
+ repeat = 2;
+ allInterval = false;
+ }
+ }
+
+ for (let i = 0; i < repeat; i++) {
+ const s1 = now.clone().add(i * durationSec, 'seconds').format(formatString);
+ const s2 = now.clone().add((i + 1) * durationSec, 'seconds').format(formatString);
+ intervals.push(`${s1} - ${s2}`);
+ }
+ interval = intervals.join('; ');
+
+ if (allInterval) {
+ this.hint = this.translate.instant('calculated-fields.aggregate-period-hint-offset', {interval});
+ } else {
+ interval += '…'
+ this.hint = this.translate.instant('calculated-fields.aggregate-period-hint-offset-and-so-on', {interval});
+ }
+ } else {
+ interval = this.buildStandardIntervalString(now, intervalType, offset, offsetCategory);
+ this.hint = this.translate.instant('calculated-fields.aggregate-period-hint-offset-and-so-on', { interval });
+ }
+ }
+
+ private getTimeCategory(seconds: number): TimeCategory {
+ if (seconds % (DAY / SECOND) === 0) {
+ return TimeCategory.DAYS;
+ }
+ if (seconds % (HOUR / SECOND) === 0) {
+ return TimeCategory.HOURS;
+ }
+ if (seconds % (MINUTE / SECOND) === 0) {
+ return TimeCategory.MINUTES;
+ }
+ return TimeCategory.SECONDS;
+ }
+
+ private getCustomFormatString(offsetCat: TimeCategory, durationCat: TimeCategory): string {
+ if (durationCat === TimeCategory.DAYS) {
+ if (offsetCat === TimeCategory.SECONDS) {
+ return '[Day] D, HH:mm:ss';
+ }
+ if (offsetCat === TimeCategory.MINUTES || offsetCat === TimeCategory.HOURS) {
+ return '[Day] D, HH:mm';
+ }
+ return '[Day] D';
+ } else {
+ if (offsetCat === TimeCategory.SECONDS) {
+ return 'HH:mm:ss';
+ }
+ return 'HH:mm';
+ }
+ }
+
+ private formatAdditiveInterval(now: _moment.Moment, addUnit: 'hour' | 'day' | 'month' | 'quarter', offsetCat: TimeCategory,
+ formats: { [key in TimeCategory]?: { s1: string, s2: string, s3: string } }): string {
+ const formatTs = formats[offsetCat] || formats[TimeCategory.SECONDS];
+
+ if (!formatTs) {
+ return '';
+ }
+
+ const s1 = now.format(formatTs.s1);
+ const s2 = now.clone().add(1, addUnit).format(formatTs.s2);
+ const s3 = now.clone().add(2, addUnit).format(formatTs.s3);
+
+ return `${s1} - ${s2}; ${s2} - ${s3}…`;
+ }
+
+ private formatNextInterval(now: _moment.Moment, offsetCat: TimeCategory, secFmt: string, minHourFmt: string, dayFmt: string): string {
+ let s1: string;
+ if (offsetCat === TimeCategory.SECONDS) {
+ s1 = now.format(secFmt);
+ } else if (offsetCat === TimeCategory.MINUTES || offsetCat === TimeCategory.HOURS) {
+ s1 = now.format(minHourFmt);
+ } else {
+ s1 = now.format(dayFmt);
+ }
+
+ const s2 = `Next ${s1}`;
+ const s3 = `Following ${s1}`;
+ return `${s1} - ${s2}; ${s2} - ${s3}… `;
+ }
+
+ private buildStandardIntervalString(now: _moment.Moment, type: AggIntervalType, offset: number, offsetCat: TimeCategory): string {
+ switch (type) {
+ case AggIntervalType.HOUR:
+ now.startOf('day').add(offset, 'seconds');
+ return this.formatAdditiveInterval(now, 'hour', offsetCat, {
+ [TimeCategory.SECONDS]: { s1: 'HH:mm:ss', s2: 'HH:mm:ss', s3: 'HH:mm:ss' },
+ [TimeCategory.MINUTES]: { s1: 'HH:mm:ss', s2: 'HH:mm', s3: 'HH:mm' }
+ });
+
+ case AggIntervalType.DAY:
+ now.startOf('month').add(offset, 'seconds');
+ return this.formatAdditiveInterval(now, 'day', offsetCat, {
+ [TimeCategory.SECONDS]: { s1: '[Day] D, HH:mm:ss', s2: '[Day] D, HH:mm:ss', s3: '[Day] D, HH:mm:ss' },
+ [TimeCategory.MINUTES]: { s1: '[Day] D, HH:mm:ss', s2: '[Day] D, HH:mm', s3: '[Day] D, HH:mm' },
+ [TimeCategory.HOURS]: { s1: 'HH:mm:ss', s2: '[Day] D, HH:mm', s3: '[Day] D, HH:mm' } // Note: Original logic, s1 format is different
+ });
+
+ case AggIntervalType.WEEK:
+ now.isoWeekday(1).startOf('isoWeek').add(offset, 'seconds');
+ return this.formatNextInterval(now, offsetCat, 'ddd, HH:mm:ss', 'ddd, HH:mm', 'ddd');
+
+ case AggIntervalType.WEEK_SUN_SAT:
+ now.startOf('week').add(offset, 'seconds');
+ return this.formatNextInterval(now, offsetCat, 'ddd, HH:mm:ss', 'ddd, HH:mm', 'ddd');
+
+ case AggIntervalType.MONTH:
+ now.startOf('year').add(offset, 'seconds');
+ return this.formatAdditiveInterval(now, 'month', offsetCat, {
+ [TimeCategory.SECONDS]: { s1: 'Do [of month], HH:mm:ss', s2: '[Next] Do, HH:mm:ss', s3: '[Following] Do, HH:mm:ss' },
+ [TimeCategory.MINUTES]: { s1: 'Do [of month], HH:mm', s2: '[Next] Do, HH:mm', s3: '[Following] Do, HH:mm' },
+ [TimeCategory.HOURS]: { s1: 'Do [of month], HH:mm', s2: '[Next] Do, HH:mm', s3: '[Following] Do, HH:mm' },
+ [TimeCategory.DAYS]: { s1: 'Do [of month]', s2: '[Next] Do', s3: '[Following] Do' }
+ });
+
+ case AggIntervalType.QUARTER:
+ now.startOf('year').add(offset, 'seconds');
+ return this.formatAdditiveInterval(now, 'quarter', offsetCat, {
+ [TimeCategory.SECONDS]: { s1: 'MMM Do, HH:mm:ss', s2: 'MMM Do, HH:mm:ss', s3: 'MMM Do, HH:mm:ss' },
+ [TimeCategory.MINUTES]: { s1: 'MMM Do, HH:mm', s2: 'MMM Do, HH:mm', s3: 'MMM Do, HH:mm' },
+ [TimeCategory.HOURS]: { s1: 'MMM Do, HH:mm', s2: 'MMM Do, HH:mm', s3: 'MMM Do, HH:mm' },
+ [TimeCategory.DAYS]: { s1: 'MMM Do', s2: 'MMM Do', s3: 'MMM Do' }
+ });
+
+ case AggIntervalType.YEAR:
+ now.startOf('year').add(offset, 'seconds');
+ return this.formatNextInterval(now, offsetCat, 'MMM Do, HH:mm:ss', 'MMM Do, HH:mm', 'MMM Do');
+
+ default:
+ return '';
+ }
+ }
}
diff --git a/ui-ngx/src/app/shared/components/time-unit-input.component.ts b/ui-ngx/src/app/shared/components/time-unit-input.component.ts
index 65acaa88f1..ac2211a50d 100644
--- a/ui-ngx/src/app/shared/components/time-unit-input.component.ts
+++ b/ui-ngx/src/app/shared/components/time-unit-input.component.ts
@@ -14,7 +14,7 @@
/// limitations under the License.
///
-import { Component, DestroyRef, forwardRef, Input, OnInit } from '@angular/core';
+import { Component, DestroyRef, forwardRef, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core';
import {
AbstractControl,
ControlValueAccessor,
@@ -51,7 +51,7 @@ interface TimeUnitInputModel {
multi: true
}]
})
-export class TimeUnitInputComponent implements ControlValueAccessor, Validator, OnInit {
+export class TimeUnitInputComponent implements ControlValueAccessor, Validator, OnInit, OnChanges {
@Input()
labelText: string;
@@ -129,15 +129,8 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator,
}
ngOnInit() {
- if (this.maxTime) {
- const maxTimeMs = this.maxTime * SECOND;
- if (maxTimeMs < MINUTE) {
- this.timeUnits = this.timeUnits.filter(item => item !== TimeUnit.MINUTES && item !== TimeUnit.HOURS && item !== TimeUnit.DAYS);
- } else if (maxTimeMs < HOUR) {
- this.timeUnits = this.timeUnits.filter(item => item !== TimeUnit.HOURS && item !== TimeUnit.DAYS);
- } else if (maxTimeMs < DAY) {
- this.timeUnits = this.timeUnits.filter(item => item !== TimeUnit.DAYS);
- }
+ if (isDefinedAndNotNull(this.maxTime)) {
+ this.updatedAllowTimeUnitInterval(this.maxTime);
}
if (this.required || this.maxTime || isDefinedAndNotNull(this.minTime) || this.stepMultipleOf) {
const timeControl = this.timeInputForm.get('time');
@@ -190,6 +183,21 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator,
}
}
+ ngOnChanges(changes: SimpleChanges): void {
+ for (const propName of Object.keys(changes)) {
+ const change = changes[propName];
+ if (!change.firstChange && change.currentValue !== change.previousValue) {
+ if (propName === 'maxTime') {
+ if (isDefinedAndNotNull(this.maxTime)) {
+ this.timeUnits = Object.values(TimeUnit).filter(item => item !== TimeUnit.MILLISECONDS) as TimeUnit[];
+ this.updatedAllowTimeUnitInterval(this.maxTime);
+ this.timeInputForm.get('time').updateValueAndValidity({emitEvent: false});
+ }
+ }
+ }
+ }
+ }
+
registerOnChange(fn: any) {
this.propagateChange = fn;
}
@@ -279,4 +287,16 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator,
};
}
+ private updatedAllowTimeUnitInterval(maxTime: number) {
+ const maxTimeMs = maxTime * SECOND;
+ this.timeUnits = Object.values(TimeUnit).filter(item => item !== TimeUnit.MILLISECONDS) as TimeUnit[];
+ if (maxTimeMs < MINUTE) {
+ this.timeUnits = this.timeUnits.filter(item => item !== TimeUnit.MINUTES && item !== TimeUnit.HOURS && item !== TimeUnit.DAYS);
+ } else if (maxTimeMs < HOUR) {
+ this.timeUnits = this.timeUnits.filter(item => item !== TimeUnit.HOURS && item !== TimeUnit.DAYS);
+ } else if (maxTimeMs < DAY) {
+ this.timeUnits = this.timeUnits.filter(item => item !== TimeUnit.DAYS);
+ }
+ }
+
}
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 9d3c2b74eb..1c1f8e7f76 100644
--- a/ui-ngx/src/assets/locale/locale.constant-en_US.json
+++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json
@@ -1210,6 +1210,8 @@
"year": "Year",
"custom": "Custom"
},
+ "aggregate-period-hint-offset": "Your aggregation interval will be: {{ interval }}",
+ "aggregate-period-hint-offset-and-so-on": "Your aggregation interval will be: {{ interval }} and so on",
"entity-aggregation": {
"argument-hint": "Data will be fetched from selected entity",
"argument-setting-hint": "Latest telemetry is the only available argument type for this calculated field",
@@ -1220,6 +1222,7 @@
"offset-value": "Offset value",
"offset-value-required": "Offset value is required",
"offset-value-min": "Offset value must be a positive integer",
+ "offset-value-max": "Offset value should be less than the aggregate interval value",
"wait-delay": "Wait for delayed telemetry",
"wait-delay-hint": "Waits for delayed telemetry after the interval ends",
"duration": "Duration",