diff --git a/ui-ngx/src/app/modules/home/pages/notification-center/inbox-table/inbox-table-header.component.html b/ui-ngx/src/app/modules/home/pages/notification-center/inbox-table/inbox-table-header.component.html index aa34aed8ff..f3dc87ea90 100644 --- a/ui-ngx/src/app/modules/home/pages/notification-center/inbox-table/inbox-table-header.component.html +++ b/ui-ngx/src/app/modules/home/pages/notification-center/inbox-table/inbox-table-header.component.html @@ -20,7 +20,7 @@ style="width: 180px;" [ngModel]="entitiesTableConfig.componentsData.unreadOnly" (ngModelChange)="changeUnreadOnly($event)"> - Unread + {{ 'notification.unread' | translate }} {{ 'notification.all' | translate }} diff --git a/ui-ngx/src/app/modules/home/pages/notification-center/notification-center.component.html b/ui-ngx/src/app/modules/home/pages/notification-center/notification-center.component.html index bd7925d7a1..eaa9a183a0 100644 --- a/ui-ngx/src/app/modules/home/pages/notification-center/notification-center.component.html +++ b/ui-ngx/src/app/modules/home/pages/notification-center/notification-center.component.html @@ -41,9 +41,9 @@ - - Content 1 - Loaded - + + diff --git a/ui-ngx/src/app/modules/home/pages/notification-center/notification-center.module.ts b/ui-ngx/src/app/modules/home/pages/notification-center/notification-center.module.ts index 59ce9cc77e..7154bc617f 100644 --- a/ui-ngx/src/app/modules/home/pages/notification-center/notification-center.module.ts +++ b/ui-ngx/src/app/modules/home/pages/notification-center/notification-center.module.ts @@ -42,6 +42,10 @@ import { TemplateAutocompleteComponent } from '@home/pages/notification-center/template-table/template-autocomplete.component'; import { InboxTableHeaderComponent } from '@home/pages/notification-center/inbox-table/inbox-table-header.component'; +import { RuleTableHeaderComponent } from '@home/pages/notification-center/rule-table/rule-table-header.component'; +import { RuleNotificationDialogComponent } from '@home/pages/notification-center/rule-table/rule-notification-dialog.component'; +import { EscalationsComponent } from '@home/pages/notification-center/rule-table/escalations.component'; +import { EscalationFormComponent } from '@home/pages/notification-center/rule-table/escalation-form.component'; @NgModule({ declarations: [ @@ -53,7 +57,11 @@ import { InboxTableHeaderComponent } from '@home/pages/notification-center/inbox TemplateTableHeaderComponent, NotificationTableComponent, TemplateAutocompleteComponent, - InboxTableHeaderComponent + InboxTableHeaderComponent, + RuleTableHeaderComponent, + RuleNotificationDialogComponent, + EscalationsComponent, + EscalationFormComponent ], imports: [ CommonModule, diff --git a/ui-ngx/src/app/modules/home/pages/notification-center/notification-table/notification-table.component.ts b/ui-ngx/src/app/modules/home/pages/notification-center/notification-table/notification-table.component.ts index a07b5dbb46..10c3134459 100644 --- a/ui-ngx/src/app/modules/home/pages/notification-center/notification-table/notification-table.component.ts +++ b/ui-ngx/src/app/modules/home/pages/notification-center/notification-table/notification-table.component.ts @@ -26,6 +26,7 @@ import { InboxTableConfig } from '@home/pages/notification-center/notification-t import { DatePipe } from '@angular/common'; import { TemplateTableConfig } from '@home/pages/notification-center/notification-table/template-table-config'; import { RequestTableConfig } from '@home/pages/notification-center/notification-table/request-table-config'; +import { RuleTableConfig } from '@home/pages/notification-center/notification-table/rule-table-config'; @Component({ selector: 'tb-notification-table', @@ -85,6 +86,12 @@ export class NotificationTableComponent implements OnInit { this.dialog, this.datePipe ); + case EntityType.NOTIFICATION_RULE: + return new RuleTableConfig( + this.notificationService, + this.translate, + this.dialog + ); } } } diff --git a/ui-ngx/src/app/modules/home/pages/notification-center/notification-table/rule-table-config.ts b/ui-ngx/src/app/modules/home/pages/notification-center/notification-table/rule-table-config.ts new file mode 100644 index 0000000000..b89bdada7d --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification-center/notification-table/rule-table-config.ts @@ -0,0 +1,126 @@ +/// +/// Copyright © 2016-2022 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 { + CellActionDescriptor, + EntityTableColumn, + EntityTableConfig +} from '@home/models/entity/entities-table-config.models'; +import { EntityTypeResource } from '@shared/models/entity-type.models'; +import { Direction } from '@shared/models/page/sort-order'; +import { + NotificationRule, + NotificationTarget, + NotificationTargetConfigTypeTranslateMap, TriggerTypeTranslationMap +} from '@shared/models/notification.models'; +import { NotificationService } from '@core/http/notification.service'; +import { TranslateService } from '@ngx-translate/core'; +import { + TargetNotificationDialogComponent, + TargetsNotificationDialogData +} from '@home/pages/notification-center/targets-table/target-notification-dialog.componet'; +import { MatDialog } from '@angular/material/dialog'; +import { EntityAction } from '@home/models/entity/entity-component.models'; +import { + TargetTableHeaderComponent +} from '@home/pages/notification-center/targets-table/target-table-header.component'; +import { RuleTableHeaderComponent } from '@home/pages/notification-center/rule-table/rule-table-header.component'; +import { + RuleNotificationDialogComponent, + RuleNotificationDialogData +} from '@home/pages/notification-center/rule-table/rule-notification-dialog.component'; + +export class RuleTableConfig extends EntityTableConfig { + + constructor(private notificationService: NotificationService, + private translate: TranslateService, + private dialog: MatDialog) { + super(); + this.loadDataOnInit = false; + this.entityTranslations = { + noEntities: 'notification.no-rules-notification', + search: 'notification.search-rules' + }; + this.entityResources = {} as EntityTypeResource; + + this.entitiesFetchFunction = pageLink => this.notificationService.getNotificationRules(pageLink); + + this.deleteEntityTitle = rule => this.translate.instant('notification.delete-rule-title', {ruleName: rule.name}); + this.deleteEntityContent = () => this.translate.instant('notification.delete-rule-text'); + this.deleteEntity = id => this.notificationService.deleteNotificationRule(id.id); + + this.cellActionDescriptors = this.configureCellActions(); + this.headerComponent = RuleTableHeaderComponent; + this.onEntityAction = action => this.onTargetAction(action); + + this.defaultSortOrder = {property: 'name', direction: Direction.ASC}; + + this.columns.push( + new EntityTableColumn('name', 'notification.rule-name', '30%'), + new EntityTableColumn('templateName', 'notification.template', '20%'), + new EntityTableColumn('triggerType', 'notification.trigger.trigger', '20%', + (rule) => this.translate.instant(TriggerTypeTranslationMap.get(rule.triggerType)) || '', + () => ({}), true), + new EntityTableColumn('additionalConfig.description', 'notification.description', '30%', + (target) => target.additionalConfig.description || '', + () => ({}), false) + ); + } + + private configureCellActions(): Array> { + return [{ + name: this.translate.instant('notification.copy-rule'), + icon: 'content_copy', + isEnabled: () => true, + onAction: ($event, entity) => this.editRule($event, entity, false, true) + }, { + name: this.translate.instant('notification.edit-rule'), + icon: 'edit', + isEnabled: () => true, + onAction: ($event, entity) => this.editRule($event, entity) + }]; + } + + private editRule($event: Event, rule: NotificationRule, isAdd = false, isCopy = false) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(RuleNotificationDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + isAdd, + isCopy, + rule + } + }).afterClosed() + .subscribe((res) => { + if (res) { + this.updateData(); + } + }); + } + + private onTargetAction(action: EntityAction): boolean { + switch (action.action) { + case 'add': + this.editRule(action.event, action.entity, true); + return true; + } + return false; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/notification-center/rule-table/escalation-form.component.html b/ui-ngx/src/app/modules/home/pages/notification-center/rule-table/escalation-form.component.html new file mode 100644 index 0000000000..397ced14d6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification-center/rule-table/escalation-form.component.html @@ -0,0 +1,42 @@ + + +
+
+
+
notification.first-recipient
+
+ After + + notification.notify +
+
+
+ + +
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/notification-center/rule-table/escalation-form.component.scss b/ui-ngx/src/app/modules/home/pages/notification-center/rule-table/escalation-form.component.scss new file mode 100644 index 0000000000..f80a0a7cef --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification-center/rule-table/escalation-form.component.scss @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2022 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 { + .escalation { + height: 100%; + min-height: 74px; + max-height: 100%; + border-radius: 8px; + background: rgba(0,0,0,0.04); + } + + .escalation-padding { + padding: 0 10px; + } + + .escalation-notify { + text-align: end; + padding: 0 10px; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/notification-center/rule-table/escalation-form.component.ts b/ui-ngx/src/app/modules/home/pages/notification-center/rule-table/escalation-form.component.ts new file mode 100644 index 0000000000..e5c638ba3c --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification-center/rule-table/escalation-form.component.ts @@ -0,0 +1,140 @@ +/// +/// Copyright © 2016-2022 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 { + ControlValueAccessor, + FormBuilder, + FormControl, + FormGroup, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + Validator, + Validators +} from '@angular/forms'; +import { UtilsService } from '@core/services/utils.service'; +import { isDefinedAndNotNull } from '@core/utils'; +import { Subject } from 'rxjs'; +import { NonConfirmedNotificationEscalation } from '@shared/models/notification.models'; +import { EntityType } from '@shared/models/entity-type.models'; +import { takeUntil } from 'rxjs/operators'; + +@Component({ + selector: 'tb-escalation-form', + templateUrl: './escalation-form.component.html', + styleUrls: ['./escalation-form.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EscalationFormComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => EscalationFormComponent), + multi: true, + } + ] +}) +export class EscalationFormComponent implements ControlValueAccessor, OnInit, OnDestroy, Validator { + + @Input() + disabled: boolean; + + @Input() + systemEscalation = false; + + escalationFormGroup: FormGroup; + + entityType = EntityType; + + private modelValue; + private propagateChange = null; + private propagateChangePending = false; + private destroy$ = new Subject(); + + constructor(private utils: UtilsService, + private fb: FormBuilder) { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + if (this.propagateChangePending) { + this.propagateChangePending = false; + setTimeout(() => { + this.propagateChange(this.modelValue); + }, 0); + } + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + this.escalationFormGroup = this.fb.group( + { + delayInSec: [0], + targets: [null, Validators.required], + }); + this.escalationFormGroup.valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(() => this.updateModel()); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.escalationFormGroup.disable({emitEvent: false}); + } else { + this.escalationFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: NonConfirmedNotificationEscalation): void { + this.propagateChangePending = false; + this.modelValue = value; + this.modelValue.delayInSec = +value.delayInSec; + if (isDefinedAndNotNull(this.modelValue)) { + this.escalationFormGroup.patchValue(this.modelValue, {emitEvent: false}); + } + if (!this.disabled && !this.escalationFormGroup.valid) { + this.updateModel(); + } + } + + public validate(c: FormControl) { + return (this.escalationFormGroup.valid) ? null : { + escalation: { + valid: false, + }, + }; + } + + private updateModel() { + const value = this.escalationFormGroup.value; + this.modelValue = {...this.modelValue, ...value}; + if (this.propagateChange) { + this.propagateChange(this.modelValue); + } else { + this.propagateChangePending = true; + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/notification-center/rule-table/escalations.component.html b/ui-ngx/src/app/modules/home/pages/notification-center/rule-table/escalations.component.html new file mode 100644 index 0000000000..e11a1227f7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification-center/rule-table/escalations.component.html @@ -0,0 +1,45 @@ + +
+
+ + + +
+
+ notification.no-rule +
+
+ +
+
diff --git a/ui-ngx/src/app/modules/home/pages/notification-center/rule-table/escalations.component.ts b/ui-ngx/src/app/modules/home/pages/notification-center/rule-table/escalations.component.ts new file mode 100644 index 0000000000..578355e8d3 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification-center/rule-table/escalations.component.ts @@ -0,0 +1,172 @@ +/// +/// Copyright © 2016-2022 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 } from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + FormArray, + FormBuilder, + FormGroup, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@app/core/core.state'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { Subject } from 'rxjs'; +import { UtilsService } from '@core/services/utils.service'; +import { NonConfirmedNotificationEscalation } from '@shared/models/notification.models'; +import { takeUntil } from 'rxjs/operators'; + +@Component({ + selector: 'tb-escalations-component', + templateUrl: './escalations.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EscalationsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => EscalationsComponent), + multi: true, + } + ] +}) +export class EscalationsComponent implements ControlValueAccessor, Validator, OnDestroy { + + escalationsFormGroup: FormGroup; + newEscalation = false; + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + @Input() + disabled: boolean; + + private mainEscalaion = { + delayInSec: 0, + targets: null + }; + + private destroy$ = new Subject(); + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + private utils: UtilsService, + private fb: FormBuilder) { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + this.escalationsFormGroup = this.fb.group({ + escalations: this.fb.array([]) + }); + + this.escalationsFormGroup.valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(() => this.updateModel()); + } + + get escalationsFormArray(): FormArray { + return this.escalationsFormGroup.get('escalations') as FormArray; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.escalationsFormGroup.disable({emitEvent: false}); + } else { + this.escalationsFormGroup.enable({emitEvent: false}); + } + } + + writeValue(escalations: Array | null): void { + if (escalations?.length === this.escalationsFormArray.length) { + this.escalationsFormArray.patchValue(escalations, {emitEvent: false}); + } else { + const escalationsControls: Array = []; + if (escalations) { + escalations.forEach((escalation, index) => { + escalationsControls.push(this.fb.control(escalation, [Validators.required])); + }); + } else { + escalationsControls.push(this.fb.control(this.mainEscalaion, [Validators.required])); + } + this.escalationsFormGroup.setControl('escalations', this.fb.array(escalationsControls), {emitEvent: false}); + if (this.disabled) { + this.escalationsFormGroup.disable({emitEvent: false}); + } else { + this.escalationsFormGroup.enable({emitEvent: false}); + } + } + } + + public removeEscalation(index: number) { + (this.escalationsFormGroup.get('escalations') as FormArray).removeAt(index); + } + + public addEscalation() { + const escalation = { + delayInSec: 0, + targets: null + }; + this.newEscalation = true; + const escalationArray = this.escalationsFormGroup.get('escalations') as FormArray; + escalationArray.push(this.fb.control(escalation, [])); + this.escalationsFormGroup.updateValueAndValidity(); + if (!this.escalationsFormGroup.valid) { + this.updateModel(); + } + } + + public validate(c: AbstractControl): ValidationErrors | null { + return this.escalationsFormGroup.valid ? null : { + escalation: { + valid: false, + }, + }; + } + + private updateModel() { + const escalations = this.escalationsFormGroup.get('escalations').value; + this.propagateChange(escalations); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/notification-center/rule-table/rule-notification-dialog.component.html b/ui-ngx/src/app/modules/home/pages/notification-center/rule-table/rule-notification-dialog.component.html new file mode 100644 index 0000000000..6d1d803095 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification-center/rule-table/rule-notification-dialog.component.html @@ -0,0 +1,245 @@ + +
+ +

{{ dialogTitle | translate }}

+ + +
+ + +
+
+ + + check + + + {{ 'notification.basic-settings' | translate }} +
+ + notification.rule-name + + + {{ 'notification.rule-name-required' | translate }} + + + + + + notification.trigger.trigger + + + {{ triggerTypeTranslationMap.get(trigger) | translate }} + + + + {{ 'notification.trigger.trigger-required' | translate }} + + +
+
+ + {{ 'notification.type-settings' | translate }} +
+
+ notification.filter + + alarm.alarm-type-list + + + {{type}} + cancel + + + + + + + alarm.alarm-severity-list + + + {{ alarmSeverityTranslationMap.get(severity) | translate }} + cancel + + + + + + + + +
+
+ notification.no-severity-found +
+ + + {{ translate.get('notification.no-severity-matching', + {severity: truncate.transform(severitySearchText, true, 6, '...')}) | async }} + + +
+
+
+
+
+ +
+ notification.clear-rule + + alarm.alarm-status-list + + + {{ alarmSearchStatusTranslationMap.get(searchStatus) | translate }} + + + +
+ +
+ notification.hierarchy-of-receiving + +
+ + + notification.description + + +
+
+ + + {{ 'notification.type-settings' | translate }} +
+ notification.filter-by +
+ + {{ 'notification.device' | translate }} + {{ 'notification.device-profile' | translate }} + +
+ + + + + + + + notification.description + + +
+
+ + + {{ 'notification.type-settings' | translate }} +
+
+ notification.filter + + +
+ notification.status + {{ 'notification.created' | translate }} + {{ 'notification.updated' | translate }} + {{ 'notification.deleted' | translate }} +
+
+ + + + notification.description + + +
+
+
+
+ +
+ + + +
+
diff --git a/ui-ngx/src/app/modules/home/pages/notification-center/rule-table/rule-notification-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/notification-center/rule-table/rule-notification-dialog.component.scss new file mode 100644 index 0000000000..99f06b7afc --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification-center/rule-table/rule-notification-dialog.component.scss @@ -0,0 +1,85 @@ +/** + * Copyright © 2016-2022 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 "../../../../../../theme"; + +:host { + ::ng-deep{ + + .mat-button-toggle-group.tb-notification-unread-toggle-group { + &.mat-button-toggle-group-appearance-standard { + border: none; + border-radius: 14px; + + .mat-button-toggle + .mat-button-toggle { + border-left: none; + } + } + + .mat-button-toggle { + background: rgba(0, 0, 0, 0.06); + height: 28px; + align-items: center; + display: flex; + + .mat-button-toggle-ripple { + top: 2px; + left: 2px; + right: 2px; + bottom: 2px; + border-radius: 12px; + } + } + + .mat-button-toggle-button { + color: #959595; + } + + .mat-button-toggle-focus-overlay { + border-radius: 14px; + margin: 2px; + } + + .mat-button-toggle-checked .mat-button-toggle-button { + background-color: $tb-primary-color; + color: #fff; + border-radius: 14px; + margin-left: 2px; + margin-right: 2px; + } + + .mat-button-toggle-appearance-standard .mat-button-toggle-label-content { + line-height: 24px; + font-size: 16px; + font-weight: 500; + } + + .mat-button-toggle-checked.mat-button-toggle-appearance-standard:not(.mat-button-toggle-disabled):hover .mat-button-toggle-focus-overlay { + opacity: .01; + } + } + } + .fields-group { + padding: 0 16px 8px; + margin-bottom: 10px; + border: 1px groove rgba(0, 0, 0, .25); + border-radius: 4px; + .fields-group-title { + display: inline-block; + font-weight: 500; + padding: 12px 0; + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/notification-center/rule-table/rule-notification-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/notification-center/rule-table/rule-notification-dialog.component.ts new file mode 100644 index 0000000000..bd256bc4b5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification-center/rule-table/rule-notification-dialog.component.ts @@ -0,0 +1,436 @@ +/// +/// Copyright © 2016-2022 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 { NotificationRule, TriggerType, TriggerTypeTranslationMap } from '@shared/models/notification.models'; +import { Component, ElementRef, Inject, OnDestroy, ViewChild } from '@angular/core'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Router } from '@angular/router'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { NotificationService } from '@core/http/notification.service'; +import { EntityType } from '@shared/models/entity-type.models'; +import { deepClone, deepTrim, isDefined } from '@core/utils'; +import { Observable, of, Subject } from 'rxjs'; +import { map, mergeMap, share, startWith, takeUntil } from 'rxjs/operators'; +import { StepperOrientation, StepperSelectionEvent } from '@angular/cdk/stepper'; +import { MatStepper } from '@angular/material/stepper'; +import { MediaBreakpoints } from '@shared/models/constants'; +import { BreakpointObserver } from '@angular/cdk/layout'; +import { MatChipInputEvent, MatChipList } from '@angular/material/chips'; +import { COMMA, ENTER, SEMICOLON } from '@angular/cdk/keycodes'; +import { + AlarmSeverity, + alarmSeverityTranslations, + AlarmStatus, + alarmStatusTranslations +} from '@shared/models/alarm.models'; +import { MatAutocomplete, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; +import { TranslateService } from '@ngx-translate/core'; +import { TruncatePipe } from '@shared/pipe/truncate.pipe'; + +export interface RuleNotificationDialogData { + rule?: NotificationRule; + isAdd?: boolean; + isCopy?: boolean; +} + +@Component({ + selector: 'tb-rule-notification-dialog', + templateUrl: './rule-notification-dialog.component.html', + styleUrls: ['rule-notification-dialog.component.scss'] +}) +export class RuleNotificationDialogComponent extends + DialogComponent implements OnDestroy { + + @ViewChild('addNotificationRule', {static: true}) addNotificationRule: MatStepper; + + @ViewChild('severitiesChipList') severitiesChipList: MatChipList; + @ViewChild('severityAutocomplete') severityAutocomplete: MatAutocomplete; + @ViewChild('severityInput') severityInput: ElementRef; + + readonly separatorKeysCodes: number[] = [ENTER, COMMA, SEMICOLON]; + + stepperOrientation: Observable; + + ruleNotificationForm: FormGroup; + alarmTemplateForm: FormGroup; + deviceInactivityTemplateForm: FormGroup; + entityActionTemplateForm: FormGroup; + + triggerType = TriggerType; + triggerTypes: TriggerType[] = Object.values(TriggerType); + triggerTypeTranslationMap = TriggerTypeTranslationMap; + + alarmSeverities = Object.keys(AlarmSeverity); + alarmSeverityEnum = AlarmSeverity; + alarmSeverityTranslationMap = alarmSeverityTranslations; + + alarmSearchStatuses = Object.values(AlarmStatus); + alarmSearchStatusTranslationMap = alarmStatusTranslations; + + entityType = EntityType; + entityTypes = Object.values(EntityType); + isAdd = true; + + selectedIndex = 0; + + filteredDisplaySeverities: Observable>; + severitySearchText = ''; + + dialogTitle = 'notification.edit-rule'; + + severityInputChange = new Subject(); + + private destroy$ = new Subject(); + + private readonly ruleNotification: NotificationRule; + + constructor(protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: RuleNotificationDialogData, + private breakpointObserver: BreakpointObserver, + private fb: FormBuilder, + public translate: TranslateService, + public truncate: TruncatePipe, + private notificationService: NotificationService) { + super(store, router, dialogRef); + + if (isDefined(data.isAdd)) { + this.isAdd = data.isAdd; + } + + this.stepperOrientation = this.breakpointObserver.observe(MediaBreakpoints['gt-xs']) + .pipe(map(({matches}) => matches ? 'horizontal' : 'vertical')); + + this.ruleNotificationForm = this.fb.group({ + name: [null, Validators.required], + templateId: [null, Validators.required], + triggerType: [null, Validators.required], + recipientsConfig: this.fb.group({ + triggerType: [], + }), + triggerConfig: this.fb.group({ + triggerType: [] + }), + additionalConfig: this.fb.group({ + description: [] + }) + }); + + this.ruleNotificationForm.get('triggerType').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe( + value => { + this.ruleNotificationForm.get('triggerConfig').patchValue({triggerType: value}, {emitEvent: false}); + this.ruleNotificationForm.get('recipientsConfig').patchValue({triggerType: value}, {emitEvent: false}); + } + ); + + this.alarmTemplateForm = this.fb.group({ + alarmTypes: [[], Validators.required], + alarmSeverities: [[], Validators.required], + clearRule: this.fb.group({ + alarmStatus: [] + }), + escalationTable: [], + description: [''] + }); + + this.deviceInactivityTemplateForm = this.fb.group({ + filterByDevice: [true], + devices: [], + deviceProfiles: [], + targets: [[], Validators.required], + description: [''] + }); + + this.deviceInactivityTemplateForm.get('filterByDevice').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe( + value => this.deviceInactivityTemplateForm.get(value ? 'deviceProfiles' : 'devices').patchValue(null, {emitEvent: false}) + ); + + this.entityActionTemplateForm = this.fb.group({ + entityType: [], + created: [false], + updated: [false], + deleted: [false], + targets: [[], Validators.required], + description: [''] + }); + + this.filteredDisplaySeverities = this.severityInputChange + .pipe( + startWith(''), + map((value) => value ? value : ''), + mergeMap(name => this.fetchSeverities(name) ), + share() + ); + + if (data.isAdd || data.isCopy) { + this.dialogTitle = 'notification.add-rule'; + } + this.ruleNotification = deepClone(this.data.rule); + + if (this.ruleNotification) { + if (this.data.isCopy) { + this.ruleNotification.name += ` (${this.translate.instant('action.copy')})`; + } + this.ruleNotificationForm.reset({}, {emitEvent: false}); + this.ruleNotificationForm.patchValue(this.ruleNotification, {emitEvent: false}); + if (this.ruleNotification.triggerType === TriggerType.ALARM) { + const parsedEscalationTable = []; + const escalationTable = this.ruleNotification.recipientsConfig.escalationTable; + for (const escalation in escalationTable) { + parsedEscalationTable.push({delayInSec: escalation, targets: escalationTable[escalation]}); + } + this.alarmTemplateForm.patchValue({ + escalationTable: parsedEscalationTable, + alarmTypes: this.ruleNotification.triggerConfig.alarmTypes, + alarmSeverities: this.ruleNotification.triggerConfig.alarmSeverities, + clearRule: this.ruleNotification.triggerConfig.clearRule, + description: this.ruleNotification.additionalConfig.description + }, {emitEvent: false}); + } else if (this.ruleNotification.triggerType === TriggerType.DEVICE_INACTIVITY) { + this.deviceInactivityTemplateForm.patchValue({ + filterByDevice: !!this.ruleNotification.triggerConfig.devices, + deviceProfiles: this.ruleNotification.triggerConfig.devicesProfiles, + devices: this.ruleNotification.triggerConfig.devices, + targets: this.ruleNotification.recipientsConfig.targets, + description: this.ruleNotification.additionalConfig.description + }, {emitEvent: false}); + } else { + this.entityActionTemplateForm.patchValue({ + entityType: this.ruleNotification.triggerConfig.entityType, + created: this.ruleNotification.triggerConfig.created, + updated: this.ruleNotification.triggerConfig.updated, + deleted: this.ruleNotification.triggerConfig.deleted, + targets: this.ruleNotification.recipientsConfig.targets + }, {emitEvent: false}); + } + } + } + + onSeverityRemoved(severity: string): void { + const severities: string[] = this.alarmTemplateForm.get('alarmSeverities').value; + const index = severities.indexOf(severity); + if (index > -1) { + severities.splice(index, 1); + this.alarmTemplateForm.get('alarmSeverities').setValue(severities); + this.alarmTemplateForm.get('alarmSeverities').markAsDirty(); + this.severitiesChipList.errorState = !severities.length; + } + } + + onSeverityInputFocus() { + this.severityInputChange.next(this.severityInput.nativeElement.value); + } + + addSeverityFromChipInput(event: MatChipInputEvent): void { + const value = event.value; + if ((value || '').trim()) { + const severityName = value.trim().toUpperCase(); + const existingSeverity = this.alarmSeverities.find(severity => severity.toUpperCase() === severityName); + if (this.addSeverity(existingSeverity)) { + this.clearSeverityInput(''); + } + } + } + + private addSeverity(existingSeverity: string): boolean { + if (existingSeverity) { + const displaySeverities: string[] = this.alarmTemplateForm.get('alarmSeverities').value; + const index = displaySeverities.indexOf(existingSeverity); + if (index === -1) { + displaySeverities.push(existingSeverity); + this.alarmTemplateForm.get('alarmSeverities').setValue(displaySeverities); + this.alarmTemplateForm.get('alarmSeverities').markAsDirty(); + this.severitiesChipList.errorState = false; + return true; + } + } + return false; + } + + clearSeverityInput(value: string = '') { + this.severityInput.nativeElement.value = value; + this.severityInputChange.next(null); + setTimeout(() => { + this.severityInput.nativeElement.blur(); + this.severityInput.nativeElement.focus(); + }, 0); + } + + severitySelected(event: MatAutocompleteSelectedEvent): void { + this.addSeverity(event.option.value); + this.clearSeverityInput(''); + } + + displaySeverityFn(severity?: string): string | undefined { + return severity ? this.translate.instant(this.alarmSeverityTranslationMap.get(this.alarmSeverityEnum[severity])) : undefined; + } + + private fetchSeverities(searchText?: string): Observable> { + this.severitySearchText = searchText; + if (this.severitySearchText && this.severitySearchText.length) { + const search = this.severitySearchText.toUpperCase(); + return of(this.alarmSeverities.filter(severity => severity.toUpperCase().includes(search))); + } else { + return of(this.alarmSeverities); + } + } + + textIsNotEmpty(text: string): boolean { + return (text && text.length > 0); + } + + public alarmTypeList(): string[] { + return this.alarmTemplateForm.get('alarmTypes').value; + } + + public removeAlarmType(type: string): void { + const types: string[] = this.alarmTemplateForm.get('alarmTypes').value; + const index = types.indexOf(type); + if (index >= 0) { + types.splice(index, 1); + this.alarmTemplateForm.get('alarmTypes').setValue(types); + this.alarmTemplateForm.get('alarmTypes').markAsDirty(); + } + } + + public addAlarmType(event: MatChipInputEvent): void { + const input = event.input; + const value = event.value; + + const types: string[] = this.alarmTemplateForm.get('alarmTypes').value; + + if ((value || '').trim()) { + types.push(value.trim()); + this.alarmTemplateForm.get('alarmTypes').setValue(types); + this.alarmTemplateForm.get('alarmTypes').markAsDirty(); + } + + if (input) { + input.value = ''; + } + } + + changeStep($event: StepperSelectionEvent) { + this.selectedIndex = $event.selectedIndex; + } + + backStep() { + this.addNotificationRule.previous(); + } + + nextStep() { + if (this.selectedIndex >= this.maxStepperIndex) { + this.add(); + } else { + this.addNotificationRule.next(); + } + } + + nextStepLabel(): string { + if (this.selectedIndex === 1 && this.selectedIndex < this.maxStepperIndex && this.alarmTemplateForm.pristine) { + return 'action.skip'; + } + if (this.selectedIndex === 2 && this.selectedIndex < this.maxStepperIndex && this.deviceInactivityTemplateForm.pristine) { + return 'action.skip'; + } + if (this.selectedIndex === 3 && this.selectedIndex < this.maxStepperIndex && this.entityActionTemplateForm.pristine) { + return 'action.skip'; + } + if (this.selectedIndex !== 0 && this.selectedIndex >= this.maxStepperIndex) { + return 'action.add'; + } + return 'action.next'; + } + + private get maxStepperIndex(): number { + return this.addNotificationRule?._steps?.length - 1; + } + + private add(): void { + if (this.allValid()) { + let formValue = this.ruleNotificationForm.value; + const triggerType = this.ruleNotificationForm.get('triggerType').value; + if (triggerType === TriggerType.ALARM) { + Object.assign(formValue.triggerConfig, this.alarmTemplateForm.value); + const parsedEscalationTable = {}; + this.alarmTemplateForm.get('escalationTable').value.forEach( + escalation => parsedEscalationTable[escalation.delayInSec] = escalation.targets + ); + formValue.recipientsConfig.escalationTable = parsedEscalationTable; + formValue.additionalConfig.description = this.alarmTemplateForm.get('description').value; + delete formValue.triggerConfig.escalationTable; + } else if (triggerType === TriggerType.DEVICE_INACTIVITY) { + Object.assign(formValue.triggerConfig, this.deviceInactivityTemplateForm.value); + formValue.recipientsConfig.targets = this.deviceInactivityTemplateForm.get('targets').value; + formValue.additionalConfig.description = this.deviceInactivityTemplateForm.get('description').value; + delete formValue.triggerConfig.filterByDevice; + } else { + Object.assign(formValue.triggerConfig, this.entityActionTemplateForm.value); + formValue.recipientsConfig.targets = this.entityActionTemplateForm.get('targets').value; + formValue.additionalConfig.description = this.entityActionTemplateForm.get('description').value; + } + if (triggerType === TriggerType.DEVICE_INACTIVITY || triggerType === TriggerType.ENTITY_ACTION) { + delete formValue.triggerConfig.trigger; + } + if (this.ruleNotification) { + formValue = {...this.ruleNotification, ...formValue}; + } + this.notificationService.saveNotificationRule(deepTrim(formValue)).subscribe( + (target) => this.dialogRef.close(target) + ); + } + } + + private allValid(): boolean { + return !this.addNotificationRule.steps.find((item, index) => { + if (item.stepControl.invalid) { + item.interacted = true; + this.addNotificationRule.selectedIndex = index; + return true; + } else { + return false; + } + }); + } + + ngOnDestroy() { + super.ngOnDestroy(); + this.destroy$.next(); + this.destroy$.complete(); + } + + cancel(): void { + this.dialogRef.close(null); + } + + save() { + let formValue = deepTrim(this.ruleNotificationForm.value); + if (isDefined(this.data.rule)) { + formValue = Object.assign({}, this.data.rule, formValue); + } + this.notificationService.saveNotificationRule(formValue).subscribe( + (rule) => this.dialogRef.close(rule) + ); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/notification-center/rule-table/rule-table-header.component.html b/ui-ngx/src/app/modules/home/pages/notification-center/rule-table/rule-table-header.component.html new file mode 100644 index 0000000000..01c921cb5b --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification-center/rule-table/rule-table-header.component.html @@ -0,0 +1,24 @@ + +
+ +
diff --git a/ui-ngx/src/app/modules/home/pages/notification-center/rule-table/rule-table-header.component.scss b/ui-ngx/src/app/modules/home/pages/notification-center/rule-table/rule-table-header.component.scss new file mode 100644 index 0000000000..f3554a23f8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification-center/rule-table/rule-table-header.component.scss @@ -0,0 +1,18 @@ +/** + * Copyright © 2016-2022 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{ + width: 100%; +} diff --git a/ui-ngx/src/app/modules/home/pages/notification-center/rule-table/rule-table-header.component.ts b/ui-ngx/src/app/modules/home/pages/notification-center/rule-table/rule-table-header.component.ts new file mode 100644 index 0000000000..7107a3901d --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification-center/rule-table/rule-table-header.component.ts @@ -0,0 +1,38 @@ +/// +/// Copyright © 2016-2022 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 } from '@angular/core'; +import { EntityTableHeaderComponent } from '@home/components/entity/entity-table-header.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { NotificationRule } from '@shared/models/notification.models'; + +@Component({ + selector: 'tb-rule-table-header', + templateUrl: './rule-table-header.component.html', + styleUrls: ['rule-table-header.component.scss'] +}) +export class RuleTableHeaderComponent extends EntityTableHeaderComponent { + + constructor(protected store: Store) { + super(store); + } + + createTarget($event: Event) { + this.entitiesTableConfig.onEntityAction({event: $event, action: 'add', entity: null}); + } + +} diff --git a/ui-ngx/src/app/shared/components/time/timeinterval.component.html b/ui-ngx/src/app/shared/components/time/timeinterval.component.html index 6d40e2f491..01dac82750 100644 --- a/ui-ngx/src/app/shared/components/time/timeinterval.component.html +++ b/ui-ngx/src/app/shared/components/time/timeinterval.component.html @@ -51,7 +51,7 @@ -
+
diff --git a/ui-ngx/src/app/shared/components/time/timeinterval.component.ts b/ui-ngx/src/app/shared/components/time/timeinterval.component.ts index 093435add4..7a4f3d4153 100644 --- a/ui-ngx/src/app/shared/components/time/timeinterval.component.ts +++ b/ui-ngx/src/app/shared/components/time/timeinterval.component.ts @@ -78,6 +78,8 @@ export class TimeintervalComponent implements OnInit, ControlValueAccessor { this.hideFlagValue = val; } + @Input() disabledAdvanced = false; + @Output() hideFlagChange = new EventEmitter(); @Input() disabled: boolean; diff --git a/ui-ngx/src/app/shared/models/entity-type.models.ts b/ui-ngx/src/app/shared/models/entity-type.models.ts index 165e011d7f..0550523a1a 100644 --- a/ui-ngx/src/app/shared/models/entity-type.models.ts +++ b/ui-ngx/src/app/shared/models/entity-type.models.ts @@ -331,14 +331,51 @@ export const entityTypeTranslations = new Map, 'label'>{ tenantId: TenantId; templateId: NotificationTemplateId; - deliveryMethods: Array; - configuration: NotificationRuleConfig; + triggerType: TriggerType; + triggerConfig: NotificationRuleTriggerConfig; + recipientsConfig: NotificationRuleRecipientConfig; + additionalConfig: {description: string}; } -export interface NotificationRuleConfig { - initialNotificationTargetId: NotificationTargetId; - escalationConfig: NotificationEscalationConfig; +export interface NotificationRuleTriggerConfig { + alarmTypes?: Array; + alarmSeverities?: Array; + clearRule?: AlarmStatus; + devices?: Array; + devicesProfiles?: Array; + entityType?: EntityType; + created?: boolean; + updated?: boolean; + deleted?: boolean; } -export interface NotificationEscalationConfig { - escalations: Array; +export interface NotificationRuleRecipientConfig { + targets?: Array; + escalationTable?: {[key: string]: Array}; } export interface NonConfirmedNotificationEscalation { @@ -300,3 +312,15 @@ export const NotificationTemplateTypeTranslateMap = new Map([ + [TriggerType.ALARM, 'notification.trigger.alarm'], + [TriggerType.DEVICE_INACTIVITY, 'notification.trigger.device-inactivity'], + [TriggerType.ENTITY_ACTION, 'notification.trigger.entity-action'] +]); 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 deb75f885a..2fe65d5780 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -1960,7 +1960,14 @@ "list-of-edges": "{ count, plural, 1 {One edge} other {List of # edges} }", "edge-name-starts-with": "Edges whose names start with '{{prefix}}'", "type-tb-resource": "Resource", - "type-ota-package": "OTA package" + "type-ota-package": "OTA package", + "type-rpc": "RPC", + "type-queue": "Queue", + "type-notification": "Notification", + "type-notification-rule": "Notification rule", + "type-notification-target": "Notification recipient", + "type-notification-request": "Notification request", + "type-notification-template": "Notification template" }, "entity-field": { "created-time": "Created time", @@ -2682,14 +2689,17 @@ "add-notification-template": "Add notification template", "add-target": "Add recipient", "add-template": "Add template", - "all": "All", "at-least-one-should-be-selected": "At least one should be selected", + "all": "All", "basic-settings": "Basic settings", "button-text": "Button text", "button-text-required": "Button text is required", "compose": "Compose", "conversation": "Conversation", "conversation-required": "Conversation is required", + "add-rule": "Add rule", + "edit-rule": "Edit rule", + "copy-rule": "Copy rule", "copy-notification-template": "Copy notification template", "copy-template": "Copy template", "create-target": "Create recipient", @@ -2707,6 +2717,8 @@ "slack": "Slack", "sms": "SMS" }, + "delete-rule-text": "Be careful, after the confirmation the notification rule will become unrecoverable.", + "delete-rule-title": "Are you sure you want to delete the notification rule '{{ruleName}}'?", "delivery-methods": "Delivery methods", "description": "Description", "edit-notification-target": "Edit notification recipient", @@ -2740,12 +2752,15 @@ "sent": "Sent" }, "review": "Review", + "no-rules-notification": "No rules notification", + "rule": "Rule", "rules": "Rules", "platform-users": "Platform users", "scheduler-later": "Schedule for later", "search-notification": "Search notification", "search-targets": "Search recipients", "search-templates": "Search templates", + "search-rules": "Search rules", "send-notification": "Send notification", "send-test-notification": "Send test notification", "sent": "Sent", @@ -2774,6 +2789,7 @@ }, "targets": "Recipients", "targets-required": "Recipients is required", + "recipient": "Recipient", "template": "Template", "template-required": "Template is required", "template-type": { @@ -2794,7 +2810,36 @@ "type": "Type", "unread": "Unread", "view-all": "View all", - "web-settings": "Web settings" + "web-settings": "Web settings", + "rule-name": "Rule name", + "rule-name-required": "Name is required", + "template-name": "Template name", + "hierarchy-of-receiving": "Hierarchy of receiving notifications", + "trigger": { + "trigger": "Trigger", + "trigger-required": "Trigger is required", + "alarm": "Alarm", + "device-inactivity": "Device inactivity", + "entity-action": "Entity action" + }, + "trigger-settings": "Trigger settings", + "filter": "Filter", + "clear-rule": "Clear rule", + "no-severity-matching": "'{{severity}}' not found.", + "no-severity-found": "No severity found", + "first-recipient": "First recipient", + "notify": "notify", + "no-rule": "No rule configured", + "device": "Device", + "devices": "Devices", + "device-profile": "Device profile", + "device-profiles": "Device profiles", + "filter-by": "Filter by", + "entity-type": "Entity type", + "created": "Created", + "updated": "Updated", + "deleted": "Deleted", + "type-settings": "Type settings" }, "ota-update": { "add": "Add package",