diff --git a/ui-ngx/src/app/core/http/calculated-fields.service.ts b/ui-ngx/src/app/core/http/calculated-fields.service.ts index 66c0cb609e..ed8f431434 100644 --- a/ui-ngx/src/app/core/http/calculated-fields.service.ts +++ b/ui-ngx/src/app/core/http/calculated-fields.service.ts @@ -19,7 +19,11 @@ import { defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; import { Observable } from 'rxjs'; import { HttpClient } from '@angular/common/http'; import { PageData } from '@shared/models/page/page-data'; -import { CalculatedField, CalculatedFieldTestScriptInputParams } from '@shared/models/calculated-field.models'; +import { + CalculatedField, + CalculatedFieldTestScriptInputParams, + CalculatedFieldType +} from '@shared/models/calculated-field.models'; import { PageLink } from '@shared/models/page/page-link'; import { EntityId } from '@shared/models/id/entity-id'; import { EntityTestScriptResult } from '@shared/models/entity.models'; @@ -46,9 +50,12 @@ export class CalculatedFieldsService { return this.http.delete(`/api/calculatedField/${calculatedFieldId}`, defaultHttpOptionsFromConfig(config)); } - public getCalculatedFields({ entityType, id }: EntityId, pageLink: PageLink, config?: RequestConfig): Observable> { - return this.http.get>(`/api/${entityType}/${id}/calculatedFields${pageLink.toQuery()}`, - defaultHttpOptionsFromConfig(config)); + public getCalculatedFields({ entityType, id }: EntityId, pageLink: PageLink, type?: CalculatedFieldType, config?: RequestConfig): Observable> { + let url = `/api/${entityType}/${id}/calculatedFields${pageLink.toQuery()}`; + if (type) { + url += `&type=${type}`; + } + return this.http.get>(url, defaultHttpOptionsFromConfig(config)); } public testScript(inputParams: CalculatedFieldTestScriptInputParams, config?: RequestConfig): Observable { diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-details-dialog.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-details-dialog.component.html new file mode 100644 index 0000000000..1caa4121c8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-details-dialog.component.html @@ -0,0 +1,54 @@ + +
+ +

{{ 'alarm-rule.edit-alarm-rule-additional-info' | translate }}

+ + +
+ + +
+
+
+ + + + +
+
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-details-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-details-dialog.component.ts new file mode 100644 index 0000000000..c413f0c1df --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-details-dialog.component.ts @@ -0,0 +1,82 @@ +/// +/// 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, Inject, OnInit, SkipSelf } from '@angular/core'; +import { ErrorStateMatcher } from '@angular/material/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormGroupDirective, NgForm, UntypedFormBuilder, UntypedFormControl, UntypedFormGroup } from '@angular/forms'; +import { Router } from '@angular/router'; +import { DialogComponent } from '@app/shared/components/dialog.component'; +import { TranslateService } from '@ngx-translate/core'; + +export interface AlarmRuleDetailsDialogData { + alarmDetails: string; + readonly: boolean; +} + +@Component({ + selector: 'tb-edit-alarm-details-dialog', + templateUrl: './alarm-rule-details-dialog.component.html', + providers: [{provide: ErrorStateMatcher, useExisting: AlarmRuleDetailsDialogComponent}], + styleUrls: [] +}) +export class AlarmRuleDetailsDialogComponent extends DialogComponent + implements OnInit, ErrorStateMatcher { + + alarmDetails = this.data.alarmDetails; + + editDetailsFormGroup: UntypedFormGroup; + + submitted = false; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: AlarmRuleDetailsDialogData, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher, + public dialogRef: MatDialogRef, + private fb: UntypedFormBuilder, + public translate: TranslateService) { + super(store, router, dialogRef); + + this.editDetailsFormGroup = this.fb.group({ + alarmDetails: [this.alarmDetails] + }); + if (this.data.readonly) { + this.editDetailsFormGroup.disable(); + } + } + + ngOnInit(): void { + } + + isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean { + const originalErrorState = this.errorStateMatcher.isErrorState(control, form); + const customErrorState = !!(control && control.invalid && this.submitted); + return originalErrorState || customErrorState; + } + + cancel(): void { + this.dialogRef.close(null); + } + + save(): void { + this.submitted = true; + this.alarmDetails = this.editDetailsFormGroup.get('alarmDetails').value; + this.dialogRef.close(this.alarmDetails); + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.html new file mode 100644 index 0000000000..93c0f02861 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.html @@ -0,0 +1,164 @@ + +
+ +

{{ 'alarm-rule.alarm-rule' | translate}}

+ +
+ +
+
+
+
+
{{ 'common.general' | translate }}
+
+ + {{ 'alarm-rule.alarm-type' | translate }} + + @if (fieldFormGroup.get('name').errors && fieldFormGroup.get('name').touched) { + + @if (fieldFormGroup.get('name').hasError('required')) { + {{ 'alarm-rule.alarm-type-required' | translate }} + } @else if (fieldFormGroup.get('name').hasError('pattern')) { + {{ 'alarm-rule.alarm-type-pattern' | translate }} + } @else if (fieldFormGroup.get('name').hasError('maxlength')) { + {{ 'alarm-rule.alarm-type-max-length' | translate }} + } + + } + + +
+
+ +
+
{{ 'calculated-fields.arguments' | translate }}
+ +
+
+
{{ 'alarm-rule.create-alarm-rules' | translate }}
+
+ + +
+
+
+
{{ 'alarm-rule.clear-alarm-rule' | translate }}
+
+
+ + +
+ +
+
+ alarm-rule.no-clear-alarm-rule +
+
+ +
+
+
+ + + {{ 'alarm-rule.advanced-settings' | translate }} + + +
+ + {{ 'alarm-rule.propagate-alarm' | translate }} + +
+ @if (configFormGroup.get('propagate').value) { + + alarm-rule.alarm-rule-relation-types-list + + + {{key}} + close + + + + + + } +
+ + {{ 'alarm-rule.propagate-alarm-to-owner' | translate }} + +
+
+ + {{ 'alarm-rule.propagate-alarm-to-tenant' | translate }} + +
+
+
+
+
+
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.scss b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.scss new file mode 100644 index 0000000000..bd07be89a2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.scss @@ -0,0 +1,70 @@ +/** + * 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. + */ + +.calculated-field-dialog-container { + width: 869px; + max-width: 100%; +} + +.clear-alarm-rule { + border: 1px solid rgba(0, 0, 0, 0.12); + border-left-width: 4px; + border-left-color: green; + border-radius: 4px; + padding: 8px; + min-width: 0; +} + +.button-icon { + color: rgba(0, 0, 0, 0.38); + min-width: 40px; +} + +.tbel-script-lang-chip { + line-height: 20px; + font-size: 14px; + font-weight: 500; + color: white; + border-radius: 100px; + width: 70px; + min-width: 70px; + display: flex; + justify-content: center; + margin-top: 2px; + margin-right: 4px; +} + +.tb-js-func { + .ace_tb { + &.ace_calculated-field { + &-ctx { + color: #C52F00; + } + &-args { + color: #185F2A; + } + &-key { + color: #c24c1a; + } + &-time-window, &-values, &-func, &-value, &-ts, &-latestTs { + color: #7214D0; + } + &-start-ts, &-end-ts { + color: #2CAA00; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.ts new file mode 100644 index 0000000000..4915ea26d3 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.ts @@ -0,0 +1,183 @@ +/// +/// 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, DestroyRef, Inject, ViewEncapsulation } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { CalculatedField, CalculatedFieldArgument, CalculatedFieldType } from '@shared/models/calculated-field.models'; +import { oneSpaceInsideRegex } from '@shared/models/regex.constants'; +import { EntityType } from '@shared/models/entity-type.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ScriptLanguage } from '@shared/models/rule-node.models'; +import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; +import { EntityId } from '@shared/models/id/entity-id'; +import { AdditionalDebugActionConfig } from '@home/components/entity/debug/entity-debug-settings.model'; +import { COMMA, ENTER, SEMICOLON } from "@angular/cdk/keycodes"; +import { MatChipInputEvent } from "@angular/material/chips"; +import { AlarmRule, AlarmRuleConditionType, AlarmRuleExpressionType } from "@shared/models/alarm-rule.models"; +import { deepTrim } from "@core/utils"; + +export interface AlarmRuleDialogData { + value?: CalculatedField; + buttonTitle: string; + entityId: EntityId; + tenantId: string; + entityName?: string; + ownerId: EntityId; + additionalDebugActionConfig: AdditionalDebugActionConfig<(calculatedField: CalculatedField) => void>; + isDirty?: boolean; +} + +@Component({ + selector: 'tb-alarm-rule-dialog', + templateUrl: './alarm-rule-dialog.component.html', + styleUrls: ['./alarm-rule-dialog.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class AlarmRuleDialogComponent extends DialogComponent { + + fieldFormGroup = this.fb.group({ + name: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]], + type: [CalculatedFieldType.ALARM], + debugSettings: [], + configuration: this.fb.group({ + arguments: this.fb.control({}), + propagate: [false], + propagateToOwner: [false], + propagateToTenant: [false], + propagateRelationTypes: [null], + createRules: [null], + clearRule: [null], + }), + }); + + additionalDebugActionConfig = this.data.value?.id ? { + ...this.data.additionalDebugActionConfig, + action: () => this.data.additionalDebugActionConfig.action({ id: this.data.value.id, ...this.fromGroupValue }), + } : null; + + readonly EntityType = EntityType; + readonly CalculatedFieldType = CalculatedFieldType; + readonly ScriptLanguage = ScriptLanguage; + + separatorKeysCodes = [ENTER, COMMA, SEMICOLON]; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: AlarmRuleDialogData, + protected dialogRef: MatDialogRef, + private calculatedFieldsService: CalculatedFieldsService, + private destroyRef: DestroyRef, + private fb: FormBuilder) { + super(store, router, dialogRef); + this.observeIsLoading(); + this.applyDialogData(); + } + + get configFormGroup(): FormGroup { + return this.fieldFormGroup.get('configuration') as FormGroup; + } + + get arguments(): Record { + return this.fieldFormGroup.get('configuration.arguments').value; + } + + public removeClearAlarmRule() { + this.configFormGroup.patchValue({clearRule: null}); + this.fieldFormGroup.markAsDirty(); + } + + public addClearAlarmRule() { + const clearAlarmRule: AlarmRule = { + condition: { + type: AlarmRuleConditionType.SIMPLE, + expression: { + type: AlarmRuleExpressionType.SIMPLE + } + } + }; + this.configFormGroup.patchValue({clearRule: clearAlarmRule}); + } + + removeRelationType(key: string): void { + const keys: string[] = this.configFormGroup.get('propagateRelationTypes').value; + const index = keys.indexOf(key); + if (index >= 0) { + keys.splice(index, 1); + this.configFormGroup.get('propagateRelationTypes').setValue(keys, {emitEvent: true}); + } + } + + addRelationType(event: MatChipInputEvent): void { + const input = event.chipInput.inputElement; + let value = event.value; + if ((value || '').trim()) { + value = value.trim(); + let keys: string[] = this.configFormGroup.get('propagateRelationTypes').value; + if (!keys || keys.indexOf(value) === -1) { + if (!keys) { + keys = []; + } + keys.push(value); + this.configFormGroup.get('propagateRelationTypes').setValue(keys, {emitEvent: true}); + } + } + if (input) { + input.value = ''; + } + } + + get fromGroupValue(): CalculatedField { + return deepTrim(this.fieldFormGroup.value as CalculatedField); + } + + cancel(): void { + this.dialogRef.close(null); + } + + add(): void { + if (this.fieldFormGroup.valid) { + const alarmRule = { entityId: this.data.entityId, ...(this.data.value ?? {}), ...this.fromGroupValue}; + alarmRule.configuration.type = CalculatedFieldType.ALARM; + + this.calculatedFieldsService.saveCalculatedField(alarmRule) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(calculatedField => this.dialogRef.close(calculatedField)); + } + } + + private applyDialogData(): void { + const { configuration = {}, type = CalculatedFieldType.ALARM, debugSettings = { failuresEnabled: true, allEnabled: true }, ...value } = this.data.value ?? {}; + this.fieldFormGroup.patchValue({ configuration, type, debugSettings, ...value }, {emitEvent: false}); + } + + private observeIsLoading(): void { + this.isLoading$.pipe(takeUntilDestroyed()).subscribe(loading => { + if (loading) { + this.fieldFormGroup.disable({emitEvent: false}); + } else { + this.fieldFormGroup.enable({emitEvent: false}); + if (this.data.isDirty) { + this.fieldFormGroup.markAsDirty(); + } + } + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule.module.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule.module.ts new file mode 100644 index 0000000000..8e96fdd841 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule.module.ts @@ -0,0 +1,94 @@ +/// +/// 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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { AlarmRuleDialogComponent } from "@home/components/alarm-rules/alarm-rule-dialog.component"; +import { CreateCfAlarmRulesComponent } from "@home/components/alarm-rules/create-cf-alarm-rules.component"; +import { CfAlarmRuleComponent } from "@home/components/alarm-rules/cf-alarm-rule.component"; +import { CfAlarmRuleConditionComponent } from "@home/components/alarm-rules/cf-alarm-rule-condition.component"; +import { + CfAlarmRuleConditionDialogComponent +} from "@home/components/alarm-rules/cf-alarm-rule-condition-dialog.component"; +import { CfAlarmScheduleComponent } from "@home/components/alarm-rules/cf-alarm-schedule.component"; +import { CfAlarmScheduleDialogComponent } from "@home/components/alarm-rules/cf-alarm-schedule-dialog.component"; +import { + EntityDebugSettingsButtonComponent +} from "@home/components/entity/debug/entity-debug-settings-button.component"; +import { AlarmRuleFilterTextComponent } from "@home/components/alarm-rules/filter/alarm-rule-filter-text.component"; +import { + CalculatedFieldArgumentsTableModule +} from "@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module"; +import { + AlarmRuleFilterPredicateListComponent +} from "@home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component"; +import { + AlarmRuleFilterPredicateComponent +} from "@home/components/alarm-rules/filter/alarm-rule-filter-predicate.component"; +import { + AlarmRuleFilterPredicateValueComponent +} from "@home/components/alarm-rules/filter/alarm-rule-filter-predicate-value.component"; +import { + AlarmRuleComplexFilterPredicateDialogComponent +} from "@home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component"; +import { AlarmRuleFilterListComponent } from "@home/components/alarm-rules/filter/alarm-rule-filter-list.component"; +import { AlarmRuleFilterDialogComponent } from "@home/components/alarm-rules/filter/alarm-rule-filter-dialog.component"; +import { AlarmRuleDetailsDialogComponent } from "@home/components/alarm-rules/alarm-rule-details-dialog.component"; + +@NgModule({ + declarations: [ + AlarmRuleDialogComponent, + CreateCfAlarmRulesComponent, + CfAlarmRuleComponent, + CfAlarmRuleConditionComponent, + CfAlarmRuleConditionDialogComponent, + CfAlarmScheduleComponent, + CfAlarmScheduleDialogComponent, + AlarmRuleFilterTextComponent, + AlarmRuleFilterListComponent, + AlarmRuleFilterDialogComponent, + AlarmRuleFilterPredicateListComponent, + AlarmRuleFilterPredicateComponent, + AlarmRuleFilterPredicateValueComponent, + AlarmRuleComplexFilterPredicateDialogComponent, + AlarmRuleDetailsDialogComponent, + ], + imports: [ + CommonModule, + SharedModule, + EntityDebugSettingsButtonComponent, + CalculatedFieldArgumentsTableModule + ], + exports: [ + AlarmRuleDialogComponent, + CreateCfAlarmRulesComponent, + CfAlarmRuleComponent, + CfAlarmRuleConditionComponent, + CfAlarmRuleConditionDialogComponent, + CfAlarmScheduleComponent, + CfAlarmScheduleDialogComponent, + AlarmRuleFilterTextComponent, + AlarmRuleFilterListComponent, + AlarmRuleFilterDialogComponent, + AlarmRuleFilterPredicateListComponent, + AlarmRuleFilterPredicateComponent, + AlarmRuleFilterPredicateValueComponent, + AlarmRuleComplexFilterPredicateDialogComponent, + AlarmRuleDetailsDialogComponent, + ] +}) +export class AlarmRuleModule { } diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts new file mode 100644 index 0000000000..9cde7ae393 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts @@ -0,0 +1,278 @@ +/// +/// 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 { + checkBoxCell, + DateEntityTableColumn, + EntityTableColumn, + EntityTableConfig +} from '@home/models/entity/entities-table-config.models'; +import { EntityType } from '@shared/models/entity-type.models'; +import { TranslateService } from '@ngx-translate/core'; +import { Direction } from '@shared/models/page/sort-order'; +import { MatDialog } from '@angular/material/dialog'; +import { PageLink } from '@shared/models/page/page-link'; +import { Observable, of } from 'rxjs'; +import { PageData } from '@shared/models/page/page-data'; +import { EntityId } from '@shared/models/id/entity-id'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { DestroyRef, Renderer2 } from '@angular/core'; +import { EntityDebugSettings } from '@shared/models/entity.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; +import { catchError, filter, switchMap } from 'rxjs/operators'; +import { + ArgumentEntityType, + CalculatedField, + CalculatedFieldAlarmRule, + CalculatedFieldType, +} from '@shared/models/calculated-field.models'; + +import { ImportExportService } from '@shared/import-export/import-export.service'; +import { EntityDebugSettingsService } from '@home/components/entity/debug/entity-debug-settings.service'; +import { DatePipe } from '@angular/common'; +import { + AlarmRuleDialogComponent, + AlarmRuleDialogData +} from "@home/components/alarm-rules/alarm-rule-dialog.component"; +import { + CalculatedFieldDebugDialogComponent, + CalculatedFieldDebugDialogData +} from "@home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component"; +import { AlarmSeverity, alarmSeverityTranslations } from "@shared/models/alarm.models"; + +export class AlarmRulesTableConfig extends EntityTableConfig { + + readonly tenantId = getCurrentAuthUser(this.store).tenantId; + additionalDebugActionConfig = { + title: this.translate.instant('calculated-fields.see-debug-events'), + action: (calculatedField: CalculatedField) => this.openDebugEventsDialog.call(this, calculatedField), + }; + + constructor(private calculatedFieldsService: CalculatedFieldsService, + private translate: TranslateService, + private dialog: MatDialog, + private datePipe: DatePipe, + public entityId: EntityId = null, + private store: Store, + private destroyRef: DestroyRef, + private renderer: Renderer2, + public entityName: string, + private ownerId: EntityId = null, + private importExportService: ImportExportService, + private entityDebugSettingsService: EntityDebugSettingsService, + ) { + super(); + this.tableTitle = this.translate.instant('alarm-rule.alarm-rules'); + this.detailsPanelEnabled = false; + this.pageMode = false; + this.entityType = EntityType.CALCULATED_FIELD; + this.entityTranslations = { + type: 'alarm-rule.alarm-rule', + typePlural: 'alarm-rule.alarm-rules', + list: 'alarm-rule.list', + add: 'action.add', + noEntities: 'alarm-rule.no-found', + search: 'action.search', + selectedEntities: 'alarm-rule.selected-fields' + }; + + this.entitiesFetchFunction = (pageLink: PageLink) => this.fetchCalculatedFields(pageLink); + this.addEntity = this.getCalculatedAlarmDialog.bind(this); + this.deleteEntityTitle = (field: CalculatedField) => this.translate.instant('alarm-rule.delete-title', {title: field.name}); + this.deleteEntityContent = () => this.translate.instant('alarm-rule.delete-text'); + this.deleteEntitiesTitle = count => this.translate.instant('alarm-rule.delete-multiple-title', {count}); + this.deleteEntitiesContent = () => this.translate.instant('alarm-rule.delete-multiple-text'); + this.deleteEntity = id => this.calculatedFieldsService.deleteCalculatedField(id.id); + this.addActionDescriptors = [ + { + name: this.translate.instant('alarm-rule.create'), + icon: 'insert_drive_file', + isEnabled: () => true, + onAction: ($event) => this.getTable().addEntity($event) + }, + { + name: this.translate.instant('alarm-rule.import'), + icon: 'file_upload', + isEnabled: () => true, + onAction: () => this.importCalculatedField() + } + ]; + + this.defaultSortOrder = {property: 'createdTime', direction: Direction.DESC}; + this.columns.push(new DateEntityTableColumn('createdTime', 'common.created-time', this.datePipe, '150px')); + this.columns.push(new EntityTableColumn('name', 'alarm-rule.alarm-type', '33%')); + this.columns.push(new EntityTableColumn('createRule', 'alarm-rule.severities', '67%', + entity => Object.keys(entity.configuration.createRules).map((severity) => this.translate.instant(alarmSeverityTranslations.get(severity as AlarmSeverity))).join(', '), + () => ({}), false)); + this.columns.push(new EntityTableColumn('clearRule', 'alarm-rule.cleared', '60px', + entity => checkBoxCell(!!entity.configuration.clearRule), ()=> { return {padding: '0 14px'}}, false)); + + this.cellActionDescriptors.push( + { + name: this.translate.instant('action.export'), + icon: 'file_download', + isEnabled: () => true, + onAction: (event$, entity) => this.exportCalculatedField(event$, entity), + }, + { + name: this.translate.instant('entity-view.events'), + icon: 'mdi:clipboard-text-clock', + isEnabled: () => true, + onAction: (_, entity) => this.openDebugEventsDialog(entity), + }, + { + name: '', + nameFunction: entity => this.entityDebugSettingsService.getDebugConfigLabel(entity?.debugSettings), + icon: 'mdi:bug', + isEnabled: () => true, + iconFunction: ({ debugSettings }) => this.entityDebugSettingsService.isDebugActive(debugSettings?.allEnabledUntil) || debugSettings?.failuresEnabled ? 'mdi:bug' : 'mdi:bug-outline', + onAction: ($event, entity) => this.onOpenDebugConfig($event, entity), + }, + { + name: this.translate.instant('action.edit'), + icon: 'edit', + isEnabled: () => true, + onAction: (_, entity) => this.editCalculatedField(entity), + } + ); + } + + fetchCalculatedFields(pageLink: PageLink): Observable> { + return this.calculatedFieldsService.getCalculatedFields(this.entityId, pageLink, CalculatedFieldType.ALARM); + } + + onOpenDebugConfig($event: Event, calculatedField: CalculatedField): void { + const { debugSettings = {}, id } = calculatedField; + const additionalActionConfig = { + ...this.additionalDebugActionConfig, + action: () => this.openDebugEventsDialog(calculatedField) + }; + if ($event) { + $event.stopPropagation(); + } + + const { viewContainerRef, renderer } = this.entityDebugSettingsService; + if (!viewContainerRef || !renderer) { + this.entityDebugSettingsService.viewContainerRef = this.getTable().viewContainerRef; + this.entityDebugSettingsService.renderer = this.renderer; + } + + this.entityDebugSettingsService.openDebugStrategyPanel({ + debugSettings, + debugConfig: { + entityType: EntityType.CALCULATED_FIELD, + additionalActionConfig, + }, + onSettingsAppliedFn: settings => this.onDebugConfigChanged(id.id, settings) + }, $event.target as Element); + } + + private editCalculatedField(calculatedField: CalculatedField, isDirty = false): void { + this.getCalculatedAlarmDialog(calculatedField, 'action.apply', isDirty) + .subscribe((res) => { + if (res) { + this.updateData(); + } + }); + } + + private getCalculatedAlarmDialog(value?: CalculatedField, buttonTitle = 'action.add', isDirty = false): Observable { + return this.dialog.open(AlarmRuleDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + value, + buttonTitle, + entityId: this.entityId, + tenantId: this.tenantId, + entityName: this.entityName, + ownerId: this.ownerId, + additionalDebugActionConfig: this.additionalDebugActionConfig, + isDirty, + }, + enterAnimationDuration: isDirty ? 0 : null, + }) + .afterClosed() + .pipe(filter(Boolean)); + } + + private openDebugEventsDialog(calculatedField: CalculatedField): void { + this.dialog.open(CalculatedFieldDebugDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + tenantId: this.tenantId, + value: calculatedField, + getTestScriptDialogFn: null, + } + }) + .afterClosed() + .subscribe(); + } + + private exportCalculatedField($event: Event, calculatedField: CalculatedField): void { + if ($event) { + $event.stopPropagation(); + } + this.importExportService.exportCalculatedField(calculatedField.id.id); + } + + private importCalculatedField(): void { + this.importExportService.openCalculatedFieldImportDialog() + .pipe( + filter(Boolean), + switchMap(calculatedField => this.getCalculatedAlarmDialog(this.updateImportedCalculatedField(calculatedField), 'action.add', true)), + filter(Boolean), + switchMap(calculatedField => this.calculatedFieldsService.saveCalculatedField(calculatedField)), + filter(Boolean), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => this.updateData()); + } + + private updateImportedCalculatedField(calculatedField: CalculatedField): CalculatedField { + if (calculatedField.type === CalculatedFieldType.GEOFENCING) { + calculatedField.configuration.zoneGroups = Object.keys(calculatedField.configuration.zoneGroups).reduce((acc, key) => { + const arg = calculatedField.configuration.zoneGroups[key]; + acc[key] = arg.refEntityId?.entityType === ArgumentEntityType.Tenant + ? { ...arg, refEntityId: { id: this.tenantId, entityType: ArgumentEntityType.Tenant } } + : arg; + return acc; + }, {}); + } else { + calculatedField.configuration.arguments = Object.keys(calculatedField.configuration.arguments).reduce((acc, key) => { + const arg = calculatedField.configuration.arguments[key]; + acc[key] = arg.refEntityId?.entityType === ArgumentEntityType.Tenant + ? { ...arg, refEntityId: { id: this.tenantId, entityType: ArgumentEntityType.Tenant } } + : arg; + return acc; + }, {}); + } + + return calculatedField; + } + + private onDebugConfigChanged(id: string, debugSettings: EntityDebugSettings): void { + this.calculatedFieldsService.getCalculatedFieldById(id).pipe( + switchMap(field => this.calculatedFieldsService.saveCalculatedField({ ...field, debugSettings })), + catchError(() => of(null)), + takeUntilDestroyed(this.destroyRef), + ).subscribe(() => this.updateData()); + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.html new file mode 100644 index 0000000000..df433bc70e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.html @@ -0,0 +1,20 @@ + +@if (calculatedFieldsTableConfig) { + +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.scss b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.scss new file mode 100644 index 0000000000..3feb1e7429 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.scss @@ -0,0 +1,22 @@ +/** + * 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 ::ng-deep { + tb-entities-table { + .mat-drawer-container { + background-color: white; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.ts new file mode 100644 index 0000000000..1cf2feaa8d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.ts @@ -0,0 +1,88 @@ +/// +/// 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 { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef, + effect, + input, + Renderer2, + ViewChild, +} from '@angular/core'; +import { EntityId } from '@shared/models/id/entity-id'; +import { EntitiesTableComponent } from '@home/components/entity/entities-table.component'; +import { TranslateService } from '@ngx-translate/core'; +import { MatDialog } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; +import { ImportExportService } from '@shared/import-export/import-export.service'; +import { EntityDebugSettingsService } from '@home/components/entity/debug/entity-debug-settings.service'; +import { DatePipe } from '@angular/common'; +import { AlarmRulesTableConfig } from "@home/components/alarm-rules/alarm-rules-table-config"; + +@Component({ + selector: 'tb-alarm-rules-table', + templateUrl: './alarm-rules-table.component.html', + styleUrls: ['./alarm-rules-table.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [EntityDebugSettingsService] +}) +export class AlarmRulesTableComponent { + + @ViewChild(EntitiesTableComponent, {static: true}) entitiesTable: EntitiesTableComponent; + + active = input(); + entityId = input(); + entityName = input(); + ownerId = input(); + + calculatedFieldsTableConfig: AlarmRulesTableConfig; + + constructor(private calculatedFieldsService: CalculatedFieldsService, + private translate: TranslateService, + private dialog: MatDialog, + private store: Store, + private datePipe: DatePipe, + private cd: ChangeDetectorRef, + private renderer: Renderer2, + private importExportService: ImportExportService, + private entityDebugSettingsService: EntityDebugSettingsService, + private destroyRef: DestroyRef) { + + effect(() => { + if (this.active()) { + this.calculatedFieldsTableConfig = new AlarmRulesTableConfig( + this.calculatedFieldsService, + this.translate, + this.dialog, + this.datePipe, + this.entityId(), + this.store, + this.destroyRef, + this.renderer, + this.entityName(), + this.ownerId(), + this.importExportService, + this.entityDebugSettingsService, + ); + this.cd.markForCheck(); + } + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.html new file mode 100644 index 0000000000..05305e7f50 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.html @@ -0,0 +1,196 @@ + +
+ +

{{ (readonly ? 'alarm-rule.alarm-rule-condition' : 'alarm-rule.edit-alarm-rule-condition') | translate }}

+ +
+ + {{ 'alarm-rule.expression-type.simple' | translate }} + {{ 'alarm-rule.expression-type.tbel' | translate }} + +
+ +
+ + +
+
+
+
+ @if (conditionFormGroup.get('expression.type').value === AlarmRuleExpressionType.SIMPLE) { +
+
+
{{ 'alarm-rule.argument-filters' | translate }}
+ + {{ complexOperationTranslationMap.get(ComplexOperation.AND) | translate }} + {{ complexOperationTranslationMap.get(ComplexOperation.OR) | translate }} + +
+ + +
+ } @else { +
+
+ {{ 'alarm-rule.script' | translate }} +
+ +
{{ 'alarm-rule.expression-type.tbel' | translate }} +
+
+
+ } +
+
+
{{ 'alarm-rule.condition-settings' | translate }}
+ + alarm-rule.condition-type + + + {{ alarmConditionTypeTranslation.get(alarmConditionType) | translate }} + + + + @if (conditionFormGroup.get('type').value == AlarmConditionType.DURATION) { +
+
+
{{ 'alarm-rule.value' | translate }}
+ + {{ 'alarm-rule.static' | translate }} + {{ 'alarm-rule.dynamic' | translate }} + +
+
+
+ +
+
+ +
+
+ + + + {{ timeUnitTranslations.get(timeUnit) | translate }} + + + + {{ 'alarm-rule.condition-duration-time-unit-required' | translate }} + + +
+
+
+ } @else if (conditionFormGroup.get('type').value == AlarmConditionType.REPEATING) { +
+
+
{{ 'alarm-rule.value' | translate }}
+ + {{ 'alarm-rule.static' | translate }} + {{ 'alarm-rule.dynamic' | translate }} + +
+
+
+ +
+
+ +
+
+
+ } +
+
+
+
+
+ + +
+ + +
+ + + {{ defaultValuePlaceholder | translate }} + @if (conditionFormGroup.get(groupName).get('staticValue').hasError('required')) { + {{ defaultValueRequiredError | translate }} + } @else if (conditionFormGroup.get(groupName).get('staticValue').hasError('min')) { + {{ defaultValueRangeError | translate }} + } @else if (conditionFormGroup.get(groupName).get('staticValue').hasError('max')) { + {{ defaultValueRangeError | translate }} + } @else if (conditionFormGroup.get(groupName).get('staticValue').hasError('pattern')) { + {{ defaultValuePatternError | translate }} + } + +
+
+ + + + alarm-rule.value-argument + + @for (argument of argumentsList; track argument) { + {{ argument }} + } + + + {{ 'calculated-fields.hint.argument-name-required' | translate }} + + + + +
+ diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.ts new file mode 100644 index 0000000000..1b0b3ec07f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.ts @@ -0,0 +1,244 @@ +/// +/// 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, Inject, OnInit, SkipSelf } from '@angular/core'; +import { ErrorStateMatcher } from '@angular/material/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { + FormGroup, + FormGroupDirective, + NgForm, + UntypedFormBuilder, + UntypedFormControl, + Validators +} from '@angular/forms'; +import { Router } from '@angular/router'; +import { DialogComponent } from '@app/shared/components/dialog.component'; +import { TranslateService } from '@ngx-translate/core'; +import { TimeUnit, timeUnitTranslationMap } from '@shared/models/time/time.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ScriptLanguage } from "@shared/models/rule-node.models"; +import { + AlarmRuleCondition, + AlarmRuleConditionType, + AlarmRuleConditionTypeTranslationMap, + AlarmRuleExpressionType +} from "@shared/models/alarm-rule.models"; +import { + CalculatedFieldArgument, + getCalculatedFieldArgumentsEditorCompleter, + getCalculatedFieldArgumentsHighlights +} from "@shared/models/calculated-field.models"; +import { TbEditorCompleter } from "@shared/models/ace/completion.models"; +import { AceHighlightRules } from "@shared/models/ace/ace.models"; +import { ComplexOperation, complexOperationTranslationMap } from "@shared/models/query/query.models"; +import { FormControlsFrom } from "@shared/models/tenant.model"; + +export interface CfAlarmRuleConditionDialogData { + readonly: boolean; + condition: AlarmRuleCondition; + arguments?: Record; +} + +@Component({ + selector: 'tb-cf-alarm-rule-condition-dialog', + templateUrl: './cf-alarm-rule-condition-dialog.component.html', + providers: [{provide: ErrorStateMatcher, useExisting: CfAlarmRuleConditionDialogComponent}], + styleUrls: ['./cf-alarm-rules-dialog.component.scss'], +}) +export class CfAlarmRuleConditionDialogComponent extends DialogComponent + implements OnInit, ErrorStateMatcher { + + AlarmRuleExpressionType = AlarmRuleExpressionType; + + timeUnits = Object.values(TimeUnit); + timeUnitTranslations = timeUnitTranslationMap; + alarmConditionTypes = Object.values(AlarmRuleConditionType); + AlarmConditionType = AlarmRuleConditionType; + alarmConditionTypeTranslation = AlarmRuleConditionTypeTranslationMap; + readonly = this.data.readonly; + condition = this.data.condition; + + conditionFormGroup: FormGroup>; + + submitted = false; + + readonly scriptLanguage = ScriptLanguage; + + defaultValuePlaceholder = ''; + defaultValueRequiredError = ''; + defaultValueRangeError = ''; + defaultValuePatternError = ''; + + durationDynamicMode = !!this.condition?.value?.dynamicValueArgument; + repeatingDynamicMode = !!this.condition?.count?.dynamicValueArgument; + + ComplexOperation = ComplexOperation; + complexOperationTranslationMap = complexOperationTranslationMap; + + functionArgs: Array; + argumentsEditorCompleter: TbEditorCompleter; + argumentsHighlightRules: AceHighlightRules; + + arguments = this.data.arguments; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: CfAlarmRuleConditionDialogData, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher, + public dialogRef: MatDialogRef, + private fb: UntypedFormBuilder, + public translate: TranslateService) { + super(store, router, dialogRef); + + this.functionArgs = ['ctx', ...Object.keys(this.data.arguments)]; + this.argumentsEditorCompleter = getCalculatedFieldArgumentsEditorCompleter(this.data.arguments); + this.argumentsHighlightRules = getCalculatedFieldArgumentsHighlights(this.data.arguments); + + this.conditionFormGroup = this.fb.group({ + expression: this.fb.group({ + type: [this.condition?.expression?.type ?? AlarmRuleExpressionType.SIMPLE], + expression: [this.condition?.expression?.expression ?? null, [Validators.required]], + operation: [this.condition?.expression?.operation ?? ComplexOperation.AND], + filters: [this.condition?.expression?.filters], + }), + type: [this.condition?.type ?? AlarmRuleConditionType.SIMPLE, Validators.required], + unit: [this.condition?.unit ?? TimeUnit.SECONDS, Validators.required], + value: this.fb.group({ + staticValue: [this.condition?.value?.staticValue ?? null, [Validators.required, Validators.min(1), Validators.max(2147483647), Validators.pattern('[0-9]*')]], + dynamicValueArgument: [this.condition?.value?.dynamicValueArgument ?? null, Validators.required], + }), + count: this.fb.group({ + staticValue: [this.condition?.count?.staticValue ?? null, [Validators.required, Validators.min(1), Validators.max(2147483647), Validators.pattern('[0-9]*')]], + dynamicValueArgument: [this.condition?.count?.dynamicValueArgument ?? null, Validators.required], + }), + }); + + this.conditionFormGroup.get('type').valueChanges.pipe( + takeUntilDestroyed() + ).subscribe((type) => { + this.updateValidators(type, true); + }); + + this.conditionFormGroup.get('expression.type').valueChanges.pipe( + takeUntilDestroyed() + ).subscribe((type) => { + this.updateExpressionTypeValidator(type); + this.updateValidators(this.conditionFormGroup.get('type').value ?? AlarmRuleConditionType.SIMPLE); + }); + + this.updateValidators(this.conditionFormGroup.get('type').value ?? AlarmRuleConditionType.SIMPLE); + this.updateExpressionTypeValidator(this.condition?.expression?.type ?? 'SIMPLE'); + } + + ngOnInit(): void { + } + + toggleDynamicMode(type: AlarmRuleConditionType): void { + if (type === AlarmRuleConditionType.DURATION) { + this.durationDynamicMode = !this.durationDynamicMode; + this.updateStaticValueValidator(type, this.durationDynamicMode); + } else { + this.repeatingDynamicMode = !this.repeatingDynamicMode; + this.updateStaticValueValidator(type, this.repeatingDynamicMode); + } + } + + updateStaticValueValidator(type: AlarmRuleConditionType, dynamicValue: boolean) { + const control = type === AlarmRuleConditionType.DURATION ? 'value' : 'count'; + if (dynamicValue) { + this.conditionFormGroup.get(`${control}.staticValue`).disable({emitEvent: false}); + this.conditionFormGroup.get(`${control}.dynamicValueArgument`).enable({emitEvent: false}); + } else { + this.conditionFormGroup.get(`${control}.staticValue`).enable({emitEvent: false}); + this.conditionFormGroup.get(`${control}.dynamicValueArgument`).disable({emitEvent: false}); + } + this.conditionFormGroup.get(`${control}.staticValue`).updateValueAndValidity({emitEvent: false}) + this.conditionFormGroup.get(`${control}.dynamicValueArgument`).updateValueAndValidity({emitEvent: false}) + } + + updateExpressionTypeValidator(type: 'SIMPLE' | 'TBEL') { + if (type === 'SIMPLE') { + this.conditionFormGroup.get(`expression.expression`).disable(); + this.conditionFormGroup.get(`expression.filters`).enable(); + } else { + this.conditionFormGroup.get(`expression.expression`).enable(); + this.conditionFormGroup.get(`expression.filters`).disable(); + } + this.conditionFormGroup.get(`expression.expression`).updateValueAndValidity({emitEvent: false}); + this.conditionFormGroup.get(`expression.filters`).updateValueAndValidity({emitEvent: false}); + } + + isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean { + const originalErrorState = this.errorStateMatcher.isErrorState(control, form); + const customErrorState = !!(control && control.invalid && this.submitted); + return originalErrorState || customErrorState; + } + + private updateValidators(type: AlarmRuleConditionType, emitEvent = false) { + switch (type) { + case AlarmRuleConditionType.DURATION: + this.conditionFormGroup.get('unit').enable(); + this.conditionFormGroup.get('value').enable(); + this.conditionFormGroup.get('count').disable(); + + this.updateStaticValueValidator(type, this.durationDynamicMode); + + this.defaultValuePlaceholder = 'alarm-rule.condition-duration-value'; + this.defaultValueRequiredError = 'alarm-rule.condition-duration-value-required'; + this.defaultValueRangeError = 'alarm-rule.condition-duration-value-range'; + this.defaultValuePatternError = 'alarm-rule.condition-duration-value-pattern'; + break; + case AlarmRuleConditionType.REPEATING: + this.conditionFormGroup.get('count').enable(); + this.conditionFormGroup.get('value').disable(); + this.conditionFormGroup.get('unit').disable(); + + this.updateStaticValueValidator(type, this.repeatingDynamicMode); + + this.defaultValuePlaceholder = 'alarm-rule.condition-repeating-value'; + this.defaultValueRequiredError = 'alarm-rule.condition-repeating-value-required'; + this.defaultValueRangeError = 'alarm-rule.condition-repeating-value-range'; + this.defaultValuePatternError = 'alarm-rule.condition-repeating-value-pattern'; + break; + case AlarmRuleConditionType.SIMPLE: + this.conditionFormGroup.get('value').disable(); + this.conditionFormGroup.get('count').disable(); + this.conditionFormGroup.get('unit').disable(); + break; + } + this.conditionFormGroup.get('value').updateValueAndValidity({emitEvent}); + this.conditionFormGroup.get('count').updateValueAndValidity({emitEvent}); + this.conditionFormGroup.get('unit').updateValueAndValidity({emitEvent}); + } + + get argumentsList(): Array { + return this.arguments ? Object.keys(this.arguments): []; + } + + cancel(): void { + this.dialogRef.close(null); + } + + save(): void { + this.submitted = true; + this.condition = this.conditionFormGroup.value as AlarmRuleCondition; + this.dialogRef.close(this.condition); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.html new file mode 100644 index 0000000000..e17d8c8261 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.html @@ -0,0 +1,52 @@ + +
+
+
{{ 'alarm-rule.condition' | translate }}
+ +
+
+
{{ 'alarm-rule.schedule-title' | translate }}
+ +
+
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.scss b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.scss new file mode 100644 index 0000000000..ac77089857 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.scss @@ -0,0 +1,37 @@ +/** + * 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 { + display: flex; + flex: 1; + + .tb-alarm-rule-condition { + display: flex; + flex: 1; + &-button { + --mat-outlined-button-horizontal-padding: 3px 0px 12px; + display: block; + width: 100%; + } + &-label { + display: block; + text-align: start; + width: 100%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.ts new file mode 100644 index 0000000000..f8c338e25b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.ts @@ -0,0 +1,283 @@ +/// +/// 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 { ChangeDetectorRef, Component, forwardRef, Input, OnInit } from '@angular/core'; +import { + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormControl, + Validator, + Validators +} from '@angular/forms'; +import { MatDialog } from '@angular/material/dialog'; +import { deepClone, isDefinedAndNotNull } from '@core/utils'; +import { TranslateService } from '@ngx-translate/core'; +import { + dayOfWeekTranslations, + getAlarmScheduleRangeText, + utcTimestampToTimeOfDay +} from '@shared/models/device.models'; +import { TimeUnit } from '@shared/models/time/time.models'; +import { + CfAlarmRuleConditionDialogComponent, + CfAlarmRuleConditionDialogData +} from "@home/components/alarm-rules/cf-alarm-rule-condition-dialog.component"; +import { + AlarmRuleCondition, + AlarmRuleConditionType, + AlarmRuleSchedule, + AlarmRuleScheduleType +} from "@shared/models/alarm-rule.models"; +import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; +import { + AlarmRuleScheduleDialogData, + CfAlarmScheduleDialogComponent +} from "@home/components/alarm-rules/cf-alarm-schedule-dialog.component"; + +@Component({ + selector: 'tb-cf-alarm-rule-condition', + templateUrl: './cf-alarm-rule-condition.component.html', + styleUrls: ['./cf-alarm-rule-condition.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CfAlarmRuleConditionComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CfAlarmRuleConditionComponent), + multi: true, + } + ] +}) +export class CfAlarmRuleConditionComponent implements ControlValueAccessor, OnInit, Validator { + + @Input() + disabled: boolean; + + @Input() + arguments: Record; + + alarmRuleConditionFormGroup = this.fb.group({ + type: ['SIMPLE'], + expression: [null, Validators.required], + schedule: [null], + }); + + specText = ''; + + scheduleText = ''; + + private modelValue: AlarmRuleCondition; + + private propagateChange = (v: any) => { }; + + constructor(private dialog: MatDialog, + private fb: UntypedFormBuilder, + private cd: ChangeDetectorRef, + private translate: TranslateService) { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.alarmRuleConditionFormGroup.disable({emitEvent: false}); + } else { + this.alarmRuleConditionFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: AlarmRuleCondition): void { + this.modelValue = value; + this.updateConditionInfo(); + } + + public conditionSet() { + return this.modelValue && (this.modelValue.expression.expression || this.modelValue.expression.filters); + } + + public validate(c: UntypedFormControl) { + return this.conditionSet() ? null : { + alarmRuleCondition: { + valid: false, + }, + }; + } + + public openFilterDialog($event: Event) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(CfAlarmRuleConditionDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + readonly: this.disabled, + condition: this.disabled ? this.modelValue : deepClone(this.modelValue), + arguments: this.arguments + } + }).afterClosed().subscribe((result) => { + if (result) { + this.modelValue = {...this.modelValue, ...result}; + this.updateModel(); + this.updateSpecText(); + this.cd.detectChanges(); + } + }); + } + + private updateConditionInfo() { + this.alarmRuleConditionFormGroup.patchValue( + { + type: this.modelValue?.type, + expression: this.modelValue?.expression, + schedule: this.modelValue?.schedule, + }, {emitEvent: false} + ); + this.updateScheduleText(); + this.updateSpecText(); + } + + private updateSpecText() { + this.specText = ''; + if (this.modelValue && this.modelValue.type) { + const type = this.modelValue.type; + switch (type) { + case AlarmRuleConditionType.SIMPLE: + break; + case AlarmRuleConditionType.DURATION: + let duringText = ''; + switch (this.modelValue.unit) { + case TimeUnit.SECONDS: + duringText = this.translate.instant('timewindow.seconds', {seconds: this.modelValue.value.staticValue}); + break; + case TimeUnit.MINUTES: + duringText = this.translate.instant('timewindow.minutes', {minutes: this.modelValue.value.staticValue}); + break; + case TimeUnit.HOURS: + duringText = this.translate.instant('timewindow.hours', {hours: this.modelValue.value.staticValue}); + break; + case TimeUnit.DAYS: + duringText = this.translate.instant('timewindow.days', {days: this.modelValue.value.staticValue}); + break; + } + if (this.modelValue.value.dynamicValueArgument) { + this.specText = this.translate.instant('alarm-rule.condition-during-dynamic', { + attribute: `${this.modelValue.value.dynamicValueArgument}` + }); + } else { + this.specText = this.translate.instant('alarm-rule.condition-during', { + during: duringText + }); + } + break; + case AlarmRuleConditionType.REPEATING: + if (this.modelValue.count.dynamicValueArgument) { + this.specText = this.translate.instant('alarm-rule.condition-repeat-times-dynamic', { + attribute: `${this.modelValue.count.dynamicValueArgument}` + }); + } else { + this.specText = this.translate.instant('alarm-rule.condition-repeat-times', + {count: this.modelValue.count.staticValue}); + } + break; + } + } + } + + private updateModel() { + this.updateConditionInfo(); + this.propagateChange(this.modelValue); + } + + public openScheduleDialog($event: Event) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(CfAlarmScheduleDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + readonly: this.disabled, + alarmSchedule: this.disabled ? this.modelValue?.schedule : deepClone(this.modelValue?.schedule), + arguments: this.arguments + } + }).afterClosed().subscribe((result) => { + if (result) { + this.modelValue.schedule = result; + this.propagateChange(this.modelValue); + this.updateScheduleText(); + this.cd.detectChanges(); + } + }); + } + + private updateScheduleText() { + let schedule = this.modelValue?.schedule; + this.scheduleText = ''; + if (isDefinedAndNotNull(schedule)) { + if (schedule.dynamicValueArgument) { + this.scheduleText = this.translate.instant('alarm-rule.value-argument') + ': ' + schedule?.dynamicValueArgument + } else { + switch (schedule.staticValue.type) { + case AlarmRuleScheduleType.ANY_TIME: + this.scheduleText = this.translate.instant('alarm-rule.schedule.any-time'); + break; + case AlarmRuleScheduleType.SPECIFIC_TIME: + for (const day of schedule.staticValue.daysOfWeek) { + if (this.scheduleText.length) { + this.scheduleText += ', '; + } + this.scheduleText += this.translate.instant(dayOfWeekTranslations[day - 1]); + } + this.scheduleText += ' ' + getAlarmScheduleRangeText(utcTimestampToTimeOfDay(schedule.staticValue.startsOn), + utcTimestampToTimeOfDay(schedule.staticValue.endsOn)) + ''; + break; + case AlarmRuleScheduleType.CUSTOM: + for (const item of schedule.staticValue.items) { + if (item.enabled) { + if (this.scheduleText.length) { + this.scheduleText += ', '; + } + this.scheduleText += this.translate.instant(dayOfWeekTranslations[item.dayOfWeek - 1]); + this.scheduleText += ' ' + getAlarmScheduleRangeText(utcTimestampToTimeOfDay(item.startsOn), + utcTimestampToTimeOfDay(item.endsOn)) + ''; + } + } + break; + } + } + } + if (!this.scheduleText.length) { + this.scheduleText = this.translate.instant('alarm-rule.schedule.any-time'); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.html new file mode 100644 index 0000000000..e1f2f41bfd --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.html @@ -0,0 +1,50 @@ + +
+ + + @if (!disabled || alarmRuleFormGroup.get('dashboardId').value) { +
+
+ alarm-rule.alarm-rule-additional-info +
+ + + + +
+ } + @if (!disabled || alarmRuleFormGroup.get('dashboardId').value) { +
+
+ alarm-rule.alarm-rule-mobile-dashboard +
+ + +
+ } +
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.scss b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.scss new file mode 100644 index 0000000000..c4e7087261 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.scss @@ -0,0 +1,35 @@ +/** + * 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 { + min-width: 0; + .row { + margin-top: 1em; + } + .tb-alarm-rule-details, .tb-alarm-rule-dashboard { + padding: 4px; + &.title { + opacity: 0.7; + overflow: visible; + } + } + .tb-alarm-rule-details { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + cursor: pointer; + } +} + diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.ts new file mode 100644 index 0000000000..e652aa8f19 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.ts @@ -0,0 +1,153 @@ +/// +/// 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, DestroyRef, forwardRef, Input, OnInit } from '@angular/core'; +import { + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormControl, + Validator, + Validators +} from '@angular/forms'; +import { MatDialog } from '@angular/material/dialog'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { isDefinedAndNotNull } from '@core/utils'; +import { DashboardId } from '@shared/models/id/dashboard-id'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { AlarmRule } from "@shared/models/alarm-rule.models"; +import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; +import { + AlarmRuleDetailsDialogComponent, + AlarmRuleDetailsDialogData +} from "@home/components/alarm-rules/alarm-rule-details-dialog.component"; + +@Component({ + selector: 'tb-cf-alarm-rule', + templateUrl: './cf-alarm-rule.component.html', + styleUrls: ['./cf-alarm-rule.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CfAlarmRuleComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CfAlarmRuleComponent), + multi: true, + } + ] +}) +export class CfAlarmRuleComponent implements ControlValueAccessor, OnInit, Validator { + + @Input() + disabled: boolean; + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + @Input() + arguments: Record; + + private modelValue: AlarmRule; + + alarmRuleFormGroup= this.fb.group({ + condition: [null, [Validators.required]], + alarmDetails: [null], + dashboardId: [null] + }); + + private propagateChange = (v: any) => { }; + + constructor(private dialog: MatDialog, + private fb: UntypedFormBuilder, + private destroyRef: DestroyRef) { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + this.alarmRuleFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateModel(); + }); + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.alarmRuleFormGroup.disable({emitEvent: false}); + } else { + this.alarmRuleFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: AlarmRule): void { + this.modelValue = value; + const model = this.modelValue ? { + ...this.modelValue, + dashboardId: this.modelValue.dashboardId?.id + } : null; + this.alarmRuleFormGroup.patchValue(model, {emitEvent: false}); + } + + public openEditDetailsDialog($event: Event) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(AlarmRuleDetailsDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + alarmDetails: this.alarmRuleFormGroup.get('alarmDetails').value, + readonly: this.disabled + } + }).afterClosed().subscribe((alarmDetails) => { + if (isDefinedAndNotNull(alarmDetails)) { + this.alarmRuleFormGroup.patchValue({alarmDetails}); + } + }); + } + + public validate(c: UntypedFormControl) { + return (!this.required && !this.modelValue || this.alarmRuleFormGroup.valid) ? null : { + alarmRule: { + valid: false, + }, + }; + } + + private updateModel() { + const value = this.alarmRuleFormGroup.value; + this.modelValue = {...this.modelValue, ...value, dashboardId: value.dashboardId ? new DashboardId(value.dashboardId) : null}; + this.propagateChange(this.modelValue); + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rules-dialog.component.scss b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rules-dialog.component.scss new file mode 100644 index 0000000000..9fa5b01723 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rules-dialog.component.scss @@ -0,0 +1,24 @@ +/** + * 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 { + form { + width: 900px; + max-width: 100%; + display: grid; + grid-template-rows: min-content minmax(auto, 1fr) min-content; + } +} + diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule-dialog.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule-dialog.component.html new file mode 100644 index 0000000000..5cb97ea1cb --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule-dialog.component.html @@ -0,0 +1,54 @@ + +
+ +

{{ (readonly ? 'alarm-rule.schedule-title' : 'alarm-rule.edit-schedule') | translate }}

+ + + {{ 'alarm-rule.static-schedule' | translate }} + {{ 'alarm-rule.dynamic-schedule' | translate }} + + +
+ + +
+ + +
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule-dialog.component.ts new file mode 100644 index 0000000000..8298e4467a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule-dialog.component.ts @@ -0,0 +1,92 @@ +/// +/// 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, Inject, OnInit, SkipSelf } from '@angular/core'; +import { ErrorStateMatcher } from '@angular/material/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormGroupDirective, NgForm, UntypedFormBuilder, UntypedFormControl, UntypedFormGroup } from '@angular/forms'; +import { Router } from '@angular/router'; +import { DialogComponent } from '@app/shared/components/dialog.component'; +import { UtilsService } from '@core/services/utils.service'; +import { TranslateService } from '@ngx-translate/core'; +import { AlarmRuleSchedule } from "@shared/models/alarm-rule.models"; +import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; + +export interface AlarmRuleScheduleDialogData { + readonly: boolean; + alarmSchedule: AlarmRuleSchedule; + arguments: Record; +} + +@Component({ + selector: 'tb-cf-alarm-schedule-dialog', + templateUrl: './cf-alarm-schedule-dialog.component.html', + providers: [{provide: ErrorStateMatcher, useExisting: CfAlarmScheduleDialogComponent}], + styleUrls: ['./cf-alarm-rules-dialog.component.scss'], +}) +export class CfAlarmScheduleDialogComponent extends DialogComponent + implements OnInit, ErrorStateMatcher { + + readonly = this.data.readonly; + alarmSchedule = this.data.alarmSchedule; + arguments = this.data.arguments; + + alarmScheduleFormGroup: UntypedFormGroup; + + submitted = false; + + settingsMode: 'static' | 'dynamic' = 'static'; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: AlarmRuleScheduleDialogData, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher, + public dialogRef: MatDialogRef, + private fb: UntypedFormBuilder, + private utils: UtilsService, + public translate: TranslateService) { + super(store, router, dialogRef); + + this.alarmScheduleFormGroup = this.fb.group({ + alarmSchedule: [this.alarmSchedule] + }); + this.settingsMode = this.alarmSchedule?.dynamicValueArgument ? 'dynamic' : 'static'; + if (this.readonly) { + this.alarmScheduleFormGroup.disable({emitEvent: false}); + } + } + + ngOnInit(): void { + } + + isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean { + const originalErrorState = this.errorStateMatcher.isErrorState(control, form); + const customErrorState = !!(control && control.invalid && this.submitted); + return originalErrorState || customErrorState; + } + + cancel(): void { + this.dialogRef.close(null); + } + + save(): void { + this.submitted = true; + this.alarmSchedule = this.alarmScheduleFormGroup.get('alarmSchedule').value; + this.dialogRef.close(this.alarmSchedule); + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule.component.html new file mode 100644 index 0000000000..6824201178 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule.component.html @@ -0,0 +1,124 @@ + +
+ @if (settingsMode === 'static') { + + + + + {{ alarmScheduleTypeTranslate.get(alarmScheduleType) | translate }} + + + + {{ 'alarm-rule.schedule-type-required' | translate }} + + + + @if (alarmScheduleForm.get('staticValue.type').value !== alarmScheduleType.ANY_TIME) { +
+
+ + +
+ + + {{ dayOfWeekTranslationsArray[day] | translate }} + + + +
+
+ + alarm-rule.schedule-time-from + + + + + + alarm-rule.schedule-time-to + + + + +
+
+
+
+
+
+
+
+
+
+ + {{ dayOfWeekTranslationsArray[day] | translate }} + +
+ + alarm-rule.schedule-time-from + + + + + + alarm-rule.schedule-time-to + + + + +
+
+
+
+
+ +
+
+
+ } + } @else { +
+ + alarm-rule.value-argument + + @for (argument of argumentsList; track argument) { + {{ argument }} + } + + + {{ 'calculated-fields.hint.argument-name-required' | translate }} + + +
+
+
+ } +
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule.component.ts new file mode 100644 index 0000000000..e7bc30be6e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule.component.ts @@ -0,0 +1,325 @@ +/// +/// 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, DestroyRef, forwardRef, Input, OnInit } from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormArray, + UntypedFormBuilder, + UntypedFormGroup, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { + dayOfWeekTranslations, + getAlarmScheduleRangeText, + timeOfDayToUTCTimestamp, + utcTimestampToTimeOfDay +} from '@shared/models/device.models'; +import { isDefined } from '@core/utils'; +import { getDefaultTimezone } from '@shared/models/time/time.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + AlarmRuleSchedule, + AlarmRuleScheduleType, + AlarmRuleScheduleTypeTranslationMap +} from "@shared/models/alarm-rule.models"; +import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; +import { MatChipSelectionChange } from "@angular/material/chips"; + +@Component({ + selector: 'tb-cf-alarm-schedule', + templateUrl: './cf-alarm-schedule.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CfAlarmScheduleComponent), + multi: true + }, { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CfAlarmScheduleComponent), + multi: true + }] +}) +export class CfAlarmScheduleComponent implements ControlValueAccessor, Validator, OnInit { + @Input() + disabled: boolean; + + @Input() + arguments: Record; + + private settingsModeValue: 'static' | 'dynamic'; + get settingsMode(): 'static' | 'dynamic' { + return this.settingsModeValue; + } + @Input() + set settingsMode(value: 'static' | 'dynamic') { + if (value !== this.settingsModeValue && this.alarmScheduleForm) { + this.settingsModeValue = value; + this.updateModeValidators(value); + this.updateModel(); + } + } + + alarmScheduleForm = this.fb.group({ + staticValue: this.fb.group({ + type: [AlarmRuleScheduleType.ANY_TIME, Validators.required], + timezone: [null, Validators.required], + daysOfWeek: [null, Validators.required], + startsOn: [0, Validators.required], + endsOn: [0, Validators.required], + items: this.fb.array(Array.from({length: 7}, (value, i) => this.defaultItemsScheduler(i)), this.validateItems), + }), + dynamicValueArgument: [null, Validators.required] + }); + + alarmScheduleTypes = Object.keys(AlarmRuleScheduleType); + alarmScheduleType = AlarmRuleScheduleType; + alarmScheduleTypeTranslate = AlarmRuleScheduleTypeTranslationMap; + dayOfWeekTranslationsArray = dayOfWeekTranslations; + + allDays = Array(7).fill(0).map((x, i) => i); + + private modelValue: AlarmRuleSchedule; + + private defaultItems = Array.from({length: 7}, (value, i) => ({ + enabled: true, + dayOfWeek: i + 1 + })); + + private propagateChange = (v: any) => { }; + + constructor(private fb: UntypedFormBuilder, + private destroyRef: DestroyRef) { + } + + ngOnInit(): void { + this.alarmScheduleForm.get('staticValue.type').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe((type) => { + const defaultTimezone = getDefaultTimezone(); + this.alarmScheduleForm.get('staticValue').patchValue({type, items: this.defaultItems, timezone: defaultTimezone}, {emitEvent: false}); + this.alarmScheduleForm.get('dynamicValueArgument').patchValue(null, {emitEvent: false}); + this.updateValidators(type); + this.alarmScheduleForm.updateValueAndValidity(); + }); + this.alarmScheduleForm.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateModel(); + }); + + this.alarmScheduleForm.get('staticValue.items').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe((items) => { + items.forEach((item, index) => this.disabledSelectedTime(item.enabled, index, false)) + }); + } + + validateItems(control: AbstractControl): ValidationErrors | null { + const items: any[] = control.value; + if (!items || !items.length || !items.find(v => v.enabled === true)) { + return { + dayOfWeeks: true + }; + } + return null; + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.alarmScheduleForm.disable({emitEvent: false}); + } else { + this.updateModeValidators(this.settingsMode); + } + } + + writeValue(value: AlarmRuleSchedule): void { + if (value) { + this.modelValue = value; + if (this.modelValue.dynamicValueArgument) { + this.settingsModeValue = 'dynamic'; + this.alarmScheduleForm.get('dynamicValueArgument').patchValue(this.modelValue.dynamicValueArgument, {emitEvent: false}); + } else { + this.settingsModeValue = 'static'; + switch (this.modelValue.staticValue.type) { + case AlarmRuleScheduleType.SPECIFIC_TIME: + this.alarmScheduleForm.patchValue({ + staticValue: { + type: this.modelValue.staticValue.type, + timezone: this.modelValue.staticValue.timezone, + daysOfWeek: this.modelValue.staticValue.daysOfWeek, + startsOn: utcTimestampToTimeOfDay(this.modelValue.staticValue.startsOn), + endsOn: utcTimestampToTimeOfDay(this.modelValue.staticValue.endsOn), + }, + }, {emitEvent: false}); + break; + case AlarmRuleScheduleType.CUSTOM: + if (this.modelValue?.dynamicValueArgument) { + this.alarmScheduleForm.patchValue({ + staticValue: { + type: this.modelValue.staticValue.type, + }, + }, {emitEvent: false}); + } else if (this.modelValue.staticValue?.items) { + const alarmDays = []; + this.modelValue.staticValue.items + .sort((a, b) => a.dayOfWeek - b.dayOfWeek) + .forEach((item, index) => { + this.disabledSelectedTime(item.enabled, index); + alarmDays.push({ + enabled: item.enabled, + startsOn: utcTimestampToTimeOfDay(item.startsOn), + endsOn: utcTimestampToTimeOfDay(item.endsOn) + }); + }); + this.alarmScheduleForm.patchValue({ + staticValue: { + type: this.modelValue.staticValue.type, + timezone: this.modelValue.staticValue.timezone, + items: alarmDays, + }, + }, {emitEvent: false}); + } + break; + default: + this.alarmScheduleForm.patchValue(this.modelValue || undefined, {emitEvent: false}); + } + this.updateValidators(this.modelValue.staticValue.type); + } + this.updateModeValidators(this.settingsMode); + } + } + + validate(control: UntypedFormGroup): ValidationErrors | null { + return this.alarmScheduleForm.valid ? null : { + alarmScheduler: { + valid: false + } + }; + } + + private updateModeValidators(mode: 'static' | 'dynamic') { + if (mode === 'static') { + this.alarmScheduleForm.get('staticValue').enable({emitEvent: false}); + this.alarmScheduleForm.get('dynamicValueArgument').disable({emitEvent: false}); + this.updateValidators(this.alarmScheduleForm.get('staticValue.type').value); + } else { + this.alarmScheduleForm.get('staticValue').disable({emitEvent: false}); + this.alarmScheduleForm.get('dynamicValueArgument').enable({emitEvent: false}); + } + } + + private updateValidators(type: AlarmRuleScheduleType){ + switch (type){ + case AlarmRuleScheduleType.ANY_TIME: + this.alarmScheduleForm.get('staticValue.timezone').disable({emitEvent: false}); + this.alarmScheduleForm.get('staticValue.daysOfWeek').disable({emitEvent: false}); + this.alarmScheduleForm.get('staticValue.startsOn').disable({emitEvent: false}); + this.alarmScheduleForm.get('staticValue.endsOn').disable({emitEvent: false}); + this.alarmScheduleForm.get('staticValue.items').disable({emitEvent: false}); + break; + case AlarmRuleScheduleType.SPECIFIC_TIME: + this.alarmScheduleForm.get('staticValue.timezone').enable({emitEvent: false}); + this.alarmScheduleForm.get('staticValue.daysOfWeek').enable({emitEvent: false}); + this.alarmScheduleForm.get('staticValue.startsOn').enable({emitEvent: false}); + this.alarmScheduleForm.get('staticValue.endsOn').enable({emitEvent: false}); + this.alarmScheduleForm.get('staticValue.items').disable({emitEvent: false}); + break; + case AlarmRuleScheduleType.CUSTOM: + this.alarmScheduleForm.get('staticValue.timezone').enable({emitEvent: false}); + this.alarmScheduleForm.get('staticValue.daysOfWeek').disable({emitEvent: false}); + this.alarmScheduleForm.get('staticValue.startsOn').disable({emitEvent: false}); + this.alarmScheduleForm.get('staticValue.endsOn').disable({emitEvent: false}); + this.alarmScheduleForm.get('staticValue.items').enable({emitEvent: true}); + break; + } + } + + private updateModel() { + const value = this.alarmScheduleForm.value as AlarmRuleSchedule; + if (this.settingsMode === 'static') { + if (isDefined(value.staticValue.startsOn) && value.staticValue.startsOn !== 0) { + value.staticValue.startsOn = timeOfDayToUTCTimestamp(value.staticValue.startsOn); + } + if (isDefined(value.staticValue.endsOn) && value.staticValue.endsOn !== 0) { + value.staticValue.endsOn = timeOfDayToUTCTimestamp(value.staticValue.endsOn); + } + if (isDefined(value.staticValue.items)){ + value.staticValue.items = this.alarmScheduleForm.getRawValue().staticValue.items; + value.staticValue.items = value.staticValue.items.map((item) => { + return { ...item, startsOn: timeOfDayToUTCTimestamp(item.startsOn), endsOn: timeOfDayToUTCTimestamp(item.endsOn)}; + }); + } + } + this.modelValue = value; + if (this.alarmScheduleForm.valid) { + this.propagateChange(this.modelValue); + } else { + this.propagateChange(null); + } + } + + + private defaultItemsScheduler(index): UntypedFormGroup { + return this.fb.group({ + enabled: [true], + dayOfWeek: [index + 1], + startsOn: [0, Validators.required], + endsOn: [0, Validators.required] + }); + } + + changeCustomScheduler($event: MatChipSelectionChange, index: number) { + const value = $event.selected; + this.disabledSelectedTime(value, index, true); + } + + private disabledSelectedTime(enable: boolean, index: number, emitEvent = false) { + if (enable) { + this.itemsSchedulerForm.at(index).get('startsOn').enable({emitEvent: false}); + this.itemsSchedulerForm.at(index).get('endsOn').enable({emitEvent}); + } else { + this.itemsSchedulerForm.at(index).get('startsOn').disable({emitEvent: false}); + this.itemsSchedulerForm.at(index).get('endsOn').disable({emitEvent}); + } + } + + getSchedulerRangeText(control: UntypedFormGroup | AbstractControl): string { + return getAlarmScheduleRangeText(control.get('startsOn').value, control.get('endsOn').value); + } + + get itemsSchedulerForm(): UntypedFormArray { + return this.alarmScheduleForm.get('staticValue.items') as UntypedFormArray; + } + + get argumentsList(): Array { + return this.arguments ? Object.keys(this.arguments): []; + } + +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.html new file mode 100644 index 0000000000..2b93030ce3 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.html @@ -0,0 +1,80 @@ + + +
+ @for (createAlarmRuleControl of createAlarmRulesFormArray().controls; track createAlarmRuleControl; let index = $index) { +
+
+
+
alarm.severity
+ + + + {{ alarmSeverityTranslationMap.get(alarmSeverityEnum[alarmSeverity]) | translate }} + + + +
+ + +
+ +
+ } +
+ + alarm-rule.add-create-alarm-rule-prompt + +
+
+ +
+
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.scss b/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.scss new file mode 100644 index 0000000000..c00cf6af9c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.scss @@ -0,0 +1,36 @@ +/** + * 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 { + .create-alarm-rule { + border: 1px solid rgba(0, 0, 0, .12); + border-left-width: 4px; + border-radius: 4px; + padding: 8px; + min-width: 0; + } +} + +:host ::ng-deep { + .mat-mdc-form-field.severity { + .mat-mdc-form-field-infix { + width: 160px; + } + } + .button-icon { + color: rgba(0, 0, 0, 0.38); + min-width: 40px; + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.ts new file mode 100644 index 0000000000..5be6d651df --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.ts @@ -0,0 +1,192 @@ +/// +/// 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, forwardRef, Input, OnDestroy, OnInit } from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormArray, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator, + Validators +} from '@angular/forms'; +import { Subject } from 'rxjs'; +import { AlarmSeverity, alarmSeverityTranslations } from '@shared/models/alarm.models'; +import { takeUntil } from 'rxjs/operators'; +import { AlarmRule } from "@shared/models/alarm-rule.models"; +import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; +import { AlarmSeverityNotificationColors } from "@shared/models/notification.models"; + +@Component({ + selector: 'tb-create-cf-alarm-rules', + templateUrl: './create-cf-alarm-rules.component.html', + styleUrls: ['./create-cf-alarm-rules.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CreateCfAlarmRulesComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CreateCfAlarmRulesComponent), + multi: true, + } + ] +}) +export class CreateCfAlarmRulesComponent implements ControlValueAccessor, OnInit, Validator, OnDestroy { + + alarmSeverities = Object.keys(AlarmSeverity); + alarmSeverityEnum = AlarmSeverity; + alarmSeverityTranslationMap = alarmSeverityTranslations; + + AlarmSeverityNotificationColors = AlarmSeverityNotificationColors; + + @Input() + disabled: boolean; + + @Input() + arguments: Record; + + createAlarmRulesFormGroup: UntypedFormGroup; + + private usedSeverities: AlarmSeverity[] = []; + + private destroy$ = new Subject(); + private propagateChange = (v: any) => { }; + + constructor(private fb: UntypedFormBuilder) { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + this.createAlarmRulesFormGroup = this.fb.group({ + createAlarmRules: this.fb.array([]) + }); + this.createAlarmRulesFormGroup.valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(() => this.updateModel()); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + createAlarmRulesFormArray(): UntypedFormArray { + return this.createAlarmRulesFormGroup.get('createAlarmRules') as UntypedFormArray; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.createAlarmRulesFormGroup.disable({emitEvent: false}); + } else { + this.createAlarmRulesFormGroup.enable({emitEvent: false}); + } + } + + writeValue(createAlarmRules: {[severity: string]: AlarmRule}): void { + const createAlarmRulesControls: Array = []; + if (createAlarmRules) { + Object.keys(createAlarmRules).forEach((severity) => { + const createAlarmRule = createAlarmRules[severity]; + if (severity === 'empty') { + severity = null; + } + createAlarmRulesControls.push(this.fb.group({ + severity: [severity, Validators.required], + alarmRule: [createAlarmRule, Validators.required] + })); + }); + } + this.createAlarmRulesFormGroup.setControl('createAlarmRules', this.fb.array(createAlarmRulesControls), {emitEvent: false}); + if (this.disabled) { + this.createAlarmRulesFormGroup.disable({emitEvent: false}); + } else { + this.createAlarmRulesFormGroup.enable({emitEvent: false}); + } + this.updateUsedSeverities(); + if (!this.disabled && !this.createAlarmRulesFormGroup.valid) { + this.updateModel(); + } + } + + public removeCreateAlarmRule(index: number) { + (this.createAlarmRulesFormGroup.get('createAlarmRules') as UntypedFormArray).removeAt(index); + } + + public addCreateAlarmRule() { + const createAlarmRulesArray = this.createAlarmRulesFormGroup.get('createAlarmRules') as UntypedFormArray; + createAlarmRulesArray.push(this.fb.group({ + severity: [this.getFirstUnusedSeverity(), Validators.required], + alarmRule: [null, Validators.required] + })); + this.createAlarmRulesFormGroup.updateValueAndValidity(); + if (!this.createAlarmRulesFormGroup.valid) { + this.updateModel(); + } + } + + private getFirstUnusedSeverity(): AlarmSeverity { + for (const severityKey of Object.keys(AlarmSeverity)) { + const severity = AlarmSeverity[severityKey]; + if (this.usedSeverities.indexOf(severity) === -1) { + return severity; + } + } + return null; + } + + public validate(c: UntypedFormControl) { + return (this.createAlarmRulesFormGroup.valid) ? null : { + createAlarmRules: { + valid: false, + }, + }; + } + + public isDisabledSeverity(severity: AlarmSeverity, index: number): boolean { + const usedIndex = this.usedSeverities.indexOf(severity); + return usedIndex > -1 && usedIndex !== index; + } + + private updateUsedSeverities() { + this.usedSeverities = []; + const value: {severity: string, alarmRule: AlarmRule}[] = this.createAlarmRulesFormGroup.get('createAlarmRules').value; + value.forEach((rule, index) => { + this.usedSeverities[index] = AlarmSeverity[rule.severity]; + }); + } + + private updateModel() { + const value: {severity: string, alarmRule: AlarmRule}[] = this.createAlarmRulesFormGroup.get('createAlarmRules').value; + const createAlarmRules: {[severity: string]: AlarmRule} = {}; + value.forEach(v => createAlarmRules[v.severity] = v.alarmRule); + this.updateUsedSeverities(); + this.propagateChange(createAlarmRules); + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component.html new file mode 100644 index 0000000000..c5b30ca6c4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component.html @@ -0,0 +1,59 @@ + +
+ +

filter.complex-filter

+ + +
+
+
+ + filter.operation.operation + + + {{complexOperationTranslations.get(complexOperationEnum[operation]) | translate}} + + + + + +
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component.ts new file mode 100644 index 0000000000..3327ed6d35 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component.ts @@ -0,0 +1,111 @@ +/// +/// 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, Inject, OnInit, SkipSelf } from '@angular/core'; +import { ErrorStateMatcher } from '@angular/material/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { + FormGroup, + FormGroupDirective, + NgForm, + UntypedFormBuilder, + UntypedFormControl, + Validators +} from '@angular/forms'; +import { Router } from '@angular/router'; +import { DialogComponent } from '@app/shared/components/dialog.component'; +import { + ComplexOperation, + complexOperationTranslationMap, + EntityKeyValueType, + FilterPredicateType +} from '@shared/models/query/query.models'; +import { ComplexAlarmRuleFilterPredicate } from "@shared/models/alarm-rule.models"; +import { FormControlsFrom } from "@shared/models/tenant.model"; +import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; + +export interface AlarmRuleComplexFilterPredicateDialogData { + complexPredicate: ComplexAlarmRuleFilterPredicate; + isAdd: boolean; + valueType: EntityKeyValueType; + arguments: Record; +} + +@Component({ + selector: 'tb-alarm-rule-complex-filter-predicate-dialog', + templateUrl: './alarm-rule-complex-filter-predicate-dialog.component.html', + providers: [{provide: ErrorStateMatcher, useExisting: AlarmRuleComplexFilterPredicateDialogComponent}], + styleUrls: [] +}) + +export class AlarmRuleComplexFilterPredicateDialogComponent extends + DialogComponent + implements OnInit, ErrorStateMatcher { + + complexFilterFormGroup: FormGroup>; + + complexOperations = Object.keys(ComplexOperation); + complexOperationEnum = ComplexOperation; + complexOperationTranslations = complexOperationTranslationMap; + + isAdd: boolean; + + submitted = false; + + arguments = this.data.arguments; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: AlarmRuleComplexFilterPredicateDialogData, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher, + public dialogRef: MatDialogRef, + private fb: UntypedFormBuilder) { + super(store, router, dialogRef); + + this.isAdd = this.data.isAdd; + + this.complexFilterFormGroup = this.fb.group( + { + operation: [this.data.complexPredicate.operation, [Validators.required]], + predicates: [this.data.complexPredicate.predicates, [Validators.required]] + } + ); + } + + ngOnInit(): void { + } + + isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean { + const originalErrorState = this.errorStateMatcher.isErrorState(control, form); + const customErrorState = !!(control && control.invalid && this.submitted); + return originalErrorState || customErrorState; + } + + cancel(): void { + this.dialogRef.close(null); + } + + save(): void { + this.submitted = true; + if (this.complexFilterFormGroup.valid) { + const predicate: ComplexAlarmRuleFilterPredicate = this.complexFilterFormGroup.value as ComplexAlarmRuleFilterPredicate; + predicate.type = FilterPredicateType.COMPLEX; + this.dialogRef.close(predicate); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.html new file mode 100644 index 0000000000..7d7dab2470 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.html @@ -0,0 +1,101 @@ + +
+ +

{{(data.isAdd ? 'alarm-rule.add-filter' : ('alarm-rule.edit-filter')) | translate}}

+ + +
+
+
+
+
{{ 'alarm-rule.general' | translate }}
+
+ + alarm-rule.value-argument + + @for (argument of argumentsList; track argument) { + {{ argument }} + } + + @if (filterFormGroup.get('argument').touched && filterFormGroup.get('argument').hasError('required')) { + + warning + + } + + + filter.value-type.value-type + + + + {{ entityKeyValueTypes.get(filterFormGroup.get('valueType').value)?.name | translate }} + + + + {{ entityKeyValueTypes.get(entityKeyValueTypeEnum[valueType]).name | translate }} + + + + {{ 'filter.value-type-required' | translate }} + + +
+
+ +
+
+
{{ 'alarm-rule.filter' | translate }}
+ + {{ complexOperationTranslationMap.get(ComplexOperation.AND) | translate }} + {{ complexOperationTranslationMap.get(ComplexOperation.OR) | translate }} + +
+ + +
+ +
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.scss b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.scss new file mode 100644 index 0000000000..0ec7194f02 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.scss @@ -0,0 +1,28 @@ +/** + * 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 ::ng-deep { + .mat-mdc-form-field.tb-value-type { + mat-select-trigger { + .mat-icon { + vertical-align: middle; + margin-right: 8px; + svg { + vertical-align: initial; + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.ts new file mode 100644 index 0000000000..fdff318c61 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.ts @@ -0,0 +1,153 @@ + /// +/// 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, Inject, OnDestroy, SkipSelf } from '@angular/core'; + import { ErrorStateMatcher } from '@angular/material/core'; + import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; + import { Store } from '@ngrx/store'; + import { AppState } from '@core/core.state'; + import { + FormGroup, + FormGroupDirective, + NgForm, + UntypedFormBuilder, + UntypedFormControl, + Validators + } from '@angular/forms'; + import { Router } from '@angular/router'; + import { DialogComponent } from '@app/shared/components/dialog.component'; + import { + ComplexOperation, + complexOperationTranslationMap, + EntityKeyValueType, + entityKeyValueTypesMap + } from '@shared/models/query/query.models'; + import { DialogService } from '@core/services/dialog.service'; + import { TranslateService } from '@ngx-translate/core'; + import { Subject } from 'rxjs'; + import { takeUntil } from 'rxjs/operators'; + import { AlarmRuleFilter, AlarmRuleFilterPredicate } from "@shared/models/alarm-rule.models"; + import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; + import { FormControlsFrom } from "@shared/models/tenant.model"; + + export interface AlarmRuleFilterDialogData { + filter: AlarmRuleFilter; + isAdd: boolean; + arguments: Record; + usedArguments: Array; +} + +@Component({ + selector: 'tb-alarm-rule-filter-dialog', + templateUrl: './alarm-rule-filter-dialog.component.html', + providers: [{provide: ErrorStateMatcher, useExisting: AlarmRuleFilterDialogComponent}], + styleUrls: ['./alarm-rule-filter-dialog.component.scss'] +}) +export class AlarmRuleFilterDialogComponent extends DialogComponent + implements OnDestroy, ErrorStateMatcher { + + private destroy$ = new Subject(); + + filterFormGroup: FormGroup>; + + entityKeyValueTypesKeys = Object.keys(EntityKeyValueType); + + entityKeyValueTypeEnum = EntityKeyValueType; + + entityKeyValueTypes = entityKeyValueTypesMap; + + complexOperationTranslationMap = complexOperationTranslationMap; + + ComplexOperation = ComplexOperation; + + submitted = false; + + searchText = ''; + + arguments = this.data.arguments; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: AlarmRuleFilterDialogData, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher, + public dialogRef: MatDialogRef, + private dialogs: DialogService, + private translate: TranslateService, + private fb: UntypedFormBuilder) { + super(store, router, dialogRef); + + this.filterFormGroup = this.fb.group( + { + argument: [this.data.filter.argument, [Validators.required]], + valueType: [this.data.filter.valueType ?? EntityKeyValueType.STRING, [Validators.required]], + predicates: [this.data.filter.predicates, [Validators.required]], + operation: [this.data.filter.operation ?? ComplexOperation.AND] + } + ); + this.filterFormGroup.get('valueType').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((valueType: EntityKeyValueType) => { + const prevValueType: EntityKeyValueType = this.filterFormGroup.value.valueType; + const predicates: AlarmRuleFilterPredicate[] = this.filterFormGroup.get('predicates').value; + if (prevValueType && prevValueType !== valueType) { + if (predicates && predicates.length) { + this.dialogs.confirm(this.translate.instant('filter.key-value-type-change-title'), + this.translate.instant('filter.key-value-type-change-message')).subscribe( + (result) => { + if (result) { + this.filterFormGroup.get('predicates').setValue([]); + } else { + this.filterFormGroup.get('valueType').setValue(prevValueType, {emitEvent: false}); + } + } + ); + } + } + }); + } + + ngOnDestroy() { + super.ngOnDestroy(); + this.destroy$.next(); + this.destroy$.complete(); + } + + argumentInUse(argument: string): boolean { + return this.data.usedArguments.includes(argument); + } + + isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean { + const originalErrorState = this.errorStateMatcher.isErrorState(control, form); + const customErrorState = !!(control && control.invalid && this.submitted); + return originalErrorState || customErrorState; + } + + cancel(): void { + this.dialogRef.close(null); + } + + save(): void { + this.submitted = true; + if (this.filterFormGroup.valid) { + const keyFilter: AlarmRuleFilter = this.filterFormGroup.getRawValue(); + this.dialogRef.close(keyFilter); + } + } + + get argumentsList(): Array { + return this.arguments ? Object.keys(this.arguments) : []; + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.html new file mode 100644 index 0000000000..1a71c92111 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.html @@ -0,0 +1,81 @@ + +
+
+ +
+ + +   +
+
+ +
+ @for (filterControl of filtersFormArray.controls; track filterControl; let index = $index) { +
+
+ @if ($index) { +
+ {{ complexOperationTranslationMap.get(operation) | translate }} +
+ } +
+
+
+
{{ filterControl.value?.argument }}
+
{{ FilterPredicateTypeTranslationMap.get(filterControl.value?.predicates[0]?.type) | translate }}
+ + +
+
+ @if (index) { + + } +
+ } + + filter.no-key-filters + +
+
+
+ +
+ diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.scss b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.scss new file mode 100644 index 0000000000..8e74f373f5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.scss @@ -0,0 +1,50 @@ +/** + * 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 { + .filter-title { + padding: 12px 0; + font-size: 14px; + font-weight: 500; + } + .filter-list { + overflow: auto; + max-height: 300px; + .no-data-found { + height: 50px; + } + + &-divider { + border-top: 1px solid rgba(0, 0, 0, 0.12); + } + } + .filters-operation { + display: flex; + justify-content: center; + margin-top: -14px; + &-container { + background-color: white; + } + &-label { + font-weight: 500; + color: #00695C; + padding: 0 8px; + border-radius: 4px; + border: 1px solid rgba(#00695C, 0.32); + background-color: rgba(#00695C, 0.04); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.ts new file mode 100644 index 0000000000..5ed3bce4e9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.ts @@ -0,0 +1,195 @@ +/// +/// 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, forwardRef, Input, OnDestroy, OnInit } from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + FormControl, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormArray, + UntypedFormBuilder, + UntypedFormGroup, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { Observable, Subject } from 'rxjs'; +import { + ComplexOperation, + complexOperationTranslationMap, + EntityKeyValueType +} from '@shared/models/query/query.models'; +import { MatDialog } from '@angular/material/dialog'; +import { deepClone } from '@core/utils'; +import { takeUntil } from 'rxjs/operators'; +import { + AlarmRuleFilterDialogComponent, + AlarmRuleFilterDialogData +} from "@home/components/alarm-rules/filter/alarm-rule-filter-dialog.component"; +import { AlarmRuleFilter, FilterPredicateTypeTranslationMap } from "@shared/models/alarm-rule.models"; +import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; +import { UtilsService } from "@core/services/utils.service"; + +@Component({ + selector: 'tb-alarm-rule-filter-list', + templateUrl: './alarm-rule-filter-list.component.html', + styleUrls: ['./alarm-rule-filter-list.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AlarmRuleFilterListComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => AlarmRuleFilterListComponent), + multi: true + } + ] +}) +export class AlarmRuleFilterListComponent implements ControlValueAccessor, Validator, OnInit, OnDestroy { + + @Input() + arguments: Record; + + @Input() operation: ComplexOperation = ComplexOperation.AND; + + filterListFormGroup: UntypedFormGroup; + filtersControl: FormControl; + + complexOperationTranslationMap = complexOperationTranslationMap; + FilterPredicateTypeTranslationMap = FilterPredicateTypeTranslationMap + + private destroy$ = new Subject(); + private propagateChange = null; + + constructor(private fb: UntypedFormBuilder, + private utils: UtilsService, + private dialog: MatDialog) { + } + + ngOnInit(): void { + this.filterListFormGroup = this.fb.group({ + filters: this.fb.array([]) + }); + this.filtersControl = this.fb.control(null); + + this.filterListFormGroup.valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(() => this.updateModel()); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + get filtersFormArray(): UntypedFormArray { + return this.filterListFormGroup.get('filters') as UntypedFormArray; + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + validate(): ValidationErrors | null { + return this.filterListFormGroup.valid && this.filtersControl.valid && this.filterListFormGroup.get('filters').value?.length ? null : { + filterList: {valid: false} + }; + } + + writeValue(filters: Array): void { + if (filters?.length === this.filtersFormArray?.length) { + this.filtersFormArray.patchValue(filters, {emitEvent: false}); + } else { + const keyFilterControls: Array = []; + if (filters) { + for (const filter of filters) { + keyFilterControls.push(this.fb.control(filter, [Validators.required])); + } + } + this.filterListFormGroup.setControl('filters', this.fb.array(keyFilterControls), {emitEvent: false}); + } + this.filtersControl.patchValue(filters, {emitEvent: false}); + } + + public removeFilter(index: number) { + (this.filterListFormGroup.get('filters') as UntypedFormArray).removeAt(index); + } + + public addFilter() { + const filtersFormArray = this.filterListFormGroup.get('filters') as UntypedFormArray; + this.openFilterDialog(null).subscribe(result => { + if (result) { + filtersFormArray.push(this.fb.control(result, [Validators.required])); + } + }); + } + + public editFilter(index: number) { + const filter: AlarmRuleFilter = + (this.filterListFormGroup.get('filters') as UntypedFormArray).at(index).value; + this.openFilterDialog(filter).subscribe(result => { + if (result) { + (this.filterListFormGroup.get('filters') as UntypedFormArray).at(index).patchValue(result); + } + }); + } + + private openFilterDialog(filter?: AlarmRuleFilter): Observable { + const isAdd = !filter; + if (!filter) { + filter = { + argument: null, + valueType: EntityKeyValueType.STRING, + operation: ComplexOperation.AND, + predicates: [] + }; + } + return this.dialog.open(AlarmRuleFilterDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + filter: filter ? deepClone(filter) : null, + isAdd, + arguments: this.arguments, + usedArguments: this.getUsedArguments + } + }).afterClosed(); + } + + get getUsedArguments(): Array { + const filters = this.filterListFormGroup.get('filters').value ?? []; + return filters.length ? filters.map((filter: AlarmRuleFilter) => filter.argument) : filters; + } + + private updateModel() { + const filters: Array = this.filterListFormGroup.getRawValue().filters; + this.filtersControl.patchValue(filters, {emitEvent: false}); + if (filters.length) { + this.propagateChange(filters); + } else { + this.propagateChange(null); + } + } + +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.html new file mode 100644 index 0000000000..4ca4f9ffdf --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.html @@ -0,0 +1,84 @@ + +
+
+
+
+
+ + + +
+   +
+
+ +
+ @for (predicateControl of predicatesFormArray.controls; track predicateControl; let index = $index) { +
+ @if (index) { +
+ {{ complexOperationTranslations.get(operation) | translate }} +
+ } +
+
+ + + +
+
+
+ } + filter.no-filters +
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.scss b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.scss new file mode 100644 index 0000000000..054d0e5281 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.scss @@ -0,0 +1,50 @@ +/** + * 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 { + .filter-title { + padding: 12px 8px; + font-size: 14px; + font-weight: 500; + } + .predicate-list { + .no-data-found { + height: 50px; + } + } + + .key-filter-list-divider { + border-top: 1px solid rgba(0, 0, 0, 0.12); + } + .filters-operation { + display: flex; + justify-content: center; + margin-top: -14px; + &-container { + position: absolute; + top: -12px; + left: 10px; + background-color: white; + } + &-label { + font-weight: 500; + color: #00695C; + padding: 0 8px; + border-radius: 4px; + border: 1px solid rgba(#00695C, 0.32); + background-color: rgba(#00695C, 0.04); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.ts new file mode 100644 index 0000000000..6772d6379c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.ts @@ -0,0 +1,239 @@ +/// +/// 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, forwardRef, Inject, Input, OnDestroy, OnInit } from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormArray, + UntypedFormBuilder, + UntypedFormGroup, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { Observable, of, Subject } from 'rxjs'; +import { + BooleanOperation, + ComplexOperation, + complexOperationTranslationMap, + EntityKeyValueType, + entityKeyValueTypeToFilterPredicateType, + FilterPredicateType, + NumericOperation, + StringOperation +} from '@shared/models/query/query.models'; +import { MatDialog } from '@angular/material/dialog'; +import { map, takeUntil } from 'rxjs/operators'; +import { ComponentType } from '@angular/cdk/portal'; +import { COMPLEX_FILTER_PREDICATE_DIALOG_COMPONENT_TOKEN } from '@home/components/tokens'; +import { + AlarmRuleComplexFilterPredicateDialogComponent, + AlarmRuleComplexFilterPredicateDialogData +} from "@home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component"; +import { + AlarmRuleFilterPredicate, + AlarmRulePredicateInfo, + ComplexAlarmRuleFilterPredicate +} from "@shared/models/alarm-rule.models"; +import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; + +@Component({ + selector: 'tb-alarm-rule-filter-predicate-list', + templateUrl: './alarm-rule-filter-predicate-list.component.html', + styleUrls: ['./alarm-rule-filter-predicate-list.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AlarmRuleFilterPredicateListComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => AlarmRuleFilterPredicateListComponent), + multi: true + } + ] +}) +export class AlarmRuleFilterPredicateListComponent implements ControlValueAccessor, Validator, OnInit, OnDestroy { + + @Input() disabled: boolean; + + @Input() valueType: EntityKeyValueType; + + @Input() operation: ComplexOperation = ComplexOperation.AND; + + @Input() arguments: Record; + + filterListFormGroup: UntypedFormGroup; + + valueTypeEnum = EntityKeyValueType; + + complexOperationTranslations = complexOperationTranslationMap; + + private destroy$ = new Subject(); + private propagateChange = null; + + constructor(private fb: UntypedFormBuilder, + @Inject(COMPLEX_FILTER_PREDICATE_DIALOG_COMPONENT_TOKEN) private complexFilterPredicateDialogComponent: ComponentType, + private dialog: MatDialog) { + } + + ngOnInit(): void { + this.filterListFormGroup = this.fb.group({ + predicates: this.fb.array([]) + }); + this.filterListFormGroup.valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(() => this.updateModel()); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + get predicatesFormArray(): UntypedFormArray { + return this.filterListFormGroup.get('predicates') as UntypedFormArray; + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState?(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.filterListFormGroup.disable({emitEvent: false}); + } else { + this.filterListFormGroup.enable({emitEvent: false}); + } + } + + validate(control: AbstractControl): ValidationErrors | null { + return this.filterListFormGroup.valid ? null : { + filterList: {valid: false} + }; + } + + writeValue(predicates: Array): void { + if (predicates?.length === this.predicatesFormArray.length) { + this.predicatesFormArray.patchValue(predicates, {emitEvent: false}); + } else { + const predicateControls: Array = []; + if (predicates) { + for (const predicate of predicates) { + predicateControls.push(this.fb.control(predicate, [Validators.required])); + } + } + this.filterListFormGroup.setControl('predicates', this.fb.array(predicateControls), {emitEvent: false}); + if (this.disabled) { + this.filterListFormGroup.disable({emitEvent: false}); + } else { + this.filterListFormGroup.enable({emitEvent: false}); + } + } + } + + public removePredicate(index: number) { + this.predicatesFormArray.removeAt(index); + } + + public addPredicate(complex: boolean) { + const predicatesFormArray = this.filterListFormGroup.get('predicates') as UntypedFormArray; + const predicate = this.createDefaultFilterPredicate(this.valueType, complex); + let observable: Observable; + if (complex) { + observable = this.openComplexFilterDialog(predicate as ComplexAlarmRuleFilterPredicate); + } else { + observable = of(predicate); + } + observable.subscribe((result) => { + if (result) { + predicatesFormArray.push(this.fb.control(result, [Validators.required])); + } + }); + } + + private createDefaultFilterPredicate(valueType: EntityKeyValueType, complex: boolean): AlarmRuleFilterPredicate { + const predicate = { + type: complex ? FilterPredicateType.COMPLEX : entityKeyValueTypeToFilterPredicateType(valueType) + } as AlarmRuleFilterPredicate; + switch (predicate.type) { + case FilterPredicateType.STRING: + predicate.operation = StringOperation.STARTS_WITH; + predicate.value = { + staticValue: '' + }; + predicate.ignoreCase = false; + break; + case FilterPredicateType.NUMERIC: + predicate.operation = NumericOperation.EQUAL; + predicate.value = { + staticValue: valueType === EntityKeyValueType.DATE_TIME ? Date.now() : 0 + }; + break; + case FilterPredicateType.BOOLEAN: + predicate.operation = BooleanOperation.EQUAL; + predicate.value = { + staticValue: false + }; + break; + case FilterPredicateType.COMPLEX: + predicate.operation = ComplexOperation.AND; + predicate.predicates = []; + break; + } + return predicate; + } + + private openComplexFilterDialog(predicate: ComplexAlarmRuleFilterPredicate): Observable { + return this.dialog.open(AlarmRuleComplexFilterPredicateDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + complexPredicate: predicate as ComplexAlarmRuleFilterPredicate, + valueType: this.valueType, + isAdd: true, + arguments: this.arguments, + } + }).afterClosed().pipe( + map((result) => { + if (result) { + predicate = result; + return predicate; + } else { + return null; + } + }) + ); + } + + private updateModel() { + const predicates: Array = this.filterListFormGroup.getRawValue().predicates; + if (predicates.length) { + this.propagateChange(predicates); + } else { + this.propagateChange(null); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-value.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-value.component.html new file mode 100644 index 0000000000..c27035ff0d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-value.component.html @@ -0,0 +1,80 @@ + +
+
+ + + + {{'alarm-rule.static' | translate}} + + + {{'alarm-rule.dynamic' | translate}} + + + + @if (mode === 'static') { + @switch (valueType) { + @case (valueTypeEnum.STRING) { + + + + } + @case (valueTypeEnum.NUMERIC) { + + + + } + @case (valueTypeEnum.BOOLEAN) { + + {{ (filterPredicateValueFormGroup.get('staticValue').value ? 'value.true' : 'value.false') | translate }} + + } + @case (valueTypeEnum.DATE_TIME) { + + } + } + } @else { + + + @for (argument of argumentsList; track argument) { + {{ argument }} + } + + @if (filterPredicateValueFormGroup.get('dynamicValueArgument').touched && filterPredicateValueFormGroup.get('dynamicValueArgument').hasError('required')) { + + warning + + } + + } +
+
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-value.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-value.component.ts new file mode 100644 index 0000000000..707f482e6e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-value.component.ts @@ -0,0 +1,159 @@ +/// +/// 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, DestroyRef, forwardRef, Input, OnInit } from '@angular/core'; +import { + ControlValueAccessor, + FormGroup, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + ValidationErrors, + Validator, + ValidatorFn, + Validators +} from '@angular/forms'; +import { EntityKeyValueType } from '@shared/models/query/query.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; +import { AlarmRuleValue } from "@shared/models/alarm-rule.models"; +import { isDefinedAndNotNull } from "@core/utils"; +import { FormControlsFrom } from "@shared/models/tenant.model"; + +@Component({ + selector: 'tb-alarm-rule-filter-predicate-value', + templateUrl: './alarm-rule-filter-predicate-value.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AlarmRuleFilterPredicateValueComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => AlarmRuleFilterPredicateValueComponent), + multi: true + } + ] +}) +export class AlarmRuleFilterPredicateValueComponent implements ControlValueAccessor, Validator, OnInit { + + @Input() + arguments: Record; + + @Input() + valueType: EntityKeyValueType; + + valueTypeEnum = EntityKeyValueType; + + filterPredicateValueFormGroup: FormGroup>>; + + mode: 'static' | 'dynamic' = 'static'; + + private propagateChange = null; + private propagateChangePending = false; + + constructor(private fb: UntypedFormBuilder, + private destroyRef: DestroyRef) { + } + + ngOnInit(): void { + let defaultValue: string | number | boolean; + let defaultValueValidators: ValidatorFn[]; + switch (this.valueType) { + case EntityKeyValueType.STRING: + defaultValue = ''; + defaultValueValidators = []; + break; + case EntityKeyValueType.NUMERIC: + defaultValue = 0; + defaultValueValidators = [Validators.required]; + break; + case EntityKeyValueType.BOOLEAN: + defaultValue = false; + defaultValueValidators = []; + break; + case EntityKeyValueType.DATE_TIME: + defaultValue = Date.now(); + defaultValueValidators = [Validators.required]; + break; + } + this.filterPredicateValueFormGroup = this.fb.group({ + staticValue: [defaultValue, defaultValueValidators], + dynamicValueArgument: [null, Validators.required] + }); + this.filterPredicateValueFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateModel(); + }); + } + + private updateValueModeValidators(mode: 'static' | 'dynamic'): void { + if (mode === 'static') { + this.filterPredicateValueFormGroup.get('staticValue').enable({emitEvent: false}); + this.filterPredicateValueFormGroup.get('dynamicValueArgument').disable({emitEvent: false}); + } else { + this.filterPredicateValueFormGroup.get('staticValue').disable({emitEvent: false}); + this.filterPredicateValueFormGroup.get('dynamicValueArgument').enable({emitEvent: false}); + } + } + + get argumentsList(): Array { + return this.arguments ? Object.keys(this.arguments): []; + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + if (this.propagateChangePending) { + this.propagateChangePending = false; + setTimeout(() => { + this.updateModel(); + }, 0); + } + } + + onModeChange(mode: 'static' | 'dynamic') { + this.mode = mode; + this.updateValueModeValidators(mode); + } + + registerOnTouched(fn: any): void { + } + + validate(): ValidationErrors | null { + return this.filterPredicateValueFormGroup.valid ? null : { + filterPredicateValue: {valid: false} + }; + } + + writeValue(predicateValue: AlarmRuleValue): void { + this.propagateChangePending = false; + this.filterPredicateValueFormGroup.patchValue(predicateValue, {emitEvent: false}); + this.mode = isDefinedAndNotNull(predicateValue.dynamicValueArgument) ? 'dynamic' : 'static'; + this.updateValueModeValidators(this.mode); + } + + private updateModel() { + const predicateValue: AlarmRuleValue = this.filterPredicateValueFormGroup.value; + if (this.propagateChange) { + this.propagateChange(predicateValue); + } else { + this.propagateChangePending = true; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate.component.html new file mode 100644 index 0000000000..6e440eac86 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate.component.html @@ -0,0 +1,79 @@ + +
+
+ @switch (type) { + @case (filterPredicateType.STRING) { +
+ + + + {{stringOperationTranslationMap.get(stringOperation[operation]) | translate}} + + + + + {{ 'alarm-rule.ignore-case' | translate }} + +
+ } + @case (filterPredicateType.NUMERIC) { +
+ + + + {{numericOperationTranslations.get(numericOperationEnum[operation]) | translate}} + + + +
+ } + @case (filterPredicateType.BOOLEAN) { +
+ + + + {{booleanOperationTranslations.get(booleanOperationEnum[operation]) | translate}} + + + +
+ } + @case (filterPredicateType.COMPLEX) { +
+ +
+ } + } + @if (type !== filterPredicateType.COMPLEX) { + + + } +
+
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate.component.ts new file mode 100644 index 0000000000..97daba14f0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate.component.ts @@ -0,0 +1,158 @@ +/// +/// 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, DestroyRef, forwardRef, Input, OnInit } from '@angular/core'; +import { + ControlValueAccessor, + FormGroup, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + ValidationErrors, + Validator +} from '@angular/forms'; +import { + BooleanOperation, + booleanOperationTranslationMap, + EntityKeyValueType, + FilterPredicateType, + NumericOperation, + numericOperationTranslationMap, + StringOperation, + stringOperationTranslationMap +} from '@shared/models/query/query.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { AlarmRuleFilterPredicate, ComplexAlarmRuleFilterPredicate } from "@shared/models/alarm-rule.models"; +import { MatDialog } from "@angular/material/dialog"; +import { + AlarmRuleComplexFilterPredicateDialogComponent, + AlarmRuleComplexFilterPredicateDialogData +} from "@home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component"; +import { FormControlsFrom } from "@shared/models/tenant.model"; +import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; + +@Component({ + selector: 'tb-alarm-rule-filter-predicate', + templateUrl: './alarm-rule-filter-predicate.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AlarmRuleFilterPredicateComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => AlarmRuleFilterPredicateComponent), + multi: true + } + ] +}) +export class AlarmRuleFilterPredicateComponent implements ControlValueAccessor, Validator, OnInit { + + @Input() + valueType: EntityKeyValueType; + + @Input() + arguments: Record; + + filterPredicateFormGroup: FormGroup>; + + type: FilterPredicateType; + + filterPredicateType = FilterPredicateType; + + stringOperations = Object.keys(StringOperation); + stringOperation = StringOperation; + stringOperationTranslationMap = stringOperationTranslationMap; + + numericOperations = Object.keys(NumericOperation); + numericOperationEnum = NumericOperation; + numericOperationTranslations = numericOperationTranslationMap; + + booleanOperations = Object.keys(BooleanOperation); + booleanOperationEnum = BooleanOperation; + booleanOperationTranslations = booleanOperationTranslationMap; + + private propagateChange = null; + + constructor(private fb: UntypedFormBuilder, + private dialog: MatDialog, + private destroyRef: DestroyRef) { + } + + ngOnInit(): void { + this.filterPredicateFormGroup = this.fb.group({ + operation: [], + ignoreCase: false, + predicates: [], + value: [] + }); + this.filterPredicateFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateModel(); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + validate(): ValidationErrors | null { + return this.filterPredicateFormGroup.valid ? null : { + filterPredicate: {valid: false} + }; + } + + writeValue(predicate: AlarmRuleFilterPredicate): void { + this.type = predicate.type; + this.filterPredicateFormGroup.patchValue(predicate, {emitEvent: false}); + } + + private updateModel() { + let predicate: AlarmRuleFilterPredicate = null; + if (this.filterPredicateFormGroup.valid) { + predicate = this.filterPredicateFormGroup.getRawValue(); + predicate.type = this.type; + } + this.propagateChange(predicate); + } + + public openComplexFilterDialog() { + this.dialog.open(AlarmRuleComplexFilterPredicateDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + complexPredicate: this.filterPredicateFormGroup.getRawValue() as ComplexAlarmRuleFilterPredicate, + valueType: this.valueType, + isAdd: false, + arguments: this.arguments, + } + }).afterClosed().subscribe( + (result) => { + if (result) { + this.filterPredicateFormGroup.patchValue(result, {emitEvent: false}); + this.updateModel(); + } + } + ); + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.html new file mode 100644 index 0000000000..c9f3dd61c8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.html @@ -0,0 +1,23 @@ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.scss b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.scss new file mode 100644 index 0000000000..a059bb4564 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.scss @@ -0,0 +1,85 @@ +/** + * 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 { + text-overflow: ellipsis; + overflow: hidden; + .tb-filter-text { + overflow-y: auto; + text-align: start; + &.required { + color: #f44336; + padding: 0; + } + &.nowrap { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + } +} + +:host ::ng-deep { + .tb-filter-text { + line-height: 1.8em; + span { + display: inline-block; + vertical-align: middle; + line-height: 1.4em; + } + .tb-filter-predicate { + padding-right: 4px; + padding-left: 4px; + } + .tb-filter-entity-key, .tb-filter-value, .tb-filter-dynamic-source { + font-weight: bold; + border: 1px groove rgba(0, 0, 0, .25); + border-radius: 4px; + padding-left: 4px; + padding-right: 4px; + } + .tb-filter-entity-key, .tb-filter-value { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 150px; + } + .tb-filter-dynamic-source { + } + .tb-filter-entity-key { + color: #305680; + } + .tb-filter-value { + color: #ff5722; + } + .tb-filter-simple-operation { + font-size: 0.9em; + } + .tb-filter-complex-operation { + font-weight: 400; + font-style: italic; + } + .tb-filter-dynamic-value { + .tb-filter-dynamic-source, .tb-filter-value { + color: #0c959c; + } + } + .tb-filter-bracket { + .tb-left-bracket, .tb-right-bracket { + font-size: 1.2em; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.ts new file mode 100644 index 0000000000..8a092e58cd --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-text.component.ts @@ -0,0 +1,192 @@ +/// +/// 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 } from '@angular/core'; +import { + booleanOperationTranslationMap, + ComplexOperation, + complexOperationTranslationMap, + EntityKeyValueType, + FilterPredicateType, + numericOperationTranslationMap, + stringOperationTranslationMap +} from '@shared/models/query/query.models'; +import { TranslateService } from '@ngx-translate/core'; +import { DatePipe } from '@angular/common'; +import { + AlarmRuleExpression, + AlarmRuleExpressionType, + AlarmRuleFilter, + AlarmRuleFilterPredicate, + ComplexAlarmRuleFilterPredicate +} from "@shared/models/alarm-rule.models"; +import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; +import { coerceBoolean } from "@shared/decorators/coercion"; + +@Component({ + selector: 'tb-alarm-rule-filter-text', + templateUrl: './alarm-rule-filter-text.component.html', + styleUrls: ['./alarm-rule-filter-text.component.scss'], + providers: [] +}) +export class AlarmRuleFilterTextComponent { + + @Input() + @coerceBoolean() + required = false; + + @Input() + noFilterText = this.translate.instant('filter.no-filter-text'); + + @Input() + addFilterPrompt = this.translate.instant('filter.add-filter-prompt'); + + @Input() + @coerceBoolean() + nowrap = false; + + @Input() + arguments: Record; + + private alarmRuleExpressionValue: AlarmRuleExpression; + get alarmRuleExpression(): AlarmRuleExpression { + return this.alarmRuleExpressionValue; + } + + @Input() + set alarmRuleExpression(value: AlarmRuleExpression) { + if (value !== this.alarmRuleExpressionValue) { + this.alarmRuleExpressionValue = value; + this.updateFilterText(value); + } + }; + + private specTextValue: string; + get specText(): string { + return this.specTextValue; + } + @Input() + set specText(value: string) { + if (value !== this.specTextValue) { + this.specTextValue = value; + this.updateFilterText(this.alarmRuleExpression); + } + } + + requiredClass = false; + + public filterText: string; + + constructor(private translate: TranslateService, + private datePipe: DatePipe) { + } + + private updateFilterText(value: AlarmRuleExpression) { + this.requiredClass = false; + if (value && (value.expression || value.filters)) { + if (value.type === AlarmRuleExpressionType.SIMPLE) { + this.filterText = this.keyFiltersToText(this.translate, this.datePipe, value.filters, value.operation); + } else { + this.filterText = 'function expression(ctx, ' + (this.arguments ? Object.keys(this.arguments).join(', ') : '' ) + ')'; + } + if (this.specText?.length) { + this.filterText = this.specText + ': ' + this.filterText; + } + } else { + if (this.required) { + this.filterText = this.addFilterPrompt; + this.requiredClass = true; + } else { + this.filterText = this.noFilterText; + } + } + } + + private keyFiltersToText(translate: TranslateService, datePipe: DatePipe, keyFilters: Array, operation: ComplexOperation): string { + const filtersText = keyFilters.map(keyFilter => + this.filterPredicateToText(translate, datePipe, keyFilter, keyFilter.predicates)); + let result: string; + if (filtersText.length > 1) { + const operationText = translate.instant(complexOperationTranslationMap.get(operation)); + result = filtersText.join(' ' + operationText + ' '); + } else { + result = filtersText[0]; + } + return result; + } + + private filterPredicateToText(translate: TranslateService, + datePipe: DatePipe, + keyFilter: AlarmRuleFilter, + keyFilterPredicates: AlarmRuleFilterPredicate[], + complexOperation?: ComplexOperation): string { + const key = keyFilter.argument; + const filterOperation: ComplexOperation = complexOperation ? complexOperation : (keyFilter.operation ?? ComplexOperation.AND); + + const predicates = keyFilterPredicates.map((keyFilterPredicate: AlarmRuleFilterPredicate) => { + if (keyFilterPredicate.type === FilterPredicateType.COMPLEX) { + const complexPredicate = keyFilterPredicate as ComplexAlarmRuleFilterPredicate; + const complexOperation = complexPredicate.operation ?? ComplexOperation.AND; + return this.filterPredicateToText(translate, datePipe, keyFilter, complexPredicate.predicates, complexOperation); + } else { + let operation: string; + let value: string; + const val = keyFilterPredicate.value; + const dynamicValue = val?.dynamicValueArgument?.length; + if (dynamicValue) { + value = '' + val?.dynamicValueArgument + ''; + } + switch (keyFilterPredicate.type) { + case FilterPredicateType.STRING: + operation = translate.instant(stringOperationTranslationMap.get(keyFilterPredicate.operation)); + if (keyFilterPredicate.ignoreCase) { + operation += ' ' + translate.instant('filter.ignore-case'); + } + if (!dynamicValue) { + value = `'${keyFilterPredicate.value.staticValue}'`; + } + break; + case FilterPredicateType.NUMERIC: + operation = translate.instant(numericOperationTranslationMap.get(keyFilterPredicate.operation)); + if (!dynamicValue) { + if (keyFilter.valueType === EntityKeyValueType.DATE_TIME) { + value = datePipe.transform(keyFilterPredicate.value.staticValue, 'yyyy-MM-dd HH:mm'); + } else { + value = keyFilterPredicate.value.staticValue + ''; + } + } + break; + case FilterPredicateType.BOOLEAN: + operation = translate.instant(booleanOperationTranslationMap.get(keyFilterPredicate.operation)); + if (!dynamicValue) { + value = translate.instant(keyFilterPredicate.value.staticValue ? 'value.true' : 'value.false'); + } + break; + } + if (!dynamicValue) { + value = `${value}`; + } + return `${key} ${operation} ${value}` + } + }); + if (predicates.length > 1) { + return '(' + predicates.join(` ${translate.instant(complexOperationTranslationMap.get(filterOperation))} `)+ ')'; + } else { + return predicates.toString(); + } + } + +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts index 1b48771247..d0bb2e12f4 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts @@ -77,6 +77,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig(); entityId = input(); entityName = input(); + ownerId = input(); calculatedFieldsTableConfig: CalculatedFieldsTableConfig; @@ -76,6 +77,7 @@ export class CalculatedFieldsTableComponent { this.destroyRef, this.renderer, this.entityName(), + this.ownerId(), this.importExportService, this.entityDebugSettingsService, ); diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.ts index dcc54c94b3..fd104353b2 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.ts @@ -33,7 +33,7 @@ import { ArgumentEntityTypeTranslations, ArgumentType, ArgumentTypeTranslations, - CalculatedFieldArgumentValue, + CalculatedFieldArgumentValue, CFArgumentDynamicSourceType, getCalculatedFieldCurrentEntityFilter } from '@shared/models/calculated-field.models'; import { debounceTime, distinctUntilChanged, filter } from 'rxjs/operators'; @@ -65,6 +65,7 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI @Input() entityId: EntityId; @Input() tenantId: string; @Input() entityName: string; + @Input() ownerId: EntityId; @Input() isScript: boolean; @Input() usedArgumentNames: string[]; @Input() isOutputKey = false; @@ -166,7 +167,9 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI saveArgument(): void { const value = this.argumentFormGroup.value as CalculatedFieldArgumentValue; - if (this.entityType === ArgumentEntityType.Tenant) { + if (this.entityType === ArgumentEntityType.Owner) { + value.refDynamicSource = CFArgumentDynamicSourceType.CURRENT_OWNER; + } else if (this.entityType === ArgumentEntityType.Tenant) { value.refEntityId = new TenantId(this.tenantId) as any; } if (this.entityType !== ArgumentEntityType.Current && this.entityType !== ArgumentEntityType.Tenant) { @@ -206,6 +209,12 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI case ArgumentEntityType.Current: entityFilter = this.currentEntityFilter; break; + case ArgumentEntityType.Owner: + entityFilter = { + type: AliasFilterType.singleEntity, + singleEntity: this.ownerId + }; + break; case ArgumentEntityType.Tenant: entityFilter = { type: AliasFilterType.singleEntity, diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.ts index 8187c360c1..018132140d 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.ts @@ -85,6 +85,7 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces @Input() entityId: EntityId; @Input() tenantId: string; @Input() entityName: string; + @Input() ownerId: EntityId; @Input() isScript: boolean; @ViewChild(MatSort, { static: true }) sort: MatSort; @@ -179,6 +180,7 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces buttonTitle: isExists ? 'action.apply' : 'action.add', tenantId: this.tenantId, entityName: this.entityName, + ownerId: this.ownerId, usedArgumentNames: this.argumentsFormArray.value.map(({ argumentName }) => argumentName).filter(name => name !== argument.argumentName), }; this.popoverComponent = this.popoverService.displayPopover({ diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html index 7e538a8dc7..75636deb60 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html @@ -89,6 +89,7 @@ void>; getTestScriptDialogFn: CalculatedFieldTestScriptFn; isDirty?: boolean; @@ -71,7 +72,7 @@ export class CalculatedFieldDialogComponent extends DialogComponent type !== CalculatedFieldType.ALARM) as CalculatedFieldType[]; readonly CalculatedFieldTypeTranslations = CalculatedFieldTypeTranslations; constructor(protected store: Store, diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.html index a44e4362af..cfab9d9def 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.html @@ -21,6 +21,7 @@ diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.ts index 7b9f6be1c0..669795258e 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.ts @@ -75,6 +75,9 @@ export class SimpleConfigurationComponent implements ControlValueAccessor, Valid @Input({required: true}) entityName: string; + @Input({required: true}) + ownerId: EntityId; + @Input({required: true}) testScript: () => Observable; diff --git a/ui-ngx/src/app/modules/home/components/home-components.module.ts b/ui-ngx/src/app/modules/home/components/home-components.module.ts index e15d466b7a..c74a0475e5 100644 --- a/ui-ngx/src/app/modules/home/components/home-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/home-components.module.ts @@ -195,6 +195,8 @@ import { CalculatedFieldDebugDialogComponent } from '@home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component'; import { CalculatedFieldsModule } from '@home/components/calculated-fields/calculated-field.module'; +import { AlarmRuleModule } from "@home/components/alarm-rules/alarm-rule.module"; +import { AlarmRulesTableComponent } from "@home/components/alarm-rules/alarm-rules-table.component"; @NgModule({ declarations: @@ -209,6 +211,7 @@ import { CalculatedFieldsModule } from '@home/components/calculated-fields/calcu AuditLogDetailsDialogComponent, CalculatedFieldsTableComponent, CalculatedFieldDebugDialogComponent, + AlarmRulesTableComponent, EventContentDialogComponent, EventTableHeaderComponent, EventTableComponent, @@ -351,6 +354,7 @@ import { CalculatedFieldsModule } from '@home/components/calculated-fields/calcu SharedModule, SharedHomeComponentsModule, CalculatedFieldsModule, + AlarmRuleModule, WidgetConfigComponentsModule, BasicWidgetConfigModule, Lwm2mProfileComponentsModule, @@ -369,6 +373,7 @@ import { CalculatedFieldsModule } from '@home/components/calculated-fields/calcu EntityDetailsPageComponent, AuditLogTableComponent, CalculatedFieldsTableComponent, + AlarmRulesTableComponent, EventTableComponent, EdgeDownlinkTableHeaderComponent, EdgeDownlinkTableComponent, diff --git a/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-tabs.component.html b/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-tabs.component.html index e4431abfed..9db6999179 100644 --- a/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-tabs.component.html @@ -15,17 +15,29 @@ limitations under the License. --> - - - - - - - - - +@if (entity && !isEdit) { + @if (authUser.authority === authorities.TENANT_ADMIN) { + + + + } + + + + + + + @if (authUser.authority === authorities.TENANT_ADMIN) { + + + + + } +} diff --git a/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.html b/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.html index 767d11eb23..b12381bf70 100644 --- a/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.html @@ -15,47 +15,52 @@ limitations under the License. --> - - - - - - - - - - - - - - - - - - - - - - - - - - +@if (entity) { + + + + + + + + + @if (authUser.authority === authorities.TENANT_ADMIN) { + + + + + + + } + + + + + + + + + + @if (authUser.authority === authorities.TENANT_ADMIN) { + + + + + + + + } +} diff --git a/ui-ngx/src/app/modules/home/pages/customer/customer-tabs.component.html b/ui-ngx/src/app/modules/home/pages/customer/customer-tabs.component.html index 0188755306..0a0e05ffdd 100644 --- a/ui-ngx/src/app/modules/home/pages/customer/customer-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/customer/customer-tabs.component.html @@ -15,43 +15,57 @@ limitations under the License. --> - - - - - - - - - - - - - - - - - - - - - - - +@if (entity) { + + + + + + + + + @if (authUser.authority === authorities.TENANT_ADMIN) { + + + + } + + + + + + + + + + + @if (authUser.authority === authorities.TENANT_ADMIN) { + + + + + + + + + } +} diff --git a/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.html b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.html index b9003c4c1c..eda12b903a 100644 --- a/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.html @@ -15,54 +15,88 @@ limitations under the License. --> - -
- - device-profile.transport-type - - - {{deviceTransportTypeTranslations.get(type) | translate}} - - - - {{deviceTransportTypeHints.get(detailsForm.get('transportType').value) | translate}} - - - {{ 'device-profile.transport-type-required' | translate }} - - -
- - -
-
-
- - - - -
-
- +@if (entity) { + +
+ + device-profile.transport-type + + + {{deviceTransportTypeTranslations.get(type) | translate}} + + + + {{deviceTransportTypeHints.get(detailsForm.get('transportType').value) | translate}} + + + {{ 'device-profile.transport-type-required' | translate }} + + +
+ + +
-
- - -
-
- - + + @if (authUser.authority === authorities.TENANT_ADMIN && !isEdit) { + + + + } + + @if (hasOldRules || authUser.authority === authorities.TENANT_ADMIN && !isEdit) { + +
+ @if (hasOldRules && !isEdit) { +
+ + {{ 'alarm-rule.alarm-rules-new' | translate }} + {{ 'alarm-rule.alarm-rules-old' | translate }} + +
+ } + @if (alarmRulesVersion) { +
+ +
+ } @else { +
+
+ +
+
+ } +
+
+ } + + +
+
+ + +
-
- + + @if (!isEdit) { + + + + } + @if (authUser.authority === authorities.TENANT_ADMIN && !isEdit) { + + + + + } +}
@@ -73,13 +107,3 @@
- - - - - - diff --git a/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.ts index 1e4b735ff7..6fa05f5751 100644 --- a/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.ts +++ b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.ts @@ -41,6 +41,9 @@ export class DeviceProfileTabsComponent extends EntityTabsComponent, private destroyRef: DestroyRef) { super(store); @@ -57,6 +60,8 @@ export class DeviceProfileTabsComponent extends EntityTabsComponent - - - - - - - - - - - - - - - - - - - - - - - - - - +@if (entity) { + + + + + + + + + @if (authUser.authority === authorities.TENANT_ADMIN) { + + + + + + + } + + + + + + + + + + + + + @if (authUser.authority === authorities.TENANT_ADMIN) { + + + + } +} diff --git a/ui-ngx/src/app/shared/models/alarm-rule.models.ts b/ui-ngx/src/app/shared/models/alarm-rule.models.ts new file mode 100644 index 0000000000..fafee4e97b --- /dev/null +++ b/ui-ngx/src/app/shared/models/alarm-rule.models.ts @@ -0,0 +1,152 @@ +/// +/// 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 { CustomTimeSchedulerItem } from "@shared/models/device.models"; +import { DashboardId } from "@shared/models/id/dashboard-id"; +import { TimeUnit } from "@shared/models/time/time.models"; +import { + BooleanOperation, + ComplexOperation, + EntityKeyValueType, + FilterPredicateType, + NumericOperation, + StringOperation +} from "@shared/models/query/query.models"; + +export enum AlarmRuleScheduleType { + ANY_TIME = 'ANY_TIME', + SPECIFIC_TIME = 'SPECIFIC_TIME', + CUSTOM = 'CUSTOM' +} + +export const AlarmRuleScheduleTypeTranslationMap = new Map( + [ + [AlarmRuleScheduleType.ANY_TIME, 'alarm-rule.schedule.any-time'], + [AlarmRuleScheduleType.SPECIFIC_TIME, 'alarm-rule.schedule.specific-time'], + [AlarmRuleScheduleType.CUSTOM, 'alarm-rule.schedule.custom'] + ] +); + +export enum AlarmRuleConditionType { + SIMPLE = 'SIMPLE', + DURATION = 'DURATION', + REPEATING = 'REPEATING' +} + +export const AlarmRuleConditionTypeTranslationMap = new Map( + [ + [AlarmRuleConditionType.SIMPLE, 'alarm-rule.conditions.simple'], + [AlarmRuleConditionType.DURATION, 'alarm-rule.conditions.duration'], + [AlarmRuleConditionType.REPEATING, 'alarm-rule.conditions.repeating'] + ] +); + +export enum AlarmRuleExpressionType { + SIMPLE = 'SIMPLE', + TBEL = 'TBEL', +} + +export const FilterPredicateTypeTranslationMap = new Map( + [ + [FilterPredicateType.STRING, 'alarm-rule.filter-predicate-type.string'], + [FilterPredicateType.NUMERIC, 'alarm-rule.filter-predicate-type.numeric'], + [FilterPredicateType.BOOLEAN, 'alarm-rule.filter-predicate-type.boolean'], + [FilterPredicateType.COMPLEX, 'alarm-rule.filter-predicate-type.complex'] + ] +); + +export interface AlarmRule { + condition: AlarmRuleCondition; + alarmDetails?: string; + dashboardId?: DashboardId; +} + +export interface AlarmRuleCondition { + type: AlarmRuleConditionType; + expression: AlarmRuleExpression; + schedule?: AlarmRuleSchedule; + unit?: TimeUnit; + value?: AlarmRuleValue; + count?: AlarmRuleValue; +} + +export interface AlarmRuleExpression { + type: AlarmRuleExpressionType; + expression?: string; + filters?: Array; + operation?: ComplexOperation; +} + +export interface AlarmRuleSchedule { + staticValue?: { + type?: AlarmRuleScheduleType; + timezone?: string; + daysOfWeek?: number[]; + startsOn?: number; + endsOn?: number; + items?: CustomTimeSchedulerItem[]; + }; + dynamicValueArgument?: string; +} + +export interface AlarmRuleFilter { + argument: string; + valueType: EntityKeyValueType; + operation: ComplexOperation; + predicates: AlarmRuleFilterPredicate[]; +} + +export interface AlarmRulePredicateInfo { + keyFilterPredicate: AlarmRuleFilterPredicate; +} + +export type AlarmRuleFilterPredicate = StringAlarmRuleFilterPredicate | + NumericAlarmRuleFilterPredicate | + BooleanAlarmRuleFilterPredicate | + ComplexAlarmRuleFilterPredicate; + +export interface AlarmRuleValue { + dynamicValueArgument?: string; + staticValue?: T +} + +export interface StringAlarmRuleFilterPredicate { + type: FilterPredicateType.STRING; + operation: StringOperation; + value: AlarmRuleValue; + ignoreCase: boolean; +} + +export interface NumericAlarmRuleFilterPredicate { + type: FilterPredicateType.NUMERIC; + operation: NumericOperation; + value: AlarmRuleValue; +} + +export interface BooleanAlarmRuleFilterPredicate { + type: FilterPredicateType.BOOLEAN; + operation: BooleanOperation; + value: AlarmRuleValue; +} + +export interface BaseComplexFilterPredicate { + type: FilterPredicateType.COMPLEX; + operation: ComplexOperation; + predicates: Array; +} + +export type ComplexAlarmRuleFilterPredicate = BaseComplexFilterPredicate; diff --git a/ui-ngx/src/app/shared/models/calculated-field.models.ts b/ui-ngx/src/app/shared/models/calculated-field.models.ts index 0ef454ae1f..af241d0681 100644 --- a/ui-ngx/src/app/shared/models/calculated-field.models.ts +++ b/ui-ngx/src/app/shared/models/calculated-field.models.ts @@ -31,6 +31,7 @@ import { } from '@shared/models/ace/ace.models'; import { EntitySearchDirection } from '@shared/models/relation.models'; import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; +import { AlarmRule } from "@shared/models/alarm-rule.models"; interface BaseCalculatedField extends Omit, 'label'>, HasVersion, HasEntityDebugSettings, HasTenantId, ExportableEntity { entityId: EntityId; @@ -56,18 +57,25 @@ export interface CalculatedFieldPropagation extends BaseCalculatedField { configuration: CalculatedFieldPropagationConfiguration; } +export interface CalculatedFieldAlarmRule extends BaseCalculatedField { + type: CalculatedFieldType.ALARM; + configuration: CalculatedFieldAlarmRuleConfiguration; +} + export type CalculatedField = | CalculatedFieldSimple | CalculatedFieldScript | CalculatedFieldGeofencing - | CalculatedFieldPropagation; + | CalculatedFieldPropagation + | CalculatedFieldAlarmRule; export enum CalculatedFieldType { SIMPLE = 'SIMPLE', SCRIPT = 'SCRIPT', GEOFENCING = 'GEOFENCING', PROPAGATION = 'PROPAGATION', - RELATED_ENTITIES_AGGREGATION = 'RELATED_ENTITIES_AGGREGATION' + RELATED_ENTITIES_AGGREGATION = 'RELATED_ENTITIES_AGGREGATION', + ALARM = 'ALARM', } export const CalculatedFieldTypeTranslations = new Map( @@ -85,7 +93,8 @@ export type CalculatedFieldConfiguration = | CalculatedFieldScriptConfiguration | CalculatedFieldGeofencingConfiguration | CalculatedFieldPropagationConfiguration - | CalculatedFieldRelatedAggregationConfiguration; + | CalculatedFieldRelatedAggregationConfiguration + | CalculatedFieldAlarmRuleConfiguration; export interface CalculatedFieldSimpleConfiguration { type: CalculatedFieldType.SIMPLE; @@ -127,6 +136,17 @@ interface BasePropagationConfiguration { output: CalculatedFieldOutput; } +interface CalculatedFieldAlarmRuleConfiguration { + type: CalculatedFieldType.ALARM; + arguments: Record; + createRules: {[severity: string]: AlarmRule}; + clearRule?: AlarmRule; + propagate?: boolean; + propagateToOwner?: boolean; + propagateToTenant?: boolean; + propagateRelationTypes?: Array; +} + export interface PropagationWithNoExpression extends BasePropagationConfiguration { applyExpressionToResolvedArguments: false; } @@ -156,6 +176,7 @@ export enum ArgumentEntityType { Asset = 'ASSET', Customer = 'CUSTOMER', Tenant = 'TENANT', + Owner = 'CURRENT_OWNER', RelationQuery = 'RELATION_PATH_QUERY', } @@ -166,6 +187,7 @@ export const ArgumentEntityTypeTranslations = new Map( ] ) +export enum CFArgumentDynamicSourceType { + CURRENT_OWNER = 'CURRENT_OWNER' +} + export interface CalculatedFieldArgument { refEntityKey: RefEntityKey; defaultValue?: string; refEntityId?: RefEntityId; + refDynamicSource?: CFArgumentDynamicSourceType; limit?: number; timeWindow?: number; } diff --git a/ui-ngx/src/assets/help/en_US/alarm-rule/alarm_rule_schedule_format.md b/ui-ngx/src/assets/help/en_US/alarm-rule/alarm_rule_schedule_format.md new file mode 100644 index 0000000000..94ffabbc34 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/alarm-rule/alarm_rule_schedule_format.md @@ -0,0 +1,123 @@ +#### Active all time schedule format + +An attribute with a dynamic value for an active all-time schedule format must contain an empty JSON object or JSON in the following format: + +```javascript +{ + "type": "ANY_TIME" +} +``` + +#### Specific time schedule format + +An attribute with a dynamic value for a specific schedule format must have JSON in the following format: + +```javascript +{ + "type": "SPECIFIC_TIME", + "daysOfWeek": [ + 2, + 4 + ], + "endsOn": 0, + "startsOn": 0, + "timezone": "Europe/Kiev" +} +``` + +
    +
  • +timezone: this value is used to designate the timezone you are using. +
  • +
  • +daysOfWeek: this value is used to designate the days in numerical representation (Monday - 1, Tuesday 2, etc.) on which the schedule will be active. +
  • +
  • +startsOn: this value is used to designate the timestamp in milliseconds, from which the schedule will be active for the designated days. +
  • +
  • +endsOn: this value is used to designate the timestamp in milliseconds until which the schedule will be active for the specified days. +
  • +
+When startsOn and endsOn equals 0 it's means that the schedule will be active the whole day. + +#### Custom time schedule format + +An attribute with a dynamic value for a custom schedule format must have JSON in the following format: + +```javascript +{ + "type": "CUSTOM" + "timezone": "Europe/Kiev", + "items": [ + { + "dayOfWeek": 1, + "enabled": true, + "endsOn": 0, + "startsOn": 0 + }, + { + "dayOfWeek": 2, + "enabled": true, + "endsOn": 0, + "startsOn": 0 + }, + { + "dayOfWeek": 3, + "enabled": true, + "endsOn": 0, + "startsOn": 0 + }, + { + "dayOfWeek": 4, + "enabled": true, + "endsOn": 0, + "startsOn": 0 + }, + { + "dayOfWeek": 5, + "enabled": true, + "endsOn": 0, + "startsOn": 0 + }, + { + "dayOfWeek": 6, + "enabled": true, + "endsOn": 0, + "startsOn": 0 + }, + { + "dayOfWeek": 7, + "enabled": true, + "endsOn": 0, + "startsOn": 0 + } + ] +} +``` + +
    +
  • +timezone: this value is used to designate the timezone you are using. +
  • +
  • +items: the array of values representing the days on which the schedule will be active. +
  • +
+ +One array item contains such fields: +
    +
  • +dayOfWeek: this value is used to designate the specified day in numerical representation (Monday - 1, Tuesday 2, etc.) on which the schedule will be active. +
  • +
  • +enabled: this boolean value, used to designate that the specified day in the schedule will be enabled. +
  • +
  • +startsOn: this value is used to designate the timestamp in milliseconds, from which the schedule will be active for the designated day. +
  • +
  • +endsOn: this value is used to designate the timestamp in milliseconds until which the schedule will be active for the specified day. +
  • +
+When startsOn and endsOn equals 0 it's means that the schedule will be active the whole day. 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 ab5fd6a3d0..7c25770896 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -1076,6 +1076,7 @@ "argument-asset": "Asset", "argument-customer": "Customer", "argument-tenant": "Current tenant", + "argument-owner": "Current owner", "argument-relation-query": "Related entities", "argument-type": "Argument type", "see-debug-events": "See debug events", @@ -1244,6 +1245,137 @@ "metrics": "Defines metrics aggregated based on the configured arguments." } }, + "alarm-rule": { + "alarm-rules-tab": "Alarm rules", + "alarm-rule": "Alarm rule", + "alarm-rules": "Alarm rules", + "alarm-rules-old": "Old", + "alarm-rules-new": "New", + "severities": "Severities", + "cleared": "Cleared", + "delete-title": "Are you sure you want to delete the alarm rule '{{title}}'?", + "delete-text": "Be careful, after the confirmation the alarm rule and all related data will become unrecoverable.", + "delete-multiple-title": "Are you sure you want to delete { count, plural, =1 {1 alarm rule} other {# alarm rules} }?", + "delete-multiple-text": "Be careful, after the confirmation all selected alarm rules will be removed and all related data will become unrecoverable.", + "create": "Create new alarm rule", + "no-found": "No alarm rules found", + "list": "{ count, plural, =1 {One alarm rule} other {List of # alarm rules} }", + "selected-fields": "{ count, plural, =1 {1 alarm rule} other {# alarm rules} } selected", + "import": "Import alarm rule", + "export": "Export alarm rule", + "export-failed-error": "Unable to export alarm rule: {{error}}", + "alarm-type": "Alarm type", + "alarm-type-required": "Alarm type is required.", + "alarm-type-pattern": "Alarm type is invalid.", + "alarm-type-max-length": "Alarm type should be less than 256 characters.", + "clear-alarm": "Clear alarm", + "value-argument": "Argument", + "value-argument-required": "Argument is required.", + "static-settings": "Static settings", + "configuration": "Configuration", + "static-schedule": "Static", + "dynamic-schedule": "Dynamic", + "operation-and": "AND", + "operation-or": "OR", + "condition-during": "During {{during}}", + "condition-during-dynamic": "During \"{{ attribute }}\"", + "condition-repeat-times": "Repeats { count, plural, =1 {1 time} other {# times} }", + "condition-repeat-times-dynamic": "Repeats \"{ attribute }\"", + "filter-preview": "Filter preview", + "condition-settings": "Condition settings", + "static": "Static", + "dynamic": "Dynamic", + "argument-filters": "Argument filters", + "argument-name": "Argument name", + "value-type": "Value type", + "general": "General", + "filter": "Filter", + "operation": "Operation", + "value-source": "Value source", + "value": "Value", + "ignore-case": "Ignore case", + "condition": "Condition", + "script": "Script", + "add-filter": "Add filter", + "edit-filter": "Edit filter", + "conditions": { + "simple": "Simple", + "duration": "Duration", + "repeating": "Repeating" + }, + "schedule-title": "Schedule", + "edit-schedule": "Edit alarm schedule", + "schedule-type": "Scheduler type", + "schedule-type-required": "Scheduler type is required.", + "schedule": { + "any-time": "Active all the time", + "specific-time": "Active at a specific time", + "custom": "Custom" + }, + "schedule-day": { + "monday": "Monday", + "tuesday": "Tuesday", + "wednesday": "Wednesday", + "thursday": "Thursday", + "friday": "Friday", + "saturday": "Saturday", + "sunday": "Sunday" + }, + "schedule-days": "Days", + "schedule-time": "Time", + "schedule-time-from": "From", + "schedule-time-to": "To", + "schedule-days-of-week-required": "At least one day of week should be selected.", + "expression-type": { + "simple": "Simple", + "tbel": "TBEL" + }, + "operation-type": { + "and": "And", + "or": "Or" + }, + "filter-predicate-type": { + "string": "String", + "numeric": "Numeric", + "boolean": "Boolean", + "complex": "Complex" + }, + "alarm-rule-additional-info": "Additional info", + "edit-alarm-rule-additional-info": "Edit additional info", + "alarm-rule-additional-info-placeholder": "Please provide your comments and adjustments here to display them within Alarm details under Additional info", + "alarm-rule-additional-info-hint": "Hint: use ${Argument name} to substitute values of the arguments that are used in alarm rule condition.", + "alarm-rule-mobile-dashboard": "Mobile dashboard", + "alarm-rule-mobile-dashboard-hint": "Used by mobile application as an alarm details dashboard", + "alarm-rule-no-mobile-dashboard": "No dashboard selected", + "alarm-rule-condition": "Alarm rule condition", + "enter-alarm-rule-condition-prompt": "Please add alarm rule condition", + "edit-alarm-rule-condition": "Edit alarm rule condition", + "condition-type": "Condition type", + "select-alarm-severity": "Select alarm severity", + "add-create-alarm-rule-prompt": "Please add create alarm rule", + "add-create-alarm-rule": "Add create condition", + "add-clear-alarm-rule": "Add clear condition", + "condition-duration": "Condition duration", + "condition-duration-value": "Duration value", + "condition-duration-time-unit": "Time unit", + "condition-duration-value-range": "Duration value should be in a range from 1 to 2147483647.", + "condition-duration-value-pattern": "Duration value should be integers.", + "condition-duration-value-required": "Duration value is required.", + "condition-duration-time-unit-required": "Time unit is required.", + "condition-repeating-value": "Count of events", + "condition-repeating-value-range": "Count of events should be in a range from 1 to 2147483647.", + "condition-repeating-value-pattern": "Count of events should be integers.", + "condition-repeating-value-required": "Count of events is required.", + "create-alarm-rules": "Create alarm rules", + "clear-alarm-rule": "Clear alarm rule", + "no-clear-alarm-rule": "No clear condition configured", + "advanced-settings": "Advanced settings", + "propagate-alarm": "Propagate alarm to related entities", + "alarm-rule-relation-types-list": "Relation types", + "alarm-rule-relation-types-list-hint": "Defines relation types to filter the related entities. If not set, the alarm will be propagated to all related entities.", + "propagate-alarm-to-owner": "Propagate alarm to entity owner (Customer or Tenant)", + "propagate-alarm-to-tenant": "Propagate alarm to Tenant" + }, "ai-models": { "ai-models": "AI models", "ai-model": "AI model",