UI: Add persistence settings in save ts rule node

This commit is contained in:
Vladyslav_Prykhodko 2025-01-22 15:43:59 +02:00
parent b898ca4d15
commit 2d1ead5f54
10 changed files with 511 additions and 10 deletions

View File

@ -42,6 +42,12 @@ import { DeleteAttributesConfigComponent } from './delete-attributes-config.comp
import { MathFunctionConfigComponent } from './math-function-config.component'; import { MathFunctionConfigComponent } from './math-function-config.component';
import { DeviceStateConfigComponent } from './device-state-config.component'; import { DeviceStateConfigComponent } from './device-state-config.component';
import { SendRestApiCallReplyConfigComponent } from './send-rest-api-call-reply-config.component'; import { SendRestApiCallReplyConfigComponent } from './send-rest-api-call-reply-config.component';
import {
AdvancedPersistenceSettingComponent
} from '@home/components/rule-node/action/advanced-persistence-setting.component';
import {
AdvancedPersistenceSettingRowComponent
} from '@home/components/rule-node/action/advanced-persistence-setting-row.component';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -67,7 +73,9 @@ import { SendRestApiCallReplyConfigComponent } from './send-rest-api-call-reply-
PushToEdgeConfigComponent, PushToEdgeConfigComponent,
PushToCloudConfigComponent, PushToCloudConfigComponent,
MathFunctionConfigComponent, MathFunctionConfigComponent,
DeviceStateConfigComponent DeviceStateConfigComponent,
AdvancedPersistenceSettingComponent,
AdvancedPersistenceSettingRowComponent,
], ],
imports: [ imports: [
CommonModule, CommonModule,

View File

@ -0,0 +1,39 @@
<!--
Copyright © 2016-2024 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<section [formGroup]="persistenceSettingRowForm" class="tb-form-panel stroked no-gap no-padding-bottom">
<div class="tb-form-panel-title mb-4">{{ title }}</div>
<mat-form-field>
<mat-label translate>rule-node-config.save-time-series.strategy</mat-label>
<mat-select formControlName="type">
@for (strategy of persistenceStrategies; track strategy) {
<mat-option [value]="strategy">{{ PersistenceTypeTranslationMap.get(strategy) | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
@if(persistenceSettingRowForm.get('type').value === PersistenceType.DEDUPLICATE) {
<tb-time-unit-input
required
labelText="{{ 'rule-node-config.save-time-series.deduplication-interval' | translate }}"
requiredText="{{ 'rule-node-config.save-time-series.deduplication-interval-required' | translate }}"
minErrorText="{{ 'rule-node-config.save-time-series.deduplication-interval-min-max-range' | translate }}"
maxErrorText="{{ 'rule-node-config.save-time-series.deduplication-interval-min-max-range' | translate }}"
[maxTime]="maxDeduplicateTime"
formControlName="deduplicationIntervalSecs">
</tb-time-unit-input>
}
</section>

View File

@ -0,0 +1,114 @@
///
/// 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 } from '@angular/core';
import {
ControlValueAccessor,
FormBuilder,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator
} from '@angular/forms';
import {
AdvancedPersistenceConfig,
defaultAdvancedPersistenceConfig,
maxDeduplicateTimeSecs,
PersistenceType,
PersistenceTypeTranslationMap
} from '@home/components/rule-node/action/timeseries-config.models';
import { isDefinedAndNotNull } from '@core/utils';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({
selector: 'tb-advanced-persistence-setting-row',
templateUrl: './advanced-persistence-setting-row.component.html',
providers: [{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => AdvancedPersistenceSettingRowComponent),
multi: true
},{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => AdvancedPersistenceSettingRowComponent),
multi: true
}]
})
export class AdvancedPersistenceSettingRowComponent implements ControlValueAccessor, Validator {
@Input()
title: string;
persistenceSettingRowForm = this.fb.group({
type: [defaultAdvancedPersistenceConfig.type],
deduplicationIntervalSecs: [{value: 60, disabled: true}]
});
PersistenceType = PersistenceType;
persistenceStrategies = [PersistenceType.ON_EVERY_MESSAGE, PersistenceType.DEDUPLICATE, PersistenceType.SKIP];
PersistenceTypeTranslationMap = PersistenceTypeTranslationMap;
maxDeduplicateTime = maxDeduplicateTimeSecs;
private propagateChange: (value: any) => void = () => {};
constructor(private fb: FormBuilder) {
this.persistenceSettingRowForm.get('type').valueChanges.pipe(
takeUntilDestroyed()
).subscribe(() => this.updatedValidation());
this.persistenceSettingRowForm.valueChanges.pipe(
takeUntilDestroyed()
).subscribe((value) => this.propagateChange(value));
}
registerOnChange(fn: any) {
this.propagateChange = fn;
}
registerOnTouched(_fn: any) {
}
setDisabledState(isDisabled: boolean) {
if (isDisabled) {
this.persistenceSettingRowForm.disable({emitEvent: false});
} else {
this.persistenceSettingRowForm.enable({emitEvent: false});
this.updatedValidation();
}
}
validate(): ValidationErrors | null {
return this.persistenceSettingRowForm.valid ? null : {
persistenceSettingRow: false
};
}
writeValue(value: AdvancedPersistenceConfig) {
if (isDefinedAndNotNull(value)) {
this.persistenceSettingRowForm.patchValue(value, {emitEvent: false});
} else {
this.persistenceSettingRowForm.patchValue(defaultAdvancedPersistenceConfig);
}
}
private updatedValidation() {
if (this.persistenceSettingRowForm.get('type').value === PersistenceType.DEDUPLICATE) {
this.persistenceSettingRowForm.get('deduplicationIntervalSecs').enable({emitEvent: false});
} else {
this.persistenceSettingRowForm.get('deduplicationIntervalSecs').disable({emitEvent: false})
}
}
}

View File

@ -0,0 +1,31 @@
<!--
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.
-->
<section [formGroup]="persistenceForm" class="tb-form-panel no-border no-padding">
<tb-advanced-persistence-setting-row
formControlName="timeseries"
title="{{ 'rule-node-config.save-time-series.time-series' | translate }}"
></tb-advanced-persistence-setting-row>
<tb-advanced-persistence-setting-row
formControlName="latest"
title="{{ 'rule-node-config.save-time-series.latest' | translate }}"
></tb-advanced-persistence-setting-row>
<tb-advanced-persistence-setting-row
formControlName="webSockets"
title="{{ 'rule-node-config.save-time-series.web-sockets' | translate }}"
></tb-advanced-persistence-setting-row>
</section>

View File

@ -0,0 +1,83 @@
///
/// 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 {
ControlValueAccessor,
FormBuilder,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator
} from '@angular/forms';
import { Component, forwardRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { AdvancedPersistenceStrategy } from '@home/components/rule-node/action/timeseries-config.models';
@Component({
selector: 'tb-advanced-persistence-settings',
templateUrl: './advanced-persistence-setting.component.html',
providers: [{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => AdvancedPersistenceSettingComponent),
multi: true
},{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => AdvancedPersistenceSettingComponent),
multi: true
}]
})
export class AdvancedPersistenceSettingComponent implements ControlValueAccessor, Validator {
persistenceForm = this.fb.group({
timeseries: [null],
latest: [null],
webSockets: [null]
});
private propagateChange: (value: any) => void = () => {};
constructor(private fb: FormBuilder) {
this.persistenceForm.valueChanges.pipe(
takeUntilDestroyed()
).subscribe(value => this.propagateChange(value));
}
registerOnChange(fn: any) {
this.propagateChange = fn;
}
registerOnTouched(_fn: any) {
}
setDisabledState(isDisabled: boolean) {
if (isDisabled) {
this.persistenceForm.disable({emitEvent: false});
} else {
this.persistenceForm.enable({emitEvent: false});
}
}
validate(): ValidationErrors | null {
return this.persistenceForm.valid ? null : {
persistenceForm: false
};
}
writeValue(value: AdvancedPersistenceStrategy) {
this.persistenceForm.patchValue(value, {emitEvent: false});
}
}

View File

@ -16,6 +16,46 @@
--> -->
<section [formGroup]="timeseriesConfigForm" class="tb-form-panel no-border no-padding"> <section [formGroup]="timeseriesConfigForm" class="tb-form-panel no-border no-padding">
<div class="tb-form-panel stroked no-padding-bottom no-gap" formGroupName="persistenceSettings">
<div class="mb-4 flex flex-row items-center justify-between">
<div class="tb-form-panel-title" tb-hint-tooltip-icon="{{ 'rule-node-config.save-time-series.persistence-settings-hint' | translate}}" translate>
rule-node-config.save-time-series.persistence-settings
</div>
<tb-toggle-select appearance="fill" selectMediaBreakpoint="xs"
formControlName="isAdvanced">
<tb-toggle-option [value]=false>{{ 'rule-node-config.basic-mode' | translate}}</tb-toggle-option>
<tb-toggle-option [value]=true>{{ 'rule-node-config.advanced-mode' | translate }}</tb-toggle-option>
</tb-toggle-select>
</div>
@if(!timeseriesConfigForm.get('persistenceSettings.isAdvanced').value) {
<mat-form-field>
<mat-label translate>rule-node-config.save-time-series.strategy</mat-label>
<mat-select formControlName="type">
@for (strategy of persistenceStrategies; track strategy) {
<mat-option [value]="strategy">{{ PersistenceTypeTranslationMap.get(strategy) | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
@if(timeseriesConfigForm.get('persistenceSettings.type').value === PersistenceType.DEDUPLICATE) {
<tb-time-unit-input
required
labelText="{{ 'rule-node-config.save-time-series.deduplication-interval' | translate }}"
requiredText="{{ 'rule-node-config.save-time-series.deduplication-interval-required' | translate }}"
minErrorText="{{ 'rule-node-config.save-time-series.deduplication-interval-min-max-range' | translate }}"
maxErrorText="{{ 'rule-node-config.save-time-series.deduplication-interval-min-max-range' | translate }}"
[maxTime]="maxDeduplicateTime"
formControlName="deduplicationIntervalSecs">
</tb-time-unit-input>
}
}
@else {
<tb-advanced-persistence-settings
class="mb-4"
formControlName="advanced"
></tb-advanced-persistence-settings>
}
</div>
<section class="tb-form-panel stroked"> <section class="tb-form-panel stroked">
<mat-expansion-panel class="tb-settings"> <mat-expansion-panel class="tb-settings">
<mat-expansion-panel-header> <mat-expansion-panel-header>

View File

@ -15,8 +15,18 @@
/// ///
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@shared/models/rule-node.models'; import { RuleNodeConfigurationComponent } from '@shared/models/rule-node.models';
import {
defaultAdvancedPersistenceStrategy,
maxDeduplicateTimeSecs,
PersistenceSettings,
PersistenceSettingsForm,
PersistenceType,
PersistenceTypeTranslationMap,
TimeseriesNodeConfiguration,
TimeseriesNodeConfigurationForm
} from '@home/components/rule-node/action/timeseries-config.models';
@Component({ @Component({
selector: 'tb-action-node-timeseries-config', selector: 'tb-action-node-timeseries-config',
@ -25,20 +35,98 @@ import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@shared/m
}) })
export class TimeseriesConfigComponent extends RuleNodeConfigurationComponent { export class TimeseriesConfigComponent extends RuleNodeConfigurationComponent {
timeseriesConfigForm: UntypedFormGroup; timeseriesConfigForm: FormGroup;
constructor(private fb: UntypedFormBuilder) { PersistenceType = PersistenceType;
persistenceStrategies = [PersistenceType.ON_EVERY_MESSAGE, PersistenceType.DEDUPLICATE, PersistenceType.WEBSOCKETS_ONLY];
PersistenceTypeTranslationMap = PersistenceTypeTranslationMap;
maxDeduplicateTime = maxDeduplicateTimeSecs
constructor(private fb: FormBuilder) {
super(); super();
} }
protected configForm(): UntypedFormGroup { protected configForm(): FormGroup {
return this.timeseriesConfigForm; return this.timeseriesConfigForm;
} }
protected onConfigurationSet(configuration: RuleNodeConfiguration) { protected validatorTriggers(): string[] {
return ['persistenceSettings.isAdvanced', 'persistenceSettings.type'];
}
protected prepareInputConfig(config: TimeseriesNodeConfiguration): TimeseriesNodeConfigurationForm {
let persistenceSettings: PersistenceSettingsForm;
if (config?.persistenceSettings) {
const isAdvanced = config?.persistenceSettings?.type === PersistenceType.ADVANCED;
persistenceSettings = {
...config.persistenceSettings,
isAdvanced: isAdvanced,
type: isAdvanced ? PersistenceType.ON_EVERY_MESSAGE : config.persistenceSettings.type,
advanced: isAdvanced ? config.persistenceSettings : defaultAdvancedPersistenceStrategy
}
} else {
persistenceSettings = {
type: PersistenceType.ON_EVERY_MESSAGE,
isAdvanced: false,
deduplicationIntervalSecs: 10,
advanced: defaultAdvancedPersistenceStrategy
};
}
return {
...config,
persistenceSettings: persistenceSettings
}
}
protected prepareOutputConfig(config: TimeseriesNodeConfigurationForm): TimeseriesNodeConfiguration {
let persistenceSettings: PersistenceSettings;
if (config.persistenceSettings.isAdvanced) {
persistenceSettings = {
...config.persistenceSettings.advanced,
type: PersistenceType.ADVANCED
};
} else {
persistenceSettings = {
type: config.persistenceSettings.type,
deduplicationIntervalSecs: config.persistenceSettings?.deduplicationIntervalSecs
};
}
return {
...config,
persistenceSettings
};
}
protected onConfigurationSet(config: TimeseriesNodeConfigurationForm) {
this.timeseriesConfigForm = this.fb.group({ this.timeseriesConfigForm = this.fb.group({
defaultTTL: [configuration ? configuration.defaultTTL : null, [Validators.required, Validators.min(0)]], persistenceSettings: this.fb.group({
useServerTs: [configuration ? configuration.useServerTs : false, []] isAdvanced: [config?.persistenceSettings?.isAdvanced ?? false],
type: [config?.persistenceSettings?.type ?? PersistenceType.ON_EVERY_MESSAGE],
deduplicationIntervalSecs: [
{value: config?.persistenceSettings?.deduplicationIntervalSecs ?? 10, disabled: true},
[Validators.required, Validators.max(maxDeduplicateTimeSecs)]
],
advanced: [{value: null, disabled: true}]
}),
defaultTTL: [config?.defaultTTL ?? null, [Validators.required, Validators.min(0)]],
useServerTs: [config?.useServerTs ?? false]
}); });
} }
protected updateValidators(emitEvent: boolean, _trigger?: string) {
const persistenceForm = this.timeseriesConfigForm.get('persistenceSettings') as FormGroup;
const isAdvanced: boolean = persistenceForm.get('isAdvanced').value;
const type: PersistenceType = persistenceForm.get('type').value;
if (!isAdvanced && type === PersistenceType.DEDUPLICATE) {
persistenceForm.get('deduplicationIntervalSecs').enable({emitEvent});
} else {
persistenceForm.get('deduplicationIntervalSecs').disable({emitEvent});
}
if (isAdvanced) {
persistenceForm.get('advanced').enable({emitEvent});
} else {
persistenceForm.get('advanced').disable({emitEvent});
}
}
} }

View File

@ -0,0 +1,78 @@
///
/// 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 { DAY, SECOND } from '@shared/models/time/time.models';
export const maxDeduplicateTimeSecs = DAY / SECOND + 1;
export interface TimeseriesNodeConfiguration {
persistenceSettings: PersistenceSettings;
defaultTTL: number;
useServerTs: boolean;
}
export interface TimeseriesNodeConfigurationForm extends Omit<TimeseriesNodeConfiguration, 'persistenceSettings'> {
persistenceSettings: PersistenceSettingsForm
}
export type PersistenceSettings = BasicPersistenceSettings & Partial<DeduplicatePersistenceStrategy> & Partial<AdvancedPersistenceStrategy>;
export type PersistenceSettingsForm = Omit<PersistenceSettings, keyof AdvancedPersistenceStrategy> & {
isAdvanced: boolean;
advanced?: Partial<AdvancedPersistenceStrategy>;
type: PersistenceType;
};
export enum PersistenceType {
ON_EVERY_MESSAGE = 'ON_EVERY_MESSAGE',
DEDUPLICATE = 'DEDUPLICATE',
WEBSOCKETS_ONLY = 'WEBSOCKETS_ONLY',
ADVANCED = 'ADVANCED',
SKIP = 'SKIP'
}
export const PersistenceTypeTranslationMap = new Map<PersistenceType, string>([
[PersistenceType.ON_EVERY_MESSAGE, 'rule-node-config.save-time-series.strategy-type.every-message'],
[PersistenceType.DEDUPLICATE, 'rule-node-config.save-time-series.strategy-type.deduplicate'],
[PersistenceType.WEBSOCKETS_ONLY, 'rule-node-config.save-time-series.strategy-type.web-sockets-only'],
[PersistenceType.SKIP, 'rule-node-config.save-time-series.strategy-type.skip'],
])
export interface BasicPersistenceSettings {
type: PersistenceType;
}
export interface DeduplicatePersistenceStrategy extends BasicPersistenceSettings{
deduplicationIntervalSecs: number;
}
export interface AdvancedPersistenceStrategy extends BasicPersistenceSettings{
timeseries: AdvancedPersistenceConfig;
latest: AdvancedPersistenceConfig;
webSockets: AdvancedPersistenceConfig;
}
export type AdvancedPersistenceConfig = WithOptional<DeduplicatePersistenceStrategy, 'deduplicationIntervalSecs'>;
export const defaultAdvancedPersistenceConfig: AdvancedPersistenceConfig = {
type: PersistenceType.ON_EVERY_MESSAGE
}
export const defaultAdvancedPersistenceStrategy: Omit<AdvancedPersistenceStrategy, 'type'> = {
timeseries: defaultAdvancedPersistenceConfig,
latest: defaultAdvancedPersistenceConfig,
webSockets: defaultAdvancedPersistenceConfig,
}

View File

@ -16,12 +16,13 @@
--> -->
<section [formGroup]="timeInputForm" class="flex gap-4"> <section [formGroup]="timeInputForm" class="flex gap-4">
<mat-form-field class="max-w-66% flex-full"> <mat-form-field class="max-w-66% flex-full" subscriptSizing="dynamic">
<mat-label *ngIf="labelText">{{ labelText }}</mat-label> <mat-label *ngIf="labelText">{{ labelText }}</mat-label>
<input type="number" min="0" step="1" matInput formControlName="time"> <input type="number" min="0" step="1" matInput formControlName="time">
<div matSuffix> <div matSuffix>
<ng-content select="[matSuffix]"></ng-content> <ng-content select="[matSuffix]"></ng-content>
</div> </div>
<mat-hint></mat-hint>
<mat-error *ngIf="timeInputForm.get('time').hasError('required') && requiredText"> <mat-error *ngIf="timeInputForm.get('time').hasError('required') && requiredText">
{{ requiredText }} {{ requiredText }}
</mat-error> </mat-error>

View File

@ -5068,6 +5068,25 @@
"units": "Units", "units": "Units",
"tell-failure-aws-lambda": "Tell Failure if AWS Lambda function execution raises exception", "tell-failure-aws-lambda": "Tell Failure if AWS Lambda function execution raises exception",
"tell-failure-aws-lambda-hint": "Rule node forces failure of message processing if AWS Lambda function execution raises exception.", "tell-failure-aws-lambda-hint": "Rule node forces failure of message processing if AWS Lambda function execution raises exception.",
"basic-mode": "Basic",
"advanced-mode": "Advanced",
"save-time-series": {
"persistence-settings": "Persistence settings",
"persistence-settings-hint": "Persistence settings hint",
"strategy": "Strategy",
"deduplication-interval": "Deduplication interval",
"deduplication-interval-required": "Deduplication interval is required",
"deduplication-interval-min-max-range": "Deduplication interval should be at least 1 second and at most 1 day",
"strategy-type": {
"every-message": "On every message",
"skip": "Skip",
"deduplicate": "Deduplicate",
"web-sockets-only": "WebSockets only"
},
"time-series": "Time series",
"latest": "Latest",
"web-sockets": "WebSockets"
},
"key-val": { "key-val": {
"key": "Key", "key": "Key",
"value": "Value", "value": "Value",