From 515646da6756d69a9b187aa6805ee826cba43281 Mon Sep 17 00:00:00 2001 From: Dmitriymush Date: Mon, 24 Oct 2022 11:38:08 +0300 Subject: [PATCH 001/100] merge w/ master and refactoring --- .../system/widget_bundles/input_widgets.json | 3 +- .../lib/multiple-input-widget.component.html | 35 ++++++++++++++ .../lib/multiple-input-widget.component.ts | 48 +++++++++++++++++-- ...ple-attributes-key-settings.component.html | 3 ++ ...tiple-attributes-key-settings.component.ts | 6 ++- .../json-object-edit-dialog.component.html | 4 +- .../json-object-edit-dialog.component.ts | 6 +++ ui-ngx/src/app/shared/shared.module.ts | 1 + .../assets/locale/locale.constant-en_US.json | 2 + 9 files changed, 99 insertions(+), 9 deletions(-) diff --git a/application/src/main/data/json/system/widget_bundles/input_widgets.json b/application/src/main/data/json/system/widget_bundles/input_widgets.json index 3c9a9fe718..f21c059119 100644 --- a/application/src/main/data/json/system/widget_bundles/input_widgets.json +++ b/application/src/main/data/json/system/widget_bundles/input_widgets.json @@ -324,9 +324,8 @@ "templateHtml": "
\n
\n
\n
\n
\n \n {{ settings.showLabel ? labelValue : '' }}\n \n \n {{requiredErrorMessage}}\n \n \n
\n \n
\n \n \n
\n
\n \n
\n {{ 'widgets.input-widgets.no-entity-selected' | translate }}\n
\n
\n {{ 'widgets.input-widgets.no-attribute-selected' | translate }}\n
\n
\n {{ 'widgets.input-widgets.timeseries-not-allowed' | translate }}\n
\n
\n
\n
", "templateCss": ".attribute-update-form {\n overflow: hidden;\n height: 100%;\n display: flex;\n flex-direction: column;\n}\n\n.attribute-update-form__grid {\n display: flex;\n}\n.grid__element:first-child {\n flex: 1;\n}\n.grid__element:last-child {\n margin-top: 19px;\n margin-left: 7px;\n}\n.grid__element {\n display: flex;\n}\n\n.attribute-update-form .mat-button.mat-icon-button {\n width: 32px;\n min-width: 32px;\n height: 32px;\n min-height: 32px;\n padding: 0 !important;\n margin: 0 !important;\n line-height: 20px;\n}\n\n.attribute-update-form .mat-icon-button mat-icon {\n width: 20px;\n min-width: 20px;\n height: 20px;\n min-height: 20px;\n font-size: 20px;\n}\n\n.tb-toast {\n font-size: 14px!important;\n}", "controllerScript": "let $scope;\nlet settings;\nlet attributeService;\nlet utils;\nlet translate;\n\nself.onInit = function() {\n self.ctx.ngZone.run(function() {\n init(); \n self.ctx.detectChanges(true);\n });\n};\n\n\nfunction init() {\n $scope = self.ctx.$scope;\n attributeService = $scope.$injector.get(self.ctx.servicesMap.get('attributeService'));\n utils = $scope.$injector.get(self.ctx.servicesMap.get('utils'));\n translate = $scope.$injector.get(self.ctx.servicesMap.get('translate'));\n $scope.toastTargetId = 'input-widget' + utils.guid();\n settings = utils.deepClone(self.ctx.settings) || {};\n settings.showLabel = utils.defaultValue(settings.showLabel, true);\n settings.showResultMessage = utils.defaultValue(settings.showResultMessage, true);\n settings.isRequired = utils.defaultValue(settings.isRequired, true);\n $scope.settings = settings;\n $scope.isValidParameter = true;\n $scope.dataKeyDetected = false;\n\n $scope.requiredErrorMessage = utils.customTranslation(settings.requiredErrorMessage, settings.requiredErrorMessage) || translate.instant('widgets.input-widgets.entity-attribute-required');\n $scope.labelValue = utils.customTranslation(settings.labelValue, settings.labelValue) || translate.instant('widgets.input-widgets.value');\n \n var validators = [$scope.validators.minLength(settings.minLength),\n $scope.validators.maxLength(settings.maxLength)];\n \n if (settings.isRequired) {\n validators.push($scope.validators.required);\n }\n \n $scope.attributeUpdateFormGroup = $scope.fb.group({\n currentValue: [undefined, validators]\n });\n\n if (self.ctx.datasources && self.ctx.datasources.length) {\n var datasource = self.ctx.datasources[0];\n if (datasource.type === 'entity') {\n if (datasource.entityType && datasource.entityId) {\n $scope.entityName = datasource.entityName;\n if (settings.widgetTitle && settings.widgetTitle.length) {\n $scope.titleTemplate = utils.customTranslation(settings.widgetTitle, settings.widgetTitle);\n } else {\n $scope.titleTemplate = self.ctx.widgetConfig.title;\n }\n\n $scope.entityDetected = true;\n }\n }\n if (datasource.dataKeys.length) {\n if (datasource.dataKeys[0].type !== \"attribute\") {\n $scope.isValidParameter = false;\n } else {\n $scope.currentKey = datasource.dataKeys[0].name;\n $scope.dataKeyType = datasource.dataKeys[0].type;\n $scope.dataKeyDetected = true;\n }\n }\n }\n\n self.ctx.widgetTitle = utils.createLabelFromDatasource(self.ctx.datasources[0], $scope.titleTemplate);\n\n $scope.updateAttribute = function () {\n $scope.isFocused = false;\n if ($scope.entityDetected) {\n var datasource = self.ctx.datasources[0];\n \n var value = $scope.attributeUpdateFormGroup.get('currentValue').value;\n \n if (!$scope.attributeUpdateFormGroup.get('currentValue').value.length) {\n value = null;\n }\n\n attributeService.saveEntityAttributes(\n datasource.entity.id,\n 'SERVER_SCOPE',\n [\n {\n key: $scope.currentKey,\n value\n }\n ]\n ).subscribe(\n function success() {\n $scope.originalValue = $scope.attributeUpdateFormGroup.get('currentValue').value;\n if (settings.showResultMessage) {\n $scope.showSuccessToast(translate.instant('widgets.input-widgets.update-successful'), 1000, 'bottom', 'left', $scope.toastTargetId);\n }\n },\n function fail() {\n if (settings.showResultMessage) {\n $scope.showErrorToast(translate.instant('widgets.input-widgets.update-failed'), 'bottom', 'left', $scope.toastTargetId);\n }\n }\n );\n }\n };\n\n $scope.changeFocus = function () {\n if ($scope.attributeUpdateFormGroup.get('currentValue').value === $scope.originalValue) {\n $scope.isFocused = false;\n }\n }\n}\n\nself.onDataUpdated = function() {\n try {\n if ($scope.dataKeyDetected) {\n if (!$scope.isFocused) {\n $scope.originalValue = self.ctx.data[0].data[0][1];\n $scope.attributeUpdateFormGroup.get('currentValue').patchValue($scope.originalValue);\n self.ctx.detectChanges();\n }\n }\n } catch (e) {\n console.log(e);\n }\n}\n\nself.onResize = function() {\n\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n }\n}\n\nself.onDestroy = function() {\n\n}", - "settingsSchema": "", + "settingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"EntitiesTableSettings\",\n \"properties\": {\n \"widgetTitle\": {\n \"title\": \"Widget title\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"showLabel\":{\n \"title\":\"Show label\",\n \"type\":\"boolean\",\n \"default\":true\n },\n \"labelValue\": {\n \"title\": \"Label\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"requiredErrorMessage\": {\n \"title\": \"'Required' error message\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"maxLength\": {\n \"title\": \"Max length\",\n \"type\": \"number\",\n \"default\": \"\"\n },\n \"minLength\": {\n \"title\": \"Min length\",\n \"type\": \"number\",\n \"default\": \"\"\n },\n \"showResultMessage\":{\n \"title\":\"Show result message\",\n \"type\":\"boolean\",\n \"default\":true\n },\n \"isRequired\":{\n \"title\":\"Required\",\n \"type\":\"boolean\",\n \"default\":true\n }\n },\n \"required\": []\n },\n \"form\": [\n \"widgetTitle\",\n \"showResultMessage\",\n \"showLabel\",\n \"isRequired\",\n \"labelValue\",\n \"requiredErrorMessage\",\n \"maxLength\",\n \"minLength\"\n ]\n}", "dataKeySettingsSchema": "{}\n", - "settingsDirective": "tb-update-string-attribute-widget-settings", "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Sin\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.23592248334107624,\"funcBody\":\"return Math.round(1000*Math.sin(time/5000));\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"showResultMessage\":true,\"showLabel\":true,\"isRequired\":true},\"title\":\"Update server string attribute\",\"dropShadow\":true,\"enableFullscreen\":false,\"enableDataExport\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{}}" } }, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.html index 550f7cde49..54de73fc66 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.html @@ -85,6 +85,41 @@ {{key.label}} +
+ + {{key.label}} + + + {{key.settings.icon}} + + icon + + + + + {{ getErrorMessageText(key.settings,'required') }} + + + {{ 'value.json-value-invalid' | translate }} + + +
(JsonObjectEditDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + jsonValue: formControl.value, + title: key.settings.dialogLabel, + saveLabel: key.settings.saveButtonLabel, + undoLabel: key.settings.saveButtonLabel + } + }).afterClosed().subscribe( + (res) => { + if (!isEqual(res, formControl.value)) { + formControl.patchValue(res); + formControl.markAsDirty(); + if(!this.settings.showActionButtons) { + this.inputChanged(source, key); + } + } + } + ); + } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.html index 22823ba778..b3e205ac9e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.html @@ -48,6 +48,9 @@ {{ 'widgets.input-widgets.datakey-value-type-integer' | translate }} + + {{ 'widgets.input-widgets.datakey-value-type-json' | translate }} + {{ 'widgets.input-widgets.datakey-value-type-boolean-checkbox' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.ts index 4884166be5..f4a4f36a34 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.ts @@ -24,6 +24,7 @@ import { dataKeySelectOptionValidator } from '@home/components/widget/lib/settings/input/datakey-select-option.component'; import { CdkDragDrop } from '@angular/cdk/drag-drop'; +import { MultipleInputWidgetDataKeyValueType } from '@home/components/widget/lib/multiple-input-widget.component'; @Component({ selector: 'tb-update-multiple-attributes-key-settings', @@ -130,7 +131,8 @@ export class UpdateMultipleAttributesKeySettingsComponent extends WidgetSettings protected updateValidators(emitEvent: boolean) { const dataKeyHidden: boolean = this.updateMultipleAttributesKeySettingsForm.get('dataKeyHidden').value; - const dataKeyValueType: string = this.updateMultipleAttributesKeySettingsForm.get('dataKeyValueType').value; + + const dataKeyValueType: MultipleInputWidgetDataKeyValueType = this.updateMultipleAttributesKeySettingsForm.get('dataKeyValueType').value; const required: boolean = this.updateMultipleAttributesKeySettingsForm.get('required').value; const isEditable: string = this.updateMultipleAttributesKeySettingsForm.get('isEditable').value; const useCustomIcon: boolean = this.updateMultipleAttributesKeySettingsForm.get('useCustomIcon').value; @@ -244,7 +246,7 @@ export class UpdateMultipleAttributesKeySettingsComponent extends WidgetSettings const dataKeyHidden: boolean = this.updateMultipleAttributesKeySettingsForm.get('dataKeyHidden').value; const required: boolean = this.updateMultipleAttributesKeySettingsForm.get('required').value; const dataKeyValueType: string = this.updateMultipleAttributesKeySettingsForm.get('dataKeyValueType').value; - return !dataKeyHidden && (required || (['integer', 'double', 'dateTime', 'date', 'time'].includes(dataKeyValueType))); + return !dataKeyHidden && (required || (['integer', 'double', 'dateTime', 'date', 'time', 'JSON'].includes(dataKeyValueType))); } } diff --git a/ui-ngx/src/app/shared/components/dialog/json-object-edit-dialog.component.html b/ui-ngx/src/app/shared/components/dialog/json-object-edit-dialog.component.html index b7949d59b7..4f91113c79 100644 --- a/ui-ngx/src/app/shared/components/dialog/json-object-edit-dialog.component.html +++ b/ui-ngx/src/app/shared/components/dialog/json-object-edit-dialog.component.html @@ -45,12 +45,12 @@ type="button" [disabled]="(isLoading$ | async)" (click)="cancel()" cdkFocusInitial> - {{ 'action.cancel' | translate }} + {{ undoButtonLabel }}
diff --git a/ui-ngx/src/app/shared/components/dialog/json-object-edit-dialog.component.ts b/ui-ngx/src/app/shared/components/dialog/json-object-edit-dialog.component.ts index 3f4672ffb0..673bdddd78 100644 --- a/ui-ngx/src/app/shared/components/dialog/json-object-edit-dialog.component.ts +++ b/ui-ngx/src/app/shared/components/dialog/json-object-edit-dialog.component.ts @@ -26,6 +26,8 @@ import { TranslateService } from '@ngx-translate/core'; export interface JsonObjectEditDialogData { jsonValue: object; title?: string; + saveLabel?: string; + undoLabel?: string; } @Component({ @@ -37,6 +39,8 @@ export class JsonObjectEditDialogComponent extends DialogComponent Date: Mon, 24 Oct 2022 12:18:09 +0300 Subject: [PATCH 002/100] minor corrections --- .../main/data/json/system/widget_bundles/input_widgets.json | 3 ++- .../components/widget/lib/multiple-input-widget.component.ts | 1 - .../input/update-multiple-attributes-key-settings.component.ts | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/application/src/main/data/json/system/widget_bundles/input_widgets.json b/application/src/main/data/json/system/widget_bundles/input_widgets.json index f21c059119..3c9a9fe718 100644 --- a/application/src/main/data/json/system/widget_bundles/input_widgets.json +++ b/application/src/main/data/json/system/widget_bundles/input_widgets.json @@ -324,8 +324,9 @@ "templateHtml": "
\n
\n
\n
\n
\n \n {{ settings.showLabel ? labelValue : '' }}\n \n \n {{requiredErrorMessage}}\n \n \n
\n \n
\n \n \n
\n
\n \n
\n {{ 'widgets.input-widgets.no-entity-selected' | translate }}\n
\n
\n {{ 'widgets.input-widgets.no-attribute-selected' | translate }}\n
\n
\n {{ 'widgets.input-widgets.timeseries-not-allowed' | translate }}\n
\n
\n
\n
", "templateCss": ".attribute-update-form {\n overflow: hidden;\n height: 100%;\n display: flex;\n flex-direction: column;\n}\n\n.attribute-update-form__grid {\n display: flex;\n}\n.grid__element:first-child {\n flex: 1;\n}\n.grid__element:last-child {\n margin-top: 19px;\n margin-left: 7px;\n}\n.grid__element {\n display: flex;\n}\n\n.attribute-update-form .mat-button.mat-icon-button {\n width: 32px;\n min-width: 32px;\n height: 32px;\n min-height: 32px;\n padding: 0 !important;\n margin: 0 !important;\n line-height: 20px;\n}\n\n.attribute-update-form .mat-icon-button mat-icon {\n width: 20px;\n min-width: 20px;\n height: 20px;\n min-height: 20px;\n font-size: 20px;\n}\n\n.tb-toast {\n font-size: 14px!important;\n}", "controllerScript": "let $scope;\nlet settings;\nlet attributeService;\nlet utils;\nlet translate;\n\nself.onInit = function() {\n self.ctx.ngZone.run(function() {\n init(); \n self.ctx.detectChanges(true);\n });\n};\n\n\nfunction init() {\n $scope = self.ctx.$scope;\n attributeService = $scope.$injector.get(self.ctx.servicesMap.get('attributeService'));\n utils = $scope.$injector.get(self.ctx.servicesMap.get('utils'));\n translate = $scope.$injector.get(self.ctx.servicesMap.get('translate'));\n $scope.toastTargetId = 'input-widget' + utils.guid();\n settings = utils.deepClone(self.ctx.settings) || {};\n settings.showLabel = utils.defaultValue(settings.showLabel, true);\n settings.showResultMessage = utils.defaultValue(settings.showResultMessage, true);\n settings.isRequired = utils.defaultValue(settings.isRequired, true);\n $scope.settings = settings;\n $scope.isValidParameter = true;\n $scope.dataKeyDetected = false;\n\n $scope.requiredErrorMessage = utils.customTranslation(settings.requiredErrorMessage, settings.requiredErrorMessage) || translate.instant('widgets.input-widgets.entity-attribute-required');\n $scope.labelValue = utils.customTranslation(settings.labelValue, settings.labelValue) || translate.instant('widgets.input-widgets.value');\n \n var validators = [$scope.validators.minLength(settings.minLength),\n $scope.validators.maxLength(settings.maxLength)];\n \n if (settings.isRequired) {\n validators.push($scope.validators.required);\n }\n \n $scope.attributeUpdateFormGroup = $scope.fb.group({\n currentValue: [undefined, validators]\n });\n\n if (self.ctx.datasources && self.ctx.datasources.length) {\n var datasource = self.ctx.datasources[0];\n if (datasource.type === 'entity') {\n if (datasource.entityType && datasource.entityId) {\n $scope.entityName = datasource.entityName;\n if (settings.widgetTitle && settings.widgetTitle.length) {\n $scope.titleTemplate = utils.customTranslation(settings.widgetTitle, settings.widgetTitle);\n } else {\n $scope.titleTemplate = self.ctx.widgetConfig.title;\n }\n\n $scope.entityDetected = true;\n }\n }\n if (datasource.dataKeys.length) {\n if (datasource.dataKeys[0].type !== \"attribute\") {\n $scope.isValidParameter = false;\n } else {\n $scope.currentKey = datasource.dataKeys[0].name;\n $scope.dataKeyType = datasource.dataKeys[0].type;\n $scope.dataKeyDetected = true;\n }\n }\n }\n\n self.ctx.widgetTitle = utils.createLabelFromDatasource(self.ctx.datasources[0], $scope.titleTemplate);\n\n $scope.updateAttribute = function () {\n $scope.isFocused = false;\n if ($scope.entityDetected) {\n var datasource = self.ctx.datasources[0];\n \n var value = $scope.attributeUpdateFormGroup.get('currentValue').value;\n \n if (!$scope.attributeUpdateFormGroup.get('currentValue').value.length) {\n value = null;\n }\n\n attributeService.saveEntityAttributes(\n datasource.entity.id,\n 'SERVER_SCOPE',\n [\n {\n key: $scope.currentKey,\n value\n }\n ]\n ).subscribe(\n function success() {\n $scope.originalValue = $scope.attributeUpdateFormGroup.get('currentValue').value;\n if (settings.showResultMessage) {\n $scope.showSuccessToast(translate.instant('widgets.input-widgets.update-successful'), 1000, 'bottom', 'left', $scope.toastTargetId);\n }\n },\n function fail() {\n if (settings.showResultMessage) {\n $scope.showErrorToast(translate.instant('widgets.input-widgets.update-failed'), 'bottom', 'left', $scope.toastTargetId);\n }\n }\n );\n }\n };\n\n $scope.changeFocus = function () {\n if ($scope.attributeUpdateFormGroup.get('currentValue').value === $scope.originalValue) {\n $scope.isFocused = false;\n }\n }\n}\n\nself.onDataUpdated = function() {\n try {\n if ($scope.dataKeyDetected) {\n if (!$scope.isFocused) {\n $scope.originalValue = self.ctx.data[0].data[0][1];\n $scope.attributeUpdateFormGroup.get('currentValue').patchValue($scope.originalValue);\n self.ctx.detectChanges();\n }\n }\n } catch (e) {\n console.log(e);\n }\n}\n\nself.onResize = function() {\n\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n }\n}\n\nself.onDestroy = function() {\n\n}", - "settingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"EntitiesTableSettings\",\n \"properties\": {\n \"widgetTitle\": {\n \"title\": \"Widget title\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"showLabel\":{\n \"title\":\"Show label\",\n \"type\":\"boolean\",\n \"default\":true\n },\n \"labelValue\": {\n \"title\": \"Label\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"requiredErrorMessage\": {\n \"title\": \"'Required' error message\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"maxLength\": {\n \"title\": \"Max length\",\n \"type\": \"number\",\n \"default\": \"\"\n },\n \"minLength\": {\n \"title\": \"Min length\",\n \"type\": \"number\",\n \"default\": \"\"\n },\n \"showResultMessage\":{\n \"title\":\"Show result message\",\n \"type\":\"boolean\",\n \"default\":true\n },\n \"isRequired\":{\n \"title\":\"Required\",\n \"type\":\"boolean\",\n \"default\":true\n }\n },\n \"required\": []\n },\n \"form\": [\n \"widgetTitle\",\n \"showResultMessage\",\n \"showLabel\",\n \"isRequired\",\n \"labelValue\",\n \"requiredErrorMessage\",\n \"maxLength\",\n \"minLength\"\n ]\n}", + "settingsSchema": "", "dataKeySettingsSchema": "{}\n", + "settingsDirective": "tb-update-string-attribute-widget-settings", "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Sin\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.23592248334107624,\"funcBody\":\"return Math.round(1000*Math.sin(time/5000));\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"showResultMessage\":true,\"showLabel\":true,\"isRequired\":true},\"title\":\"Update server string attribute\",\"dropShadow\":true,\"enableFullscreen\":false,\"enableDataExport\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{}}" } }, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.ts index 1a1a85c599..b0d6aa58dd 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.ts @@ -91,7 +91,6 @@ interface MultipleInputWidgetDataKeySettings { maxValue?: number; requiredErrorMessage?: string; invalidDateErrorMessage?: string; - invalidJSONErrorMessage?: string; minValueErrorMessage?: string; maxValueErrorMessage?: string; useCustomIcon: boolean; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.ts index f4a4f36a34..2d9ca8db35 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.ts @@ -131,7 +131,6 @@ export class UpdateMultipleAttributesKeySettingsComponent extends WidgetSettings protected updateValidators(emitEvent: boolean) { const dataKeyHidden: boolean = this.updateMultipleAttributesKeySettingsForm.get('dataKeyHidden').value; - const dataKeyValueType: MultipleInputWidgetDataKeyValueType = this.updateMultipleAttributesKeySettingsForm.get('dataKeyValueType').value; const required: boolean = this.updateMultipleAttributesKeySettingsForm.get('required').value; const isEditable: string = this.updateMultipleAttributesKeySettingsForm.get('isEditable').value; From 5eebe2e6ffa5daf9f794a6a1bec5fba17523cbbd Mon Sep 17 00:00:00 2001 From: Seraphym-Tuhai Date: Fri, 6 Jan 2023 12:02:44 +0200 Subject: [PATCH 003/100] add guide for run tests on built UI --- msa/black-box-tests/README.md | 8 +++++--- .../server/msa/TestProperties.java | 19 +++---------------- .../src/test/resources/config.properties | 2 -- 3 files changed, 8 insertions(+), 21 deletions(-) delete mode 100644 msa/black-box-tests/src/test/resources/config.properties diff --git a/msa/black-box-tests/README.md b/msa/black-box-tests/README.md index ae0e8b79a0..df28271864 100644 --- a/msa/black-box-tests/README.md +++ b/msa/black-box-tests/README.md @@ -42,6 +42,8 @@ As result, in REPOSITORY column, next images should be present: mvn clean install -DblackBoxTests.skip=false -Dsuite=all - - - +### To run a separate test manually on a built UI: +1. Add the black-box-tests module in the [pom.xml](../pom.xml) +2. Add Vm Option "-DrunLocal=true -Dtb.baseUiUrl=http://localhost:4200/" in Run -> Edit Configuration -> Edit Configuration Templates -> TestNG +3. Go to the test class you need in the [UI tests directory](../black-box-tests/src/test/java/org/thingsboard/server/msa/ui/tests) and run the test in it +or go to the [resources](../black-box-tests/src/test/resources) and run test suite you need \ No newline at end of file diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestProperties.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestProperties.java index b5d91647c1..e5ccc62f4c 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestProperties.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestProperties.java @@ -36,33 +36,20 @@ public class TestProperties { if (instance.isActive()) { return HTTPS_URL; } - return getProperties().getProperty("tb.baseUrl"); + return System.getProperty("tb.baseUrl", "http://localhost:8080"); } public static String getBaseUiUrl() { if (instance.isActive()) { return "https://host.docker.internal"; } - return getProperties().getProperty("tb.baseUrl"); + return System.getProperty("tb.baseUiUrl", "http://localhost:8080"); } public static String getWebSocketUrl() { if (instance.isActive()) { return WSS_URL; } - return getProperties().getProperty("tb.wsUrl"); + return System.getProperty("tb.wsUrl", "ws://localhost:8080"); } - - private static Properties getProperties() { - if (properties == null) { - try (InputStream input = TestProperties.class.getClassLoader().getResourceAsStream("config.properties")) { - properties = new Properties(); - properties.load(input); - } catch (IOException ex) { - log.error("Exception while reading test properties " + ex.getMessage()); - } - } - return properties; - } - } diff --git a/msa/black-box-tests/src/test/resources/config.properties b/msa/black-box-tests/src/test/resources/config.properties deleted file mode 100644 index 419c73185d..0000000000 --- a/msa/black-box-tests/src/test/resources/config.properties +++ /dev/null @@ -1,2 +0,0 @@ -tb.baseUrl=http://localhost:8080 -tb.wsUrl=ws://localhost:8080 From 166c543faf14b8a1db3198f0acf6628b1f49c056 Mon Sep 17 00:00:00 2001 From: Seraphym-Tuhai Date: Wed, 11 Jan 2023 13:24:30 +0200 Subject: [PATCH 004/100] add one more step in README.md --- msa/black-box-tests/README.md | 3 ++- .../src/test/resources/addAsMavenProject.png | Bin 0 -> 125747 bytes 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 msa/black-box-tests/src/test/resources/addAsMavenProject.png diff --git a/msa/black-box-tests/README.md b/msa/black-box-tests/README.md index df28271864..1907991dd3 100644 --- a/msa/black-box-tests/README.md +++ b/msa/black-box-tests/README.md @@ -43,7 +43,8 @@ As result, in REPOSITORY column, next images should be present: mvn clean install -DblackBoxTests.skip=false -Dsuite=all ### To run a separate test manually on a built UI: -1. Add the black-box-tests module in the [pom.xml](../pom.xml) +1. Add the black-box-tests module in the [pom.xml](../pom.xml) or add as a Maven project as in the picture +![addAsMavenProject.png](../black-box-tests/src/test/resources/addAsMavenProject.png) 2. Add Vm Option "-DrunLocal=true -Dtb.baseUiUrl=http://localhost:4200/" in Run -> Edit Configuration -> Edit Configuration Templates -> TestNG 3. Go to the test class you need in the [UI tests directory](../black-box-tests/src/test/java/org/thingsboard/server/msa/ui/tests) and run the test in it or go to the [resources](../black-box-tests/src/test/resources) and run test suite you need \ No newline at end of file diff --git a/msa/black-box-tests/src/test/resources/addAsMavenProject.png b/msa/black-box-tests/src/test/resources/addAsMavenProject.png new file mode 100644 index 0000000000000000000000000000000000000000..0f5e9bbb3a4515ddc5b5233ced129b6e384071c6 GIT binary patch literal 125747 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%0Du&d@OwuCG_Fr;s`?0( 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

x)CLFjymj((}C!|^O4+{-IVn36<(H2mt(Bl_} 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�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 Date: Thu, 12 Jan 2023 17:16:17 +0200 Subject: [PATCH 005/100] Improve Device Profile Entity --- .../server/dao/model/ModelConstants.java | 3 + .../dao/model/sql/DeviceProfileEntity.java | 15 ++++ .../main/resources/sql/schema-entities.sql | 71 ++++++++++++++++--- 3 files changed, 79 insertions(+), 10 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index e24bde91fb..3736a236e6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -185,6 +185,9 @@ public class ModelConstants { public static final String DEVICE_PROFILE_PROVISION_DEVICE_KEY = "provision_device_key"; public static final String DEVICE_PROFILE_FIRMWARE_ID_PROPERTY = "firmware_id"; public static final String DEVICE_PROFILE_SOFTWARE_ID_PROPERTY = "software_id"; + public static final String DEVICE_PROFILE_CERTIFICATE_HASH_PROPERTY = "certificate_hash"; + public static final String DEVICE_PROFILE_CERTIFICATE_VALUE_PROPERTY = "certificate_value"; + public static final String DEVICE_PROFILE_CERTIFICATE_REGEX_PATTERN_PROPERTY = "certificate_regex_pattern"; /** * Asset profile constants. diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/DeviceProfileEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/DeviceProfileEntity.java index 893c9d549c..2aaaf61197 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/DeviceProfileEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/DeviceProfileEntity.java @@ -106,6 +106,15 @@ public final class DeviceProfileEntity extends BaseSqlEntity impl @Column(name = ModelConstants.EXTERNAL_ID_PROPERTY) private UUID externalId; + @Column(name = ModelConstants.DEVICE_PROFILE_CERTIFICATE_VALUE_PROPERTY) + private String certificateValue; + + @Column(name = ModelConstants.DEVICE_PROFILE_CERTIFICATE_HASH_PROPERTY) + private String certificateHash; + + @Column(name = ModelConstants.DEVICE_PROFILE_CERTIFICATE_REGEX_PATTERN_PROPERTY) + private String certificateRegexPattern; + public DeviceProfileEntity() { super(); } @@ -123,6 +132,9 @@ public final class DeviceProfileEntity extends BaseSqlEntity impl this.image = deviceProfile.getImage(); this.transportType = deviceProfile.getTransportType(); this.provisionType = deviceProfile.getProvisionType(); + this.certificateHash = deviceProfile.getCertificateHash(); + this.certificateValue = deviceProfile.getCertificateValue(); + this.certificateRegexPattern = deviceProfile.getCertificateRegexPattern(); this.description = deviceProfile.getDescription(); this.isDefault = deviceProfile.isDefault(); this.profileData = JacksonUtil.convertValue(deviceProfile.getProfileData(), ObjectNode.class); @@ -182,6 +194,9 @@ public final class DeviceProfileEntity extends BaseSqlEntity impl deviceProfile.setDefaultDashboardId(new DashboardId(defaultDashboardId)); } deviceProfile.setProvisionDeviceKey(provisionDeviceKey); + deviceProfile.setCertificateHash(certificateHash); + deviceProfile.setCertificateValue(certificateValue); + deviceProfile.setCertificateRegexPattern(certificateRegexPattern); if (firmwareId != null) { deviceProfile.setFirmwareId(new OtaPackageId(firmwareId)); diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index 73039274a6..c4c3317009 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -271,24 +271,75 @@ CREATE TABLE IF NOT EXISTS device_profile ( provision_type varchar(255), profile_data jsonb, description varchar, - search_text varchar(255), + search_text varchar +( + 255 +), is_default boolean, tenant_id uuid, firmware_id uuid, software_id uuid, default_rule_chain_id uuid, default_dashboard_id uuid, - default_queue_name varchar(255), + default_queue_name varchar +( + 255 +), provision_device_key varchar, + certificate_value varchar; + certificate_hash varchar, + certificate_regex_pattern varchar +( + 255 +), external_id uuid, - CONSTRAINT device_profile_name_unq_key UNIQUE (tenant_id, name), - CONSTRAINT device_provision_key_unq_key UNIQUE (provision_device_key), - CONSTRAINT device_profile_external_id_unq_key UNIQUE (tenant_id, external_id), - CONSTRAINT fk_default_rule_chain_device_profile FOREIGN KEY (default_rule_chain_id) REFERENCES rule_chain(id), - CONSTRAINT fk_default_dashboard_device_profile FOREIGN KEY (default_dashboard_id) REFERENCES dashboard(id), - CONSTRAINT fk_firmware_device_profile FOREIGN KEY (firmware_id) REFERENCES ota_package(id), - CONSTRAINT fk_software_device_profile FOREIGN KEY (software_id) REFERENCES ota_package(id) -); + CONSTRAINT device_profile_credentials_hash_unq_key UNIQUE +( + certificate_hash +), + CONSTRAINT device_profile_name_unq_key UNIQUE +( + tenant_id, + name +), + CONSTRAINT device_provision_key_unq_key UNIQUE +( + provision_device_key +), + CONSTRAINT device_profile_external_id_unq_key UNIQUE +( + tenant_id, + external_id +), + CONSTRAINT fk_default_rule_chain_device_profile FOREIGN KEY +( + default_rule_chain_id +) REFERENCES rule_chain +( + id +), + CONSTRAINT fk_default_dashboard_device_profile FOREIGN KEY +( + default_dashboard_id +) REFERENCES dashboard +( + id +), + CONSTRAINT fk_firmware_device_profile FOREIGN KEY +( + firmware_id +) REFERENCES ota_package +( + id +), + CONSTRAINT fk_software_device_profile FOREIGN KEY +( + software_id +) REFERENCES ota_package +( + id +) + ); DO $$ From 5d35fa1f261dfa2e421a786a184b87b04776f461 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Thu, 12 Jan 2023 17:18:29 +0200 Subject: [PATCH 006/100] Add ability to upgrade device credentials if chain match with uploaded device profile certificate. --- .../transport/DefaultTransportApiService.java | 62 +++++++++- common/cluster-api/src/main/proto/queue.proto | 65 ++++++---- .../dao/device/DeviceCredentialsService.java | 4 +- .../dao/device/DeviceProfileService.java | 2 + .../server/common/data/DeviceProfile.java | 9 +- .../mqtt/MqttSslHandlerProvider.java | 60 ++++++++- .../transport/mqtt/MqttTransportHandler.java | 115 +++++++++++++----- .../common/transport/TransportService.java | 9 ++ ...idateDeviceProfileCredentialsResponse.java | 28 +++++ .../service/DefaultTransportService.java | 29 ++++- .../server/common/transport/util/SslUtil.java | 17 +++ .../dao/device/DeviceCredentialsDao.java | 8 ++ .../device/DeviceCredentialsServiceImpl.java | 10 +- .../dao/device/DeviceProfileCacheKey.java | 14 ++- .../server/dao/device/DeviceProfileDao.java | 2 + .../dao/device/DeviceProfileServiceImpl.java | 10 ++ .../device/DeviceCredentialsRepository.java | 5 + .../sql/device/DeviceProfileRepository.java | 2 + .../sql/device/JpaDeviceCredentialsDao.java | 5 + .../dao/sql/device/JpaDeviceProfileDao.java | 5 + 20 files changed, 388 insertions(+), 73 deletions(-) create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/auth/ValidateDeviceProfileCredentialsResponse.java diff --git a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java index f8b42ecc1e..9bc61d6493 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java @@ -65,8 +65,8 @@ import org.thingsboard.server.common.msg.EncryptionUtil; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgDataType; import org.thingsboard.server.common.msg.TbMsgMetaData; -import org.thingsboard.server.queue.util.DataDecodingEncodingService; import org.thingsboard.server.dao.device.DeviceCredentialsService; +import org.thingsboard.server.dao.device.DeviceProfileService; import org.thingsboard.server.dao.device.DeviceProvisionService; import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.device.provision.ProvisionFailedException; @@ -90,11 +90,15 @@ import org.thingsboard.server.gen.transport.TransportProtos.GetSnmpDevicesRespon import org.thingsboard.server.gen.transport.TransportProtos.ProvisionDeviceRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.TransportApiRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.TransportApiResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.UpdateOrCreateDeviceX509CertRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceCredentialsResponseMsg; import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceLwM2MCredentialsRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceProfileCredentialsResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceProfileX509CertRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceTokenRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceX509CertRequestMsg; import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.util.DataDecodingEncodingService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.apiusage.TbApiUsageStateService; import org.thingsboard.server.service.executors.DbCallbackExecutorService; @@ -108,6 +112,8 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Collectors; import static org.thingsboard.server.service.transport.BasicCredentialsValidationResult.PASSWORD_MISMATCH; @@ -128,6 +134,7 @@ public class DefaultTransportApiService implements TransportApiService { private final TbTenantProfileCache tenantProfileCache; private final TbApiUsageStateService apiUsageStateService; private final DeviceService deviceService; + private final DeviceProfileService deviceProfileService; private final RelationService relationService; private final DeviceCredentialsService deviceCredentialsService; private final DbCallbackExecutorService dbCallbackExecutorService; @@ -159,6 +166,13 @@ public class DefaultTransportApiService implements TransportApiService { } else if (transportApiRequestMsg.hasValidateX509CertRequestMsg()) { ValidateDeviceX509CertRequestMsg msg = transportApiRequestMsg.getValidateX509CertRequestMsg(); result = validateCredentials(msg.getHash(), DeviceCredentialsType.X509_CERTIFICATE); + } else if (transportApiRequestMsg.hasValidateProfileX509CertRequestMsg()) { + ValidateDeviceProfileX509CertRequestMsg msg = transportApiRequestMsg.getValidateProfileX509CertRequestMsg(); + result = validateDeviceProfileCertificate(msg.getHash()); + } else if (transportApiRequestMsg.hasUpdateOrCreateDeviceCertRequestMsg()) { + UpdateOrCreateDeviceX509CertRequestMsg msg = transportApiRequestMsg.getUpdateOrCreateDeviceCertRequestMsg(); + DeviceProfile deviceProfile = deviceProfileCache.find(new DeviceProfileId(new UUID(msg.getDeviceProfileIdMSB(), msg.getDeviceProfileIdLSB()))); + result = updateOrCreateDeviceCredentials(msg.getHash(), msg.getValue(), msg.getCommonName(), deviceProfile, DeviceCredentialsType.X509_CERTIFICATE); } else if (transportApiRequestMsg.hasGetOrCreateDeviceRequestMsg()) { result = handle(transportApiRequestMsg.getGetOrCreateDeviceRequestMsg()); } else if (transportApiRequestMsg.hasEntityProfileRequestMsg()) { @@ -226,6 +240,31 @@ public class DefaultTransportApiService implements TransportApiService { } } + private ListenableFuture validateDeviceProfileCertificate(String credentialsId) { + DeviceProfile deviceProfile = deviceProfileService.findDeviceProfileByCertificateHash(credentialsId); + if (deviceProfile != null) { + return getDeviceProfileInfo(deviceProfile); + } + return getEmptyTransportApiResponseFuture(); + } + + private ListenableFuture updateOrCreateDeviceCredentials(String credentialsId, + String credentialsValue, + String deviceCN, + DeviceProfile deviceProfile, + DeviceCredentialsType credentialsType) { + String deviceName = extractRegex(deviceCN, deviceProfile.getCertificateRegexPattern()); + // find deviceCredentials by deviceName (device exists) + DeviceCredentials deviceCredentials = deviceCredentialsService.findDeviceCredentialsByTenantIdAndDeviceName(deviceProfile.getTenantId(), deviceName); + if (deviceCredentials != null && deviceCredentials.getCredentialsType() == credentialsType) { + deviceCredentials.setCredentialsId(credentialsId); + deviceCredentials.setCredentialsValue(credentialsValue); + deviceCredentialsService.updateDeviceCredentials(deviceProfile.getTenantId(), deviceCredentials); + return getDeviceInfo(deviceCredentials); + } + return getEmptyTransportApiResponseFuture(); + } + private ListenableFuture validateUserNameCredentials(TransportProtos.ValidateBasicMqttCredRequestMsg mqtt) { DeviceCredentials credentials = deviceCredentialsService.findDeviceCredentialsByCredentialsId(mqtt.getUserName()); if (credentials != null) { @@ -507,6 +546,18 @@ public class DefaultTransportApiService implements TransportApiService { }, MoreExecutors.directExecutor()); } + private ListenableFuture getDeviceProfileInfo(DeviceProfile deviceProfile) { + ValidateDeviceProfileCredentialsResponseMsg.Builder builder = ValidateDeviceProfileCredentialsResponseMsg.newBuilder() + .setDeviceProfileIdMSB(deviceProfile.getId().getId().getMostSignificantBits()) + .setDeviceProfileIdLSB(deviceProfile.getId().getId().getLeastSignificantBits()) + .setIsDeviceProfileFound(true); + + return Futures.immediateFuture( + TransportApiResponseMsg.newBuilder() + .setValidateDeviceProfileResponseMsg(builder.build()) + .build()); + } + private DeviceInfoProto getDeviceInfoProto(Device device) throws JsonProcessingException { DeviceInfoProto.Builder builder = DeviceInfoProto.newBuilder() .setTenantIdMSB(device.getTenantId().getId().getMostSignificantBits()) @@ -664,4 +715,13 @@ public class DefaultTransportApiService implements TransportApiService { private Long checkLong(Long l) { return l != null ? l : 0; } + + private String extractRegex(String commonName, String regex) { + Pattern pattern = Pattern.compile(regex); + Matcher matcher = pattern.matcher(commonName); + if (matcher.find()) { + return matcher.group(0); + } + return commonName; + } } diff --git a/common/cluster-api/src/main/proto/queue.proto b/common/cluster-api/src/main/proto/queue.proto index 5a28d42546..405b96726a 100644 --- a/common/cluster-api/src/main/proto/queue.proto +++ b/common/cluster-api/src/main/proto/queue.proto @@ -169,6 +169,18 @@ message ValidateDeviceX509CertRequestMsg { string hash = 1; } +message ValidateDeviceProfileX509CertRequestMsg { + string hash = 1; +} + +message UpdateOrCreateDeviceX509CertRequestMsg { + string hash = 1; + string value = 2; + string commonName = 3; + int64 deviceProfileIdMSB = 4; + int64 deviceProfileIdLSB = 5; +} + message ValidateBasicMqttCredRequestMsg { string clientId = 1; string userName = 2; @@ -181,6 +193,12 @@ message ValidateDeviceCredentialsResponseMsg { bytes profileBody = 3; } +message ValidateDeviceProfileCredentialsResponseMsg { + int64 deviceProfileIdMSB = 1; + int64 deviceProfileIdLSB = 2; + bool isDeviceProfileFound = 3; +} + message GetOrCreateDeviceFromGatewayRequestMsg { int64 gatewayIdMSB = 1; int64 gatewayIdLSB = 2; @@ -885,33 +903,36 @@ message VersionControlResponseMsg { message TransportApiRequestMsg { ValidateDeviceTokenRequestMsg validateTokenRequestMsg = 1; ValidateDeviceX509CertRequestMsg validateX509CertRequestMsg = 2; - GetOrCreateDeviceFromGatewayRequestMsg getOrCreateDeviceRequestMsg = 3; - GetEntityProfileRequestMsg entityProfileRequestMsg = 4; - LwM2MRequestMsg lwM2MRequestMsg = 5; - ValidateBasicMqttCredRequestMsg validateBasicMqttCredRequestMsg = 6; - ProvisionDeviceRequestMsg provisionDeviceRequestMsg = 7; - ValidateDeviceLwM2MCredentialsRequestMsg validateDeviceLwM2MCredentialsRequestMsg = 8; - GetResourceRequestMsg resourceRequestMsg = 9; - GetOtaPackageRequestMsg otaPackageRequestMsg = 10; - GetSnmpDevicesRequestMsg snmpDevicesRequestMsg = 11; - GetDeviceRequestMsg deviceRequestMsg = 12; - GetDeviceCredentialsRequestMsg deviceCredentialsRequestMsg = 13; - GetAllQueueRoutingInfoRequestMsg getAllQueueRoutingInfoRequestMsg = 14; + ValidateDeviceProfileX509CertRequestMsg validateProfileX509CertRequestMsg = 3; + GetOrCreateDeviceFromGatewayRequestMsg getOrCreateDeviceRequestMsg = 4; + GetEntityProfileRequestMsg entityProfileRequestMsg = 5; + LwM2MRequestMsg lwM2MRequestMsg = 6; + ValidateBasicMqttCredRequestMsg validateBasicMqttCredRequestMsg = 7; + ProvisionDeviceRequestMsg provisionDeviceRequestMsg = 8; + ValidateDeviceLwM2MCredentialsRequestMsg validateDeviceLwM2MCredentialsRequestMsg = 9; + GetResourceRequestMsg resourceRequestMsg = 10; + GetOtaPackageRequestMsg otaPackageRequestMsg = 11; + GetSnmpDevicesRequestMsg snmpDevicesRequestMsg = 12; + GetDeviceRequestMsg deviceRequestMsg = 13; + GetDeviceCredentialsRequestMsg deviceCredentialsRequestMsg = 14; + GetAllQueueRoutingInfoRequestMsg getAllQueueRoutingInfoRequestMsg = 15; + UpdateOrCreateDeviceX509CertRequestMsg updateOrCreateDeviceCertRequestMsg = 16; } /* Response from ThingsBoard Core Service to Transport Service */ message TransportApiResponseMsg { ValidateDeviceCredentialsResponseMsg validateCredResponseMsg = 1; - GetOrCreateDeviceFromGatewayResponseMsg getOrCreateDeviceResponseMsg = 2; - GetEntityProfileResponseMsg entityProfileResponseMsg = 3; - ProvisionDeviceResponseMsg provisionDeviceResponseMsg = 4; - GetSnmpDevicesResponseMsg snmpDevicesResponseMsg = 5; - LwM2MResponseMsg lwM2MResponseMsg = 6; - GetResourceResponseMsg resourceResponseMsg = 7; - GetOtaPackageResponseMsg otaPackageResponseMsg = 8; - GetDeviceResponseMsg deviceResponseMsg = 9; - GetDeviceCredentialsResponseMsg deviceCredentialsResponseMsg = 10; - repeated GetQueueRoutingInfoResponseMsg getQueueRoutingInfoResponseMsgs = 11; + ValidateDeviceProfileCredentialsResponseMsg validateDeviceProfileResponseMsg = 2; + GetOrCreateDeviceFromGatewayResponseMsg getOrCreateDeviceResponseMsg = 3; + GetEntityProfileResponseMsg entityProfileResponseMsg = 4; + ProvisionDeviceResponseMsg provisionDeviceResponseMsg = 5; + GetSnmpDevicesResponseMsg snmpDevicesResponseMsg = 6; + LwM2MResponseMsg lwM2MResponseMsg = 7; + GetResourceResponseMsg resourceResponseMsg = 8; + GetOtaPackageResponseMsg otaPackageResponseMsg = 9; + GetDeviceResponseMsg deviceResponseMsg = 10; + GetDeviceCredentialsResponseMsg deviceCredentialsResponseMsg = 11; + repeated GetQueueRoutingInfoResponseMsg getQueueRoutingInfoResponseMsgs = 12; } /* Messages that are handled by ThingsBoard Core Service */ diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsService.java index 42d6d10a1a..150106e3cb 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsService.java @@ -15,10 +15,10 @@ */ package org.thingsboard.server.dao.device; +import com.fasterxml.jackson.databind.JsonNode; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.security.DeviceCredentials; -import com.fasterxml.jackson.databind.JsonNode; public interface DeviceCredentialsService { @@ -26,6 +26,8 @@ public interface DeviceCredentialsService { DeviceCredentials findDeviceCredentialsByCredentialsId(String credentialsId); + DeviceCredentials findDeviceCredentialsByTenantIdAndDeviceName(TenantId tenantId, String deviceName); + DeviceCredentials updateDeviceCredentials(TenantId tenantId, DeviceCredentials deviceCredentials); DeviceCredentials createDeviceCredentials(TenantId tenantId, DeviceCredentials deviceCredentials); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProfileService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProfileService.java index 2e65e159cd..6ead3e08c5 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProfileService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProfileService.java @@ -39,6 +39,8 @@ public interface DeviceProfileService extends EntityDaoService { PageData findDeviceProfileInfos(TenantId tenantId, PageLink pageLink, String transportType); + DeviceProfile findDeviceProfileByCertificateHash(String credentialsId); + DeviceProfile findOrCreateDeviceProfile(TenantId tenantId, String profileName); DeviceProfile createDefaultDeviceProfile(TenantId tenantId); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfile.java b/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfile.java index a66f3a40d9..2a5b11f1ff 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfile.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfile.java @@ -16,7 +16,6 @@ package org.thingsboard.server.common.data; import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.core.JsonProcessingException; import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; @@ -28,7 +27,6 @@ import org.thingsboard.server.common.data.device.profile.DeviceProfileData; import org.thingsboard.server.common.data.id.DashboardId; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.OtaPackageId; -import org.thingsboard.server.common.data.id.QueueId; import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.validation.Length; @@ -68,6 +66,13 @@ public class DeviceProfile extends SearchTextBased implements H private DeviceTransportType transportType; @ApiModelProperty(position = 15, value = "Provisioning strategy.") private DeviceProfileProvisionType provisionType; + @ApiModelProperty(position = 16, value = "CA certificate value") + private String certificateValue; + @ApiModelProperty(position = 17, value = "CA certificate hash") + private String certificateHash; + @ApiModelProperty(position = 18, value = "Regex for fetch deviceName from CN") + private String certificateRegexPattern; + @ApiModelProperty(position = 7, value = "Reference to the rule chain. " + "If present, the specified rule chain will be used to process all messages related to device, including telemetry, attribute updates, etc. " + "Otherwise, the root rule chain will be used to process those messages.") diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java index 98aaca7f06..ed2326946f 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java @@ -24,12 +24,13 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Component; -import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.DeviceTransportType; +import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.msg.EncryptionUtil; import org.thingsboard.server.common.transport.TransportService; import org.thingsboard.server.common.transport.TransportServiceCallback; import org.thingsboard.server.common.transport.auth.ValidateDeviceCredentialsResponse; +import org.thingsboard.server.common.transport.auth.ValidateDeviceProfileCredentialsResponse; import org.thingsboard.server.common.transport.config.ssl.SslCredentials; import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; import org.thingsboard.server.common.transport.util.SslUtil; @@ -142,8 +143,15 @@ public class MqttSslHandlerProvider { } @Override - public void checkClientTrusted(X509Certificate[] chain, - String authType) throws CertificateException { + public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { + String deviceCN = SslUtil.parseCommonName(chain[0]); + String deviceCert; + String deviceCredentialsValue = SslUtil.getCertificateString(chain[0]); + try { + deviceCert = EncryptionUtil.getSha3Hash(SslUtil.getCertificateString(chain[0])); + } catch (CertificateEncodingException e) { + throw new RuntimeException(e); + } String credentialsBody = null; for (X509Certificate cert : chain) { try { @@ -152,13 +160,53 @@ public class MqttSslHandlerProvider { final String[] credentialsBodyHolder = new String[1]; CountDownLatch latch = new CountDownLatch(1); transportService.process(DeviceTransportType.MQTT, TransportProtos.ValidateDeviceX509CertRequestMsg.newBuilder().setHash(sha3Hash).build(), - new TransportServiceCallback() { + new TransportServiceCallback<>() { @Override public void onSuccess(ValidateDeviceCredentialsResponse msg) { if (!StringUtils.isEmpty(msg.getCredentials())) { credentialsBodyHolder[0] = msg.getCredentials(); + latch.countDown(); + } else { + transportService.process(DeviceTransportType.MQTT, + TransportProtos.ValidateDeviceProfileX509CertRequestMsg.newBuilder().setHash(sha3Hash).build(), + new TransportServiceCallback<>() { + @Override + public void onSuccess(ValidateDeviceProfileCredentialsResponse msg) { + if (msg.isDeviceProfileFound()) { + transportService.process(DeviceTransportType.MQTT, + TransportProtos.UpdateOrCreateDeviceX509CertRequestMsg.newBuilder() + .setHash(deviceCert) + .setCommonName(deviceCN) + .setDeviceProfileIdMSB(msg.getDeviceProfileId().getId().getMostSignificantBits()) + .setDeviceProfileIdLSB(msg.getDeviceProfileId().getId().getLeastSignificantBits()) + .build(), + new TransportServiceCallback<>() { + @Override + public void onSuccess(ValidateDeviceCredentialsResponse msg) { + System.out.println("msg.getCredentials() = " + msg.getCredentials()); + credentialsBodyHolder[0] = msg.getCredentials(); + latch.countDown(); + } + + @Override + public void onError(Throwable e) { + log.error(e.getMessage(), e); + latch.countDown(); + } + } + ); + } else { + latch.countDown(); + } + } + + @Override + public void onError(Throwable e) { + log.error(e.getMessage(), e); + latch.countDown(); + } + }); } - latch.countDown(); } @Override @@ -168,7 +216,7 @@ public class MqttSslHandlerProvider { } }); latch.await(10, TimeUnit.SECONDS); - if (strCert.equals(credentialsBodyHolder[0])) { + if (deviceCredentialsValue.equals(credentialsBodyHolder[0])) { credentialsBody = credentialsBodyHolder[0]; break; } diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java index 7515a1fcd2..5044d5a35e 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java @@ -21,7 +21,6 @@ import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelInboundHandlerAdapter; import io.netty.handler.codec.mqtt.MqttConnAckMessage; -import io.netty.handler.codec.mqtt.MqttConnAckVariableHeader; import io.netty.handler.codec.mqtt.MqttConnectMessage; import io.netty.handler.codec.mqtt.MqttConnectReturnCode; import io.netty.handler.codec.mqtt.MqttFixedHeader; @@ -63,12 +62,12 @@ import org.thingsboard.server.common.transport.adaptor.AdaptorException; import org.thingsboard.server.common.transport.auth.SessionInfoCreator; import org.thingsboard.server.common.transport.auth.TransportDeviceInfo; import org.thingsboard.server.common.transport.auth.ValidateDeviceCredentialsResponse; +import org.thingsboard.server.common.transport.auth.ValidateDeviceProfileCredentialsResponse; import org.thingsboard.server.common.transport.service.DefaultTransportService; import org.thingsboard.server.common.transport.service.SessionMetaData; import org.thingsboard.server.common.transport.util.SslUtil; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.ProvisionDeviceResponseMsg; -import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceX509CertRequestMsg; import org.thingsboard.server.queue.scheduler.SchedulerComponent; import org.thingsboard.server.transport.mqtt.adaptors.MqttTransportAdaptor; import org.thingsboard.server.transport.mqtt.session.DeviceSessionCtx; @@ -80,7 +79,7 @@ import org.thingsboard.server.transport.mqtt.util.ReturnCodeResolver; import javax.net.ssl.SSLPeerUnverifiedException; import java.io.IOException; import java.net.InetSocketAddress; -import java.security.cert.Certificate; +import java.security.cert.CertificateEncodingException; import java.security.cert.X509Certificate; import java.util.ArrayList; import java.util.Collections; @@ -90,16 +89,15 @@ import java.util.UUID; import java.util.concurrent.Callable; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; import static com.amazonaws.util.StringUtils.UTF8; -import static io.netty.handler.codec.mqtt.MqttMessageType.CONNACK; import static io.netty.handler.codec.mqtt.MqttMessageType.CONNECT; import static io.netty.handler.codec.mqtt.MqttMessageType.PINGRESP; import static io.netty.handler.codec.mqtt.MqttMessageType.SUBACK; -import static io.netty.handler.codec.mqtt.MqttMessageType.UNSUBACK; import static io.netty.handler.codec.mqtt.MqttQoS.AT_LEAST_ONCE; import static io.netty.handler.codec.mqtt.MqttQoS.AT_MOST_ONCE; import static org.thingsboard.server.common.transport.service.DefaultTransportService.SESSION_EVENT_MSG_CLOSED; @@ -810,9 +808,9 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement deviceSessionCtx.setProvisionOnly(true); ctx.writeAndFlush(createMqttConnAckMsg(ReturnCode.SUCCESS, msg)); } else { - X509Certificate cert; - if (sslHandler != null && (cert = getX509Certificate()) != null) { - processX509CertConnect(ctx, cert, msg); + X509Certificate[] chain; + if (sslHandler != null && (chain = getX509Certificate()) != null) { + processX509CertConnect(ctx, chain, msg); } else { processAuthTokenConnect(ctx, msg); } @@ -848,27 +846,82 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement }); } - private void processX509CertConnect(ChannelHandlerContext ctx, X509Certificate cert, MqttConnectMessage connectMessage) { + private void processX509CertConnect(ChannelHandlerContext ctx, X509Certificate[] chain, MqttConnectMessage connectMessage) { try { - if (!context.isSkipValidityCheckForClientCert()) { - cert.checkValidity(); + String deviceCN = SslUtil.parseCommonName(chain[0]); + String deviceCertHash = EncryptionUtil.getSha3Hash(SslUtil.getCertificateString(chain[0])); + for (X509Certificate cert : chain) { + try { + String strCert = SslUtil.getCertificateString(cert); + String sha3Hash = EncryptionUtil.getSha3Hash(strCert); + final ValidateDeviceCredentialsResponse[] validateDeviceCredentialsResponses = new ValidateDeviceCredentialsResponse[1]; + CountDownLatch latch = new CountDownLatch(1); + transportService.process(DeviceTransportType.MQTT, TransportProtos.ValidateDeviceX509CertRequestMsg.newBuilder().setHash(sha3Hash).build(), + new TransportServiceCallback<>() { + @Override + public void onSuccess(ValidateDeviceCredentialsResponse msg) { + if (!StringUtils.isEmpty(msg.getCredentials())) { + validateDeviceCredentialsResponses[0] = msg; + latch.countDown(); + } else { + transportService.process(DeviceTransportType.MQTT, + TransportProtos.ValidateDeviceProfileX509CertRequestMsg.newBuilder().setHash(sha3Hash).build(), + new TransportServiceCallback<>() { + @Override + public void onSuccess(ValidateDeviceProfileCredentialsResponse msg) { + if (msg.isDeviceProfileFound()) { + transportService.process(DeviceTransportType.MQTT, + TransportProtos.UpdateOrCreateDeviceX509CertRequestMsg.newBuilder() + .setHash(deviceCertHash) + .setCommonName(deviceCN) + .setDeviceProfileIdMSB(msg.getDeviceProfileId().getId().getMostSignificantBits()) + .setDeviceProfileIdLSB(msg.getDeviceProfileId().getId().getLeastSignificantBits()) + .build(), + new TransportServiceCallback<>() { + @Override + public void onSuccess(ValidateDeviceCredentialsResponse msg) { + if (!StringUtils.isEmpty(msg.getCredentials())) { + validateDeviceCredentialsResponses[0] = msg; + latch.countDown(); + } + } + + @Override + public void onError(Throwable e) { + log.error(e.getMessage(), e); + latch.countDown(); + } + } + ); + } else { + latch.countDown(); + } + } + + @Override + public void onError(Throwable e) { + log.error(e.getMessage(), e); + latch.countDown(); + } + }); + } + } + + @Override + public void onError(Throwable e) { + log.error(e.getMessage(), e); + latch.countDown(); + } + }); + latch.await(10, TimeUnit.SECONDS); + if (validateDeviceCredentialsResponses[0] != null && validateDeviceCredentialsResponses[0].hasDeviceInfo()) { + onValidateDeviceResponse(validateDeviceCredentialsResponses[0], ctx, connectMessage); + break; + } + } catch (InterruptedException | CertificateEncodingException e) { + log.error(e.getMessage(), e); + } } - String strCert = SslUtil.getCertificateString(cert); - String sha3Hash = EncryptionUtil.getSha3Hash(strCert); - transportService.process(DeviceTransportType.MQTT, ValidateDeviceX509CertRequestMsg.newBuilder().setHash(sha3Hash).build(), - new TransportServiceCallback<>() { - @Override - public void onSuccess(ValidateDeviceCredentialsResponse msg) { - onValidateDeviceResponse(msg, ctx, connectMessage); - } - - @Override - public void onError(Throwable e) { - log.trace("[{}] Failed to process credentials: {}", address, sha3Hash, e); - ctx.writeAndFlush(createMqttConnAckMsg(ReturnCode.SERVER_UNAVAILABLE_5, connectMessage)); - ctx.close(); - } - }); } catch (Exception e) { context.onAuthFailure(address); ctx.writeAndFlush(createMqttConnAckMsg(ReturnCode.NOT_AUTHORIZED_5, connectMessage)); @@ -877,17 +930,13 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement } } - private X509Certificate getX509Certificate() { + private X509Certificate[] getX509Certificate() { try { - Certificate[] certChain = sslHandler.engine().getSession().getPeerCertificates(); - if (certChain.length > 0) { - return (X509Certificate) certChain[0]; - } + return (X509Certificate[]) sslHandler.engine().getSession().getPeerCertificates(); } catch (SSLPeerUnverifiedException e) { log.warn(e.getMessage()); return null; } - return null; } private MqttConnAckMessage createMqttConnAckMsg(ReturnCode returnCode, MqttConnectMessage msg) { diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java index f1d3f7e26c..21e668cf37 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java @@ -20,6 +20,7 @@ import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.rpc.RpcStatus; import org.thingsboard.server.common.transport.auth.GetOrCreateDeviceFromGatewayResponse; import org.thingsboard.server.common.transport.auth.ValidateDeviceCredentialsResponse; +import org.thingsboard.server.common.transport.auth.ValidateDeviceProfileCredentialsResponse; import org.thingsboard.server.common.transport.service.SessionMetaData; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.ClaimDeviceMsg; @@ -52,8 +53,10 @@ import org.thingsboard.server.gen.transport.TransportProtos.ToDeviceRpcRequestMs import org.thingsboard.server.gen.transport.TransportProtos.ToDeviceRpcResponseMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToServerRpcRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.TransportToDeviceActorMsg; +import org.thingsboard.server.gen.transport.TransportProtos.UpdateOrCreateDeviceX509CertRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.ValidateBasicMqttCredRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceLwM2MCredentialsRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceProfileX509CertRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceTokenRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceX509CertRequestMsg; @@ -87,6 +90,12 @@ public interface TransportService { void process(DeviceTransportType transportType, ValidateDeviceX509CertRequestMsg msg, TransportServiceCallback callback); + void process(DeviceTransportType transportType, ValidateDeviceProfileX509CertRequestMsg msg, + TransportServiceCallback callback); + + void process(DeviceTransportType transportType, UpdateOrCreateDeviceX509CertRequestMsg msg, + TransportServiceCallback callback); + void process(ValidateDeviceLwM2MCredentialsRequestMsg msg, TransportServiceCallback callback); diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/auth/ValidateDeviceProfileCredentialsResponse.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/auth/ValidateDeviceProfileCredentialsResponse.java new file mode 100644 index 0000000000..cfbe79bf96 --- /dev/null +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/auth/ValidateDeviceProfileCredentialsResponse.java @@ -0,0 +1,28 @@ +/** + * 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. + */ +package org.thingsboard.server.common.transport.auth; + +import lombok.Builder; +import lombok.Data; +import org.thingsboard.server.common.data.id.DeviceProfileId; + +@Data +@Builder +public class ValidateDeviceProfileCredentialsResponse { + + private final DeviceProfileId deviceProfileId; + private final boolean isDeviceProfileFound; +} diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java index bf03122b18..a41c1d6055 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java @@ -71,8 +71,8 @@ import org.thingsboard.server.common.transport.TransportTenantProfileCache; import org.thingsboard.server.common.transport.auth.GetOrCreateDeviceFromGatewayResponse; import org.thingsboard.server.common.transport.auth.TransportDeviceInfo; import org.thingsboard.server.common.transport.auth.ValidateDeviceCredentialsResponse; +import org.thingsboard.server.common.transport.auth.ValidateDeviceProfileCredentialsResponse; import org.thingsboard.server.common.transport.limits.TransportRateLimitService; -import org.thingsboard.server.queue.util.DataDecodingEncodingService; import org.thingsboard.server.common.transport.util.JsonUtils; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.ProvisionDeviceRequestMsg; @@ -97,6 +97,7 @@ import org.thingsboard.server.queue.provider.TbQueueProducerProvider; import org.thingsboard.server.queue.provider.TbTransportQueueFactory; import org.thingsboard.server.queue.scheduler.SchedulerComponent; import org.thingsboard.server.queue.util.AfterStartUp; +import org.thingsboard.server.queue.util.DataDecodingEncodingService; import org.thingsboard.server.queue.util.TbTransportComponent; import javax.annotation.PostConstruct; @@ -427,6 +428,32 @@ public class DefaultTransportService implements TransportService { AsyncCallbackTemplate.withCallback(response, callback::onSuccess, callback::onError, transportCallbackExecutor); } + @Override + public void process(DeviceTransportType transportType, TransportProtos.ValidateDeviceProfileX509CertRequestMsg requestMsg, + TransportServiceCallback callback) { + log.trace("Processing msg: {}", requestMsg); + TbProtoQueueMsg protoMsg = new TbProtoQueueMsg<>(UUID.randomUUID(), + TransportApiRequestMsg.newBuilder().setValidateProfileX509CertRequestMsg(requestMsg).build()); + ListenableFuture response = Futures.transform(transportApiRequestTemplate.send(protoMsg), tmp -> { + TransportProtos.ValidateDeviceProfileCredentialsResponseMsg msg = tmp.getValue().getValidateDeviceProfileResponseMsg(); + ValidateDeviceProfileCredentialsResponse.ValidateDeviceProfileCredentialsResponseBuilder result = ValidateDeviceProfileCredentialsResponse.builder(); + DeviceProfileId deviceProfileId = new DeviceProfileId(new UUID(msg.getDeviceProfileIdMSB(), msg.getDeviceProfileIdLSB())); + result.deviceProfileId(deviceProfileId); + result.isDeviceProfileFound(msg.getIsDeviceProfileFound()); + return result.build(); + }, MoreExecutors.directExecutor()); + AsyncCallbackTemplate.withCallback(response, callback::onSuccess, callback::onError, transportCallbackExecutor); + } + + @Override + public void process(DeviceTransportType transportType, TransportProtos.UpdateOrCreateDeviceX509CertRequestMsg requestMsg, + TransportServiceCallback callback) { + log.trace("Processing msg: {}", requestMsg); + TbProtoQueueMsg protoMsg = new TbProtoQueueMsg<>(UUID.randomUUID(), TransportApiRequestMsg.newBuilder() + .setUpdateOrCreateDeviceCertRequestMsg(requestMsg).build()); + doProcess(transportType, protoMsg, callback); + } + @Override public void process(DeviceTransportType transportType, TransportProtos.ValidateDeviceX509CertRequestMsg msg, TransportServiceCallback callback) { log.trace("Processing msg: {}", msg); diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java index 45a09a9899..51ed2cd948 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java @@ -16,11 +16,17 @@ package org.thingsboard.server.common.transport.util; import lombok.extern.slf4j.Slf4j; +import org.bouncycastle.asn1.x500.RDN; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x500.style.BCStyle; +import org.bouncycastle.asn1.x500.style.IETFUtils; +import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder; import org.springframework.util.Base64Utils; import org.thingsboard.server.common.msg.EncryptionUtil; import java.security.cert.Certificate; import java.security.cert.CertificateEncodingException; +import java.security.cert.X509Certificate; /** * @author Valerii Sosliuk @@ -35,4 +41,15 @@ public class SslUtil { throws CertificateEncodingException { return EncryptionUtil.certTrimNewLines(Base64Utils.encodeToString(cert.getEncoded())); } + + public static String parseCommonName(X509Certificate certificate) { + X500Name x500name; + try { + x500name = new JcaX509CertificateHolder(certificate).getSubject(); + } catch (CertificateEncodingException e) { + throw new RuntimeException(e); + } + RDN cn = x500name.getRDNs(BCStyle.CN)[0]; + return IETFUtils.valueToString(cn.getFirst().getValue()); + } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsDao.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsDao.java index 10363ac5d7..b6758af59c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsDao.java @@ -53,4 +53,12 @@ public interface DeviceCredentialsDao extends Dao { */ DeviceCredentials findByCredentialsId(TenantId tenantId, String credentialsId); + /** + * Find device credentials by device name. + * + * @param deviceName the device name + * @return the device credentials object + */ + DeviceCredentials findByTenantIdAndDeviceName(TenantId tenantId, String deviceName); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java index c4cbabfb3d..c21031fe65 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java @@ -16,15 +16,12 @@ package org.thingsboard.server.dao.device; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.extern.slf4j.Slf4j; import org.eclipse.leshan.core.SecurityMode; import org.eclipse.leshan.core.util.SecurityUtil; import org.hibernate.exception.ConstraintViolationException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionalEventListener; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.StringUtils; @@ -86,6 +83,13 @@ public class DeviceCredentialsServiceImpl extends AbstractCachedEntityService, ExportableEntityDa DeviceProfile findByProvisionDeviceKey(String provisionDeviceKey); DeviceProfile findByName(TenantId tenantId, String profileName); + + DeviceProfile findByCertificateHash(String certificateHash); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java index 9dad6a22d9..41a51176f4 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java @@ -53,6 +53,7 @@ import java.util.Map; import java.util.Optional; import static org.thingsboard.server.dao.service.Validator.validateId; +import static org.thingsboard.server.dao.service.Validator.validateString; @Service("DeviceProfileDaoService") @Slf4j @@ -61,6 +62,7 @@ public class DeviceProfileServiceImpl extends AbstractCachedEntityService deviceProfileDao.findByCertificateHash(certificateHash), true); + } + @Override public DeviceProfile findOrCreateDeviceProfile(TenantId tenantId, String name) { log.trace("Executing findOrCreateDefaultDeviceProfile"); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceCredentialsRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceCredentialsRepository.java index 0dd35a39b8..7642b45d5b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceCredentialsRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceCredentialsRepository.java @@ -16,6 +16,8 @@ package org.thingsboard.server.dao.sql.device; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.thingsboard.server.dao.model.sql.DeviceCredentialsEntity; import java.util.UUID; @@ -28,4 +30,7 @@ public interface DeviceCredentialsRepository extends JpaRepository Date: Fri, 13 Jan 2023 17:27:08 +0200 Subject: [PATCH 007/100] Fix sql formatting and incorrect proto-message updating --- common/cluster-api/src/main/proto/queue.proto | 50 ++++++------ .../mqtt/MqttSslHandlerProvider.java | 15 ++-- .../transport/mqtt/MqttTransportHandler.java | 6 +- .../sql/device/JpaDeviceCredentialsDao.java | 2 +- .../main/resources/sql/schema-entities.sql | 76 +++++-------------- 5 files changed, 52 insertions(+), 97 deletions(-) diff --git a/common/cluster-api/src/main/proto/queue.proto b/common/cluster-api/src/main/proto/queue.proto index 405b96726a..ace1f3cf6d 100644 --- a/common/cluster-api/src/main/proto/queue.proto +++ b/common/cluster-api/src/main/proto/queue.proto @@ -903,36 +903,36 @@ message VersionControlResponseMsg { message TransportApiRequestMsg { ValidateDeviceTokenRequestMsg validateTokenRequestMsg = 1; ValidateDeviceX509CertRequestMsg validateX509CertRequestMsg = 2; - ValidateDeviceProfileX509CertRequestMsg validateProfileX509CertRequestMsg = 3; - GetOrCreateDeviceFromGatewayRequestMsg getOrCreateDeviceRequestMsg = 4; - GetEntityProfileRequestMsg entityProfileRequestMsg = 5; - LwM2MRequestMsg lwM2MRequestMsg = 6; - ValidateBasicMqttCredRequestMsg validateBasicMqttCredRequestMsg = 7; - ProvisionDeviceRequestMsg provisionDeviceRequestMsg = 8; - ValidateDeviceLwM2MCredentialsRequestMsg validateDeviceLwM2MCredentialsRequestMsg = 9; - GetResourceRequestMsg resourceRequestMsg = 10; - GetOtaPackageRequestMsg otaPackageRequestMsg = 11; - GetSnmpDevicesRequestMsg snmpDevicesRequestMsg = 12; - GetDeviceRequestMsg deviceRequestMsg = 13; - GetDeviceCredentialsRequestMsg deviceCredentialsRequestMsg = 14; - GetAllQueueRoutingInfoRequestMsg getAllQueueRoutingInfoRequestMsg = 15; - UpdateOrCreateDeviceX509CertRequestMsg updateOrCreateDeviceCertRequestMsg = 16; + GetOrCreateDeviceFromGatewayRequestMsg getOrCreateDeviceRequestMsg = 3; + GetEntityProfileRequestMsg entityProfileRequestMsg = 4; + LwM2MRequestMsg lwM2MRequestMsg = 5; + ValidateBasicMqttCredRequestMsg validateBasicMqttCredRequestMsg = 6; + ProvisionDeviceRequestMsg provisionDeviceRequestMsg = 7; + ValidateDeviceLwM2MCredentialsRequestMsg validateDeviceLwM2MCredentialsRequestMsg = 8; + GetResourceRequestMsg resourceRequestMsg = 9; + GetOtaPackageRequestMsg otaPackageRequestMsg = 10; + GetSnmpDevicesRequestMsg snmpDevicesRequestMsg = 11; + GetDeviceRequestMsg deviceRequestMsg = 12; + GetDeviceCredentialsRequestMsg deviceCredentialsRequestMsg = 13; + GetAllQueueRoutingInfoRequestMsg getAllQueueRoutingInfoRequestMsg = 14; + UpdateOrCreateDeviceX509CertRequestMsg updateOrCreateDeviceCertRequestMsg = 15; + ValidateDeviceProfileX509CertRequestMsg validateProfileX509CertRequestMsg = 16; } /* Response from ThingsBoard Core Service to Transport Service */ message TransportApiResponseMsg { ValidateDeviceCredentialsResponseMsg validateCredResponseMsg = 1; - ValidateDeviceProfileCredentialsResponseMsg validateDeviceProfileResponseMsg = 2; - GetOrCreateDeviceFromGatewayResponseMsg getOrCreateDeviceResponseMsg = 3; - GetEntityProfileResponseMsg entityProfileResponseMsg = 4; - ProvisionDeviceResponseMsg provisionDeviceResponseMsg = 5; - GetSnmpDevicesResponseMsg snmpDevicesResponseMsg = 6; - LwM2MResponseMsg lwM2MResponseMsg = 7; - GetResourceResponseMsg resourceResponseMsg = 8; - GetOtaPackageResponseMsg otaPackageResponseMsg = 9; - GetDeviceResponseMsg deviceResponseMsg = 10; - GetDeviceCredentialsResponseMsg deviceCredentialsResponseMsg = 11; - repeated GetQueueRoutingInfoResponseMsg getQueueRoutingInfoResponseMsgs = 12; + GetOrCreateDeviceFromGatewayResponseMsg getOrCreateDeviceResponseMsg = 2; + GetEntityProfileResponseMsg entityProfileResponseMsg = 3; + ProvisionDeviceResponseMsg provisionDeviceResponseMsg = 4; + GetSnmpDevicesResponseMsg snmpDevicesResponseMsg = 5; + LwM2MResponseMsg lwM2MResponseMsg = 6; + GetResourceResponseMsg resourceResponseMsg = 7; + GetOtaPackageResponseMsg otaPackageResponseMsg = 8; + GetDeviceResponseMsg deviceResponseMsg = 9; + GetDeviceCredentialsResponseMsg deviceCredentialsResponseMsg = 10; + repeated GetQueueRoutingInfoResponseMsg getQueueRoutingInfoResponseMsgs = 11; + ValidateDeviceProfileCredentialsResponseMsg validateDeviceProfileResponseMsg = 12; } /* Messages that are handled by ThingsBoard Core Service */ diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java index ed2326946f..237ff86375 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java @@ -145,13 +145,8 @@ public class MqttSslHandlerProvider { @Override public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { String deviceCN = SslUtil.parseCommonName(chain[0]); - String deviceCert; - String deviceCredentialsValue = SslUtil.getCertificateString(chain[0]); - try { - deviceCert = EncryptionUtil.getSha3Hash(SslUtil.getCertificateString(chain[0])); - } catch (CertificateEncodingException e) { - throw new RuntimeException(e); - } + String clientDeviceCertValue = SslUtil.getCertificateString(chain[0]); + String clientDeviceCertHash = EncryptionUtil.getSha3Hash(clientDeviceCertValue); String credentialsBody = null; for (X509Certificate cert : chain) { try { @@ -175,7 +170,8 @@ public class MqttSslHandlerProvider { if (msg.isDeviceProfileFound()) { transportService.process(DeviceTransportType.MQTT, TransportProtos.UpdateOrCreateDeviceX509CertRequestMsg.newBuilder() - .setHash(deviceCert) + .setHash(clientDeviceCertHash) + .setValue(clientDeviceCertValue) .setCommonName(deviceCN) .setDeviceProfileIdMSB(msg.getDeviceProfileId().getId().getMostSignificantBits()) .setDeviceProfileIdLSB(msg.getDeviceProfileId().getId().getLeastSignificantBits()) @@ -183,7 +179,6 @@ public class MqttSslHandlerProvider { new TransportServiceCallback<>() { @Override public void onSuccess(ValidateDeviceCredentialsResponse msg) { - System.out.println("msg.getCredentials() = " + msg.getCredentials()); credentialsBodyHolder[0] = msg.getCredentials(); latch.countDown(); } @@ -216,7 +211,7 @@ public class MqttSslHandlerProvider { } }); latch.await(10, TimeUnit.SECONDS); - if (deviceCredentialsValue.equals(credentialsBodyHolder[0])) { + if (clientDeviceCertValue.equals(credentialsBodyHolder[0])) { credentialsBody = credentialsBodyHolder[0]; break; } diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java index 5044d5a35e..803a8ff3ca 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java @@ -849,7 +849,8 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement private void processX509CertConnect(ChannelHandlerContext ctx, X509Certificate[] chain, MqttConnectMessage connectMessage) { try { String deviceCN = SslUtil.parseCommonName(chain[0]); - String deviceCertHash = EncryptionUtil.getSha3Hash(SslUtil.getCertificateString(chain[0])); + String clientDeviceCertValue = SslUtil.getCertificateString(chain[0]); + String clientDeviceCertHash = EncryptionUtil.getSha3Hash(clientDeviceCertValue); for (X509Certificate cert : chain) { try { String strCert = SslUtil.getCertificateString(cert); @@ -872,7 +873,8 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement if (msg.isDeviceProfileFound()) { transportService.process(DeviceTransportType.MQTT, TransportProtos.UpdateOrCreateDeviceX509CertRequestMsg.newBuilder() - .setHash(deviceCertHash) + .setHash(clientDeviceCertHash) + .setValue(clientDeviceCertValue) .setCommonName(deviceCN) .setDeviceProfileIdMSB(msg.getDeviceProfileId().getId().getMostSignificantBits()) .setDeviceProfileIdLSB(msg.getDeviceProfileId().getId().getLeastSignificantBits()) diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceCredentialsDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceCredentialsDao.java index a56a13c98a..01a746c9b8 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceCredentialsDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceCredentialsDao.java @@ -69,6 +69,6 @@ public class JpaDeviceCredentialsDao extends JpaAbstractDao Date: Thu, 19 Jan 2023 15:18:15 +0200 Subject: [PATCH 008/100] Add device profile upgrade --- .../src/main/data/upgrade/3.4.3/schema_update.sql | 12 ++++++++++++ dao/src/main/resources/sql/schema-entities.sql | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/application/src/main/data/upgrade/3.4.3/schema_update.sql b/application/src/main/data/upgrade/3.4.3/schema_update.sql index e6fd32bc2d..87e9e98aef 100644 --- a/application/src/main/data/upgrade/3.4.3/schema_update.sql +++ b/application/src/main/data/upgrade/3.4.3/schema_update.sql @@ -24,3 +24,15 @@ CREATE TABLE IF NOT EXISTS alarm_comment ( CONSTRAINT fk_alarm_comment_alarm_id FOREIGN KEY (alarm_id) REFERENCES alarm(id) ON DELETE CASCADE ) PARTITION BY RANGE (created_time); CREATE INDEX IF NOT EXISTS idx_alarm_comment_alarm_id ON alarm_comment(alarm_id); + + + +-- DEVICE PROFILE CERTIFICATE START + +ALTER TABLE device_profile + ADD COLUMN IF NOT EXISTS certificate_hash varchar, + ADD COLUMN IF NOT EXISTS certificate_value varchar, + ADD COLUMN IF NOT EXISTS certificate_regex_pattern varchar(255), + DROP CONSTRAINT IF EXISTS device_profile_credentials_hash_unq_key, + ADD CONSTRAINT device_profile_credentials_hash_unq_key UNIQUE (certificate_hash); +-- DEVICE PROFILE CERTIFICATE END diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index 5d2921370a..6a2996bff7 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -292,7 +292,7 @@ CREATE TABLE IF NOT EXISTS device_profile ( default_dashboard_id uuid, default_queue_name varchar(255), provision_device_key varchar, - certificate_value varchar; + certificate_value varchar, certificate_hash varchar, certificate_regex_pattern varchar(255), default_edge_rule_chain_id uuid, From 18c1440a488dcc6594d2a5dbb5457ec0e37e32de Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Thu, 19 Jan 2023 15:21:48 +0200 Subject: [PATCH 009/100] Add validation on device profile create or update --- .../server/common/data/DeviceProfile.java | 2 +- .../mqtt/MqttSslHandlerProvider.java | 4 +- .../transport/mqtt/MqttTransportHandler.java | 111 +++++------------- .../dao/device/DeviceProfileServiceImpl.java | 24 +++- .../validator/DeviceProfileDataValidator.java | 54 +++++++++ 5 files changed, 107 insertions(+), 88 deletions(-) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfile.java b/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfile.java index d0efc8cb5b..54989e1aab 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfile.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfile.java @@ -70,7 +70,7 @@ public class DeviceProfile extends SearchTextBased implements H private String certificateValue; @ApiModelProperty(position = 17, value = "CA certificate hash") private String certificateHash; - @ApiModelProperty(position = 18, value = "Regex for fetch deviceName from CN") + @ApiModelProperty(position = 18, value = "Regex to fetch deviceName from CN") private String certificateRegexPattern; @ApiModelProperty(position = 7, value = "Reference to the rule chain. " + diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java index 237ff86375..2b4a24e116 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java @@ -124,7 +124,7 @@ public class MqttSslHandlerProvider { static class ThingsboardMqttX509TrustManager implements X509TrustManager { private final X509TrustManager trustManager; - private TransportService transportService; + private final TransportService transportService; ThingsboardMqttX509TrustManager(X509TrustManager trustManager, TransportService transportService) { this.trustManager = trustManager; @@ -220,7 +220,7 @@ public class MqttSslHandlerProvider { } } if (credentialsBody == null) { - throw new CertificateException("Invalid Device Certificate"); + throw new CertificateException("Invalid Certificate's chain"); } } } diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java index 803a8ff3ca..9159759c28 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java @@ -62,7 +62,6 @@ import org.thingsboard.server.common.transport.adaptor.AdaptorException; import org.thingsboard.server.common.transport.auth.SessionInfoCreator; import org.thingsboard.server.common.transport.auth.TransportDeviceInfo; import org.thingsboard.server.common.transport.auth.ValidateDeviceCredentialsResponse; -import org.thingsboard.server.common.transport.auth.ValidateDeviceProfileCredentialsResponse; import org.thingsboard.server.common.transport.service.DefaultTransportService; import org.thingsboard.server.common.transport.service.SessionMetaData; import org.thingsboard.server.common.transport.util.SslUtil; @@ -79,7 +78,7 @@ import org.thingsboard.server.transport.mqtt.util.ReturnCodeResolver; import javax.net.ssl.SSLPeerUnverifiedException; import java.io.IOException; import java.net.InetSocketAddress; -import java.security.cert.CertificateEncodingException; +import java.security.cert.Certificate; import java.security.cert.X509Certificate; import java.util.ArrayList; import java.util.Collections; @@ -89,7 +88,6 @@ import java.util.UUID; import java.util.concurrent.Callable; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; -import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -808,9 +806,9 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement deviceSessionCtx.setProvisionOnly(true); ctx.writeAndFlush(createMqttConnAckMsg(ReturnCode.SUCCESS, msg)); } else { - X509Certificate[] chain; - if (sslHandler != null && (chain = getX509Certificate()) != null) { - processX509CertConnect(ctx, chain, msg); + X509Certificate cert; + if (sslHandler != null && (cert = getX509Certificate()) != null) { + processX509CertConnect(ctx, cert, msg); } else { processAuthTokenConnect(ctx, msg); } @@ -846,84 +844,25 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement }); } - private void processX509CertConnect(ChannelHandlerContext ctx, X509Certificate[] chain, MqttConnectMessage connectMessage) { + private void processX509CertConnect(ChannelHandlerContext ctx, X509Certificate cert, MqttConnectMessage connectMessage) { try { - String deviceCN = SslUtil.parseCommonName(chain[0]); - String clientDeviceCertValue = SslUtil.getCertificateString(chain[0]); - String clientDeviceCertHash = EncryptionUtil.getSha3Hash(clientDeviceCertValue); - for (X509Certificate cert : chain) { - try { - String strCert = SslUtil.getCertificateString(cert); - String sha3Hash = EncryptionUtil.getSha3Hash(strCert); - final ValidateDeviceCredentialsResponse[] validateDeviceCredentialsResponses = new ValidateDeviceCredentialsResponse[1]; - CountDownLatch latch = new CountDownLatch(1); - transportService.process(DeviceTransportType.MQTT, TransportProtos.ValidateDeviceX509CertRequestMsg.newBuilder().setHash(sha3Hash).build(), - new TransportServiceCallback<>() { - @Override - public void onSuccess(ValidateDeviceCredentialsResponse msg) { - if (!StringUtils.isEmpty(msg.getCredentials())) { - validateDeviceCredentialsResponses[0] = msg; - latch.countDown(); - } else { - transportService.process(DeviceTransportType.MQTT, - TransportProtos.ValidateDeviceProfileX509CertRequestMsg.newBuilder().setHash(sha3Hash).build(), - new TransportServiceCallback<>() { - @Override - public void onSuccess(ValidateDeviceProfileCredentialsResponse msg) { - if (msg.isDeviceProfileFound()) { - transportService.process(DeviceTransportType.MQTT, - TransportProtos.UpdateOrCreateDeviceX509CertRequestMsg.newBuilder() - .setHash(clientDeviceCertHash) - .setValue(clientDeviceCertValue) - .setCommonName(deviceCN) - .setDeviceProfileIdMSB(msg.getDeviceProfileId().getId().getMostSignificantBits()) - .setDeviceProfileIdLSB(msg.getDeviceProfileId().getId().getLeastSignificantBits()) - .build(), - new TransportServiceCallback<>() { - @Override - public void onSuccess(ValidateDeviceCredentialsResponse msg) { - if (!StringUtils.isEmpty(msg.getCredentials())) { - validateDeviceCredentialsResponses[0] = msg; - latch.countDown(); - } - } - - @Override - public void onError(Throwable e) { - log.error(e.getMessage(), e); - latch.countDown(); - } - } - ); - } else { - latch.countDown(); - } - } - - @Override - public void onError(Throwable e) { - log.error(e.getMessage(), e); - latch.countDown(); - } - }); - } - } - - @Override - public void onError(Throwable e) { - log.error(e.getMessage(), e); - latch.countDown(); - } - }); - latch.await(10, TimeUnit.SECONDS); - if (validateDeviceCredentialsResponses[0] != null && validateDeviceCredentialsResponses[0].hasDeviceInfo()) { - onValidateDeviceResponse(validateDeviceCredentialsResponses[0], ctx, connectMessage); - break; - } - } catch (InterruptedException | CertificateEncodingException e) { - log.error(e.getMessage(), e); - } + if (!context.isSkipValidityCheckForClientCert()) { + cert.checkValidity(); } + String strCert = SslUtil.getCertificateString(cert); + String sha3Hash = EncryptionUtil.getSha3Hash(strCert); + transportService.process(DeviceTransportType.MQTT, TransportProtos.ValidateDeviceX509CertRequestMsg.newBuilder().setHash(sha3Hash).build(), + new TransportServiceCallback<>() { + @Override + public void onSuccess(ValidateDeviceCredentialsResponse msg) { + onValidateDeviceResponse(msg, ctx, connectMessage); + } + + @Override + public void onError(Throwable e) { + log.error(e.getMessage(), e); + } + }); } catch (Exception e) { context.onAuthFailure(address); ctx.writeAndFlush(createMqttConnAckMsg(ReturnCode.NOT_AUTHORIZED_5, connectMessage)); @@ -932,13 +871,17 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement } } - private X509Certificate[] getX509Certificate() { + private X509Certificate getX509Certificate() { try { - return (X509Certificate[]) sslHandler.engine().getSession().getPeerCertificates(); + Certificate[] certChains = sslHandler.engine().getSession().getPeerCertificates(); + if (certChains.length > 1) { + return (X509Certificate) certChains[0]; + } } catch (SSLPeerUnverifiedException e) { log.warn(e.getMessage()); return null; } + return null; } private MqttConnAckMessage createMqttConnAckMsg(ReturnCode returnCode, MqttConnectMessage msg) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java index 41a51176f4..8cd08739b3 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java @@ -40,6 +40,7 @@ import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.msg.EncryptionUtil; import org.thingsboard.server.dao.entity.AbstractCachedEntityService; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.queue.QueueService; @@ -51,6 +52,8 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import static org.thingsboard.server.dao.service.Validator.validateId; import static org.thingsboard.server.dao.service.Validator.validateString; @@ -62,7 +65,7 @@ public class DeviceProfileServiceImpl extends AbstractCachedEntityService rootCa = new HashSet<>(); + try { + String filename = System.getProperty("java.home") + "/lib/security/cacerts".replace('/', File.separatorChar); + FileInputStream is = new FileInputStream(filename); + KeyStore keystore = KeyStore.getInstance(KeyStore.getDefaultType()); + String password = "changeit"; + keystore.load(is, password.toCharArray()); + + PKIXParameters params = new PKIXParameters(keystore); + for (TrustAnchor ta : params.getTrustAnchors()) { + X509Certificate cert = ta.getTrustedCert(); + rootCa.add(EncryptionUtil.getSha3Hash(getCertificateString(cert))); + } + } catch (CertificateException | KeyStoreException | NoSuchAlgorithmException | + InvalidAlgorithmParameterException | IOException ignored) { + } + return rootCa.contains(deviceProfileHash); + } + + private String getCertificateString(Certificate cert) throws CertificateEncodingException { + return EncryptionUtil.certTrimNewLines(Base64Utils.encodeToString(cert.getEncoded())); + } } From b4a9ba26e7b77f09bd367a54f34bdaef2bcc7676 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Fri, 20 Jan 2023 19:00:19 +0200 Subject: [PATCH 010/100] Add new field: update database and upgrade script --- application/src/main/data/upgrade/3.4.3/schema_update.sql | 2 ++ dao/src/main/resources/sql/schema-entities.sql | 1 + 2 files changed, 3 insertions(+) diff --git a/application/src/main/data/upgrade/3.4.3/schema_update.sql b/application/src/main/data/upgrade/3.4.3/schema_update.sql index 87e9e98aef..78203951ec 100644 --- a/application/src/main/data/upgrade/3.4.3/schema_update.sql +++ b/application/src/main/data/upgrade/3.4.3/schema_update.sql @@ -33,6 +33,8 @@ ALTER TABLE device_profile ADD COLUMN IF NOT EXISTS certificate_hash varchar, ADD COLUMN IF NOT EXISTS certificate_value varchar, ADD COLUMN IF NOT EXISTS certificate_regex_pattern varchar(255), + ADD COLUMN IF NOT EXISTS allow_create_device_by_x509 boolean, DROP CONSTRAINT IF EXISTS device_profile_credentials_hash_unq_key, ADD CONSTRAINT device_profile_credentials_hash_unq_key UNIQUE (certificate_hash); + -- DEVICE PROFILE CERTIFICATE END diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index 6a2996bff7..eed6f237b6 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -297,6 +297,7 @@ CREATE TABLE IF NOT EXISTS device_profile ( certificate_regex_pattern varchar(255), default_edge_rule_chain_id uuid, external_id uuid, + allow_create_device_by_x509 boolean, CONSTRAINT device_profile_credentials_hash_unq_key UNIQUE (certificate_hash), CONSTRAINT device_profile_name_unq_key UNIQUE (tenant_id, name), CONSTRAINT device_provision_key_unq_key UNIQUE (provision_device_key), From 7957da4a9e2ab7922074848018e8dcde8e2bab23 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Fri, 20 Jan 2023 19:02:14 +0200 Subject: [PATCH 011/100] Updated: send full chain to kafka with single message and do whole logic on core --- .../transport/DefaultTransportApiService.java | 148 +++++++++++++----- common/cluster-api/src/main/proto/queue.proto | 22 +-- .../dao/device/DeviceCredentialsService.java | 2 - .../server/common/data/DeviceProfile.java | 13 +- .../data/DeviceProfileProvisionType.java | 3 +- ...509CertificateProvisionConfiguration.java} | 18 ++- .../DeviceProfileProvisionConfiguration.java | 3 +- .../server/common/msg/EncryptionUtil.java | 5 + .../mqtt/MqttSslHandlerProvider.java | 104 ++++-------- .../transport/mqtt/MqttTransportHandler.java | 13 +- .../common/transport/TransportService.java | 11 +- .../service/DefaultTransportService.java | 30 +--- .../server/common/transport/util/SslUtil.java | 12 ++ .../dao/device/DeviceCredentialsDao.java | 8 - .../device/DeviceCredentialsServiceImpl.java | 7 - .../server/dao/model/ModelConstants.java | 1 + .../dao/model/sql/DeviceProfileEntity.java | 5 + .../validator/DeviceProfileDataValidator.java | 20 +-- .../device/DeviceCredentialsRepository.java | 5 - .../sql/device/JpaDeviceCredentialsDao.java | 5 - 20 files changed, 211 insertions(+), 224 deletions(-) rename common/{transport/transport-api/src/main/java/org/thingsboard/server/common/transport/auth/ValidateDeviceProfileCredentialsResponse.java => data/src/main/java/org/thingsboard/server/common/data/device/profile/AllowCreatingNewDevicesByX509CertificateProvisionConfiguration.java} (58%) diff --git a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java index 9bc61d6493..0bf0ff8035 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java @@ -25,6 +25,7 @@ import com.google.common.util.concurrent.MoreExecutors; import com.google.protobuf.ByteString; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.codec.binary.Base64; import org.springframework.stereotype.Service; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.cache.ota.OtaPackageDataCache; @@ -33,6 +34,7 @@ import org.thingsboard.server.common.data.ApiUsageState; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.DeviceProfileProvisionType; import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.OtaPackage; @@ -65,6 +67,7 @@ import org.thingsboard.server.common.msg.EncryptionUtil; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgDataType; import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.common.transport.util.SslUtil; import org.thingsboard.server.dao.device.DeviceCredentialsService; import org.thingsboard.server.dao.device.DeviceProfileService; import org.thingsboard.server.dao.device.DeviceProvisionService; @@ -90,11 +93,8 @@ import org.thingsboard.server.gen.transport.TransportProtos.GetSnmpDevicesRespon import org.thingsboard.server.gen.transport.TransportProtos.ProvisionDeviceRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.TransportApiRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.TransportApiResponseMsg; -import org.thingsboard.server.gen.transport.TransportProtos.UpdateOrCreateDeviceX509CertRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceCredentialsResponseMsg; import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceLwM2MCredentialsRequestMsg; -import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceProfileCredentialsResponseMsg; -import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceProfileX509CertRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceTokenRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceX509CertRequestMsg; import org.thingsboard.server.queue.common.TbProtoQueueMsg; @@ -105,6 +105,13 @@ import org.thingsboard.server.service.executors.DbCallbackExecutorService; import org.thingsboard.server.service.profile.TbDeviceProfileCache; import org.thingsboard.server.service.resource.TbResourceService; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -166,13 +173,9 @@ public class DefaultTransportApiService implements TransportApiService { } else if (transportApiRequestMsg.hasValidateX509CertRequestMsg()) { ValidateDeviceX509CertRequestMsg msg = transportApiRequestMsg.getValidateX509CertRequestMsg(); result = validateCredentials(msg.getHash(), DeviceCredentialsType.X509_CERTIFICATE); - } else if (transportApiRequestMsg.hasValidateProfileX509CertRequestMsg()) { - ValidateDeviceProfileX509CertRequestMsg msg = transportApiRequestMsg.getValidateProfileX509CertRequestMsg(); - result = validateDeviceProfileCertificate(msg.getHash()); - } else if (transportApiRequestMsg.hasUpdateOrCreateDeviceCertRequestMsg()) { - UpdateOrCreateDeviceX509CertRequestMsg msg = transportApiRequestMsg.getUpdateOrCreateDeviceCertRequestMsg(); - DeviceProfile deviceProfile = deviceProfileCache.find(new DeviceProfileId(new UUID(msg.getDeviceProfileIdMSB(), msg.getDeviceProfileIdLSB()))); - result = updateOrCreateDeviceCredentials(msg.getHash(), msg.getValue(), msg.getCommonName(), deviceProfile, DeviceCredentialsType.X509_CERTIFICATE); + } else if (transportApiRequestMsg.hasValidateOrCreateX509CertRequestMsg()) { + TransportProtos.ValidateOrCreateDeviceX509CertRequestMsg msg = transportApiRequestMsg.getValidateOrCreateX509CertRequestMsg(); + result = validateOrCreateDeviceX509Certificate(msg.getCertificate(), DeviceCredentialsType.X509_CERTIFICATE); } else if (transportApiRequestMsg.hasGetOrCreateDeviceRequestMsg()) { result = handle(transportApiRequestMsg.getGetOrCreateDeviceRequestMsg()); } else if (transportApiRequestMsg.hasEntityProfileRequestMsg()) { @@ -240,27 +243,43 @@ public class DefaultTransportApiService implements TransportApiService { } } - private ListenableFuture validateDeviceProfileCertificate(String credentialsId) { - DeviceProfile deviceProfile = deviceProfileService.findDeviceProfileByCertificateHash(credentialsId); - if (deviceProfile != null) { - return getDeviceProfileInfo(deviceProfile); - } - return getEmptyTransportApiResponseFuture(); - } + private ListenableFuture validateOrCreateDeviceX509Certificate(String certChain, DeviceCredentialsType credentialsType) { + try { + List chain = getX509CertificateChainFromString(certChain); + String updateDeviceCertificateValue = SslUtil.getCertificateString(chain.get(0)); + String updateDeviceCertificateHash = EncryptionUtil.getSha3Hash(updateDeviceCertificateValue); + + for (X509Certificate cert: chain) { + String certificateValue = SslUtil.getCertificateString(cert); + String certificateHash = EncryptionUtil.getSha3Hash(certificateValue); + DeviceCredentials credentials = deviceCredentialsService.findDeviceCredentialsByCredentialsId(certificateHash); + if (credentials != null && credentials.getCredentialsType() == credentialsType) { + return getDeviceInfo(credentials); + } + DeviceProfile deviceProfile = deviceProfileService.findDeviceProfileByCertificateHash(certificateHash); + if (deviceProfile != null) { + String deviceCN = extractDeviceNameFromCNByRegEx(SslUtil.parseCommonName(chain.get(0)), deviceProfile.getCertificateRegexPattern()); + String deviceName = extractDeviceNameFromCNByRegEx(deviceCN, deviceProfile.getCertificateRegexPattern()); + Device device = deviceService.findDeviceByTenantIdAndName(deviceProfile.getTenantId(), deviceName); + if (device != null) { + DeviceCredentials deviceCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(device.getTenantId(), device.getId()); + if (deviceCredentials != null && deviceCredentials.getCredentialsType() == credentialsType) { + deviceCredentials = updateDeviceCredentials(device.getTenantId(), deviceCredentials, updateDeviceCertificateValue, updateDeviceCertificateHash, credentialsType); + } else if (deviceCredentials == null) { + deviceCredentials = createDeviceCredentials(device.getTenantId(), device.getId(), updateDeviceCertificateValue, updateDeviceCertificateHash, credentialsType); + } + return getDeviceInfo(deviceCredentials); + } else if (deviceProfile.getProvisionType() == DeviceProfileProvisionType.ALLOW_CREATING_NEW_DEVICES_BY_X509_CERTIFICATE && deviceProfile.isAllowCreateNewDevicesByX509Strategy()) { + Device savedDevice = createDevice(deviceProfile.getTenantId(), deviceProfile.getId(), deviceName, deviceProfile.getName()); + DeviceCredentials deviceCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(savedDevice.getTenantId(), savedDevice.getId()); + deviceCredentials = updateDeviceCredentials(savedDevice.getTenantId(), deviceCredentials, updateDeviceCertificateValue, updateDeviceCertificateHash, credentialsType); + return getDeviceInfo(deviceCredentials); + } - private ListenableFuture updateOrCreateDeviceCredentials(String credentialsId, - String credentialsValue, - String deviceCN, - DeviceProfile deviceProfile, - DeviceCredentialsType credentialsType) { - String deviceName = extractRegex(deviceCN, deviceProfile.getCertificateRegexPattern()); - // find deviceCredentials by deviceName (device exists) - DeviceCredentials deviceCredentials = deviceCredentialsService.findDeviceCredentialsByTenantIdAndDeviceName(deviceProfile.getTenantId(), deviceName); - if (deviceCredentials != null && deviceCredentials.getCredentialsType() == credentialsType) { - deviceCredentials.setCredentialsId(credentialsId); - deviceCredentials.setCredentialsValue(credentialsValue); - deviceCredentialsService.updateDeviceCredentials(deviceProfile.getTenantId(), deviceCredentials); - return getDeviceInfo(deviceCredentials); + } + } + } catch (CertificateEncodingException e) { + throw new RuntimeException(e); } return getEmptyTransportApiResponseFuture(); } @@ -546,18 +565,6 @@ public class DefaultTransportApiService implements TransportApiService { }, MoreExecutors.directExecutor()); } - private ListenableFuture getDeviceProfileInfo(DeviceProfile deviceProfile) { - ValidateDeviceProfileCredentialsResponseMsg.Builder builder = ValidateDeviceProfileCredentialsResponseMsg.newBuilder() - .setDeviceProfileIdMSB(deviceProfile.getId().getId().getMostSignificantBits()) - .setDeviceProfileIdLSB(deviceProfile.getId().getId().getLeastSignificantBits()) - .setIsDeviceProfileFound(true); - - return Futures.immediateFuture( - TransportApiResponseMsg.newBuilder() - .setValidateDeviceProfileResponseMsg(builder.build()) - .build()); - } - private DeviceInfoProto getDeviceInfoProto(Device device) throws JsonProcessingException { DeviceInfoProto.Builder builder = DeviceInfoProto.newBuilder() .setTenantIdMSB(device.getTenantId().getId().getMostSignificantBits()) @@ -716,7 +723,7 @@ public class DefaultTransportApiService implements TransportApiService { return l != null ? l : 0; } - private String extractRegex(String commonName, String regex) { + private String extractDeviceNameFromCNByRegEx(String commonName, String regex) { Pattern pattern = Pattern.compile(regex); Matcher matcher = pattern.matcher(commonName); if (matcher.find()) { @@ -724,4 +731,59 @@ public class DefaultTransportApiService implements TransportApiService { } return commonName; } + + private List getX509CertificateChainFromString(String certificateChain) { + List chain = new ArrayList<>(); + String[] test = Arrays.stream(certificateChain.split("-----BEGIN CERTIFICATE-----")).filter(e -> e.trim().length() > 0).map(EncryptionUtil::certTrimNewLines).toArray(String[]::new); + Arrays.stream(test).forEach(s -> { + try { + chain.add(readCertFile(s)); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + return chain; + } + + private X509Certificate readCertFile(String fileContent) throws Exception { + X509Certificate certificate = null; + if (fileContent != null && !fileContent.trim().isEmpty()) { + fileContent = fileContent.replace("-----BEGIN CERTIFICATE-----", "") + .replace("-----END CERTIFICATE-----", "") + .replaceAll("\\s", ""); + byte[] decoded = Base64.decodeBase64(fileContent); + CertificateFactory certFactory = CertificateFactory.getInstance("X.509"); + try (InputStream inStream = new ByteArrayInputStream(decoded)) { + certificate = (X509Certificate) certFactory.generateCertificate(inStream); + } + } + return certificate; + } + + private DeviceCredentials updateDeviceCredentials(TenantId tenantId, DeviceCredentials deviceCredentials, String certificateValue, + String certificateHash, DeviceCredentialsType credentialsType) { + deviceCredentials.setCredentialsId(certificateHash); + deviceCredentials.setCredentialsValue(certificateValue); + deviceCredentials.setCredentialsType(credentialsType); + return deviceCredentialsService.updateDeviceCredentials(tenantId, deviceCredentials); + } + + private DeviceCredentials createDeviceCredentials(TenantId tenantId, DeviceId deviceId, String certificateValue, + String certificateHash, DeviceCredentialsType credentialsType) { + DeviceCredentials createDevCredentials = new DeviceCredentials(); + createDevCredentials.setDeviceId(deviceId); + createDevCredentials.setCredentialsType(credentialsType); + createDevCredentials.setCredentialsId(certificateHash); + createDevCredentials.setCredentialsValue(certificateValue); + return deviceCredentialsService.createDeviceCredentials(tenantId, createDevCredentials); + } + + private Device createDevice(TenantId tenantId, DeviceProfileId deviceProfileId, String deviceName, String type) { + Device device = new Device(); + device.setTenantId(tenantId); + device.setDeviceProfileId(deviceProfileId); + device.setName(deviceName); + device.setType(type); + return deviceService.saveDevice(device); + } } diff --git a/common/cluster-api/src/main/proto/queue.proto b/common/cluster-api/src/main/proto/queue.proto index ace1f3cf6d..b9741429f7 100644 --- a/common/cluster-api/src/main/proto/queue.proto +++ b/common/cluster-api/src/main/proto/queue.proto @@ -169,16 +169,8 @@ message ValidateDeviceX509CertRequestMsg { string hash = 1; } -message ValidateDeviceProfileX509CertRequestMsg { - string hash = 1; -} - -message UpdateOrCreateDeviceX509CertRequestMsg { - string hash = 1; - string value = 2; - string commonName = 3; - int64 deviceProfileIdMSB = 4; - int64 deviceProfileIdLSB = 5; +message ValidateOrCreateDeviceX509CertRequestMsg { + string certificate = 1; } message ValidateBasicMqttCredRequestMsg { @@ -193,12 +185,6 @@ message ValidateDeviceCredentialsResponseMsg { bytes profileBody = 3; } -message ValidateDeviceProfileCredentialsResponseMsg { - int64 deviceProfileIdMSB = 1; - int64 deviceProfileIdLSB = 2; - bool isDeviceProfileFound = 3; -} - message GetOrCreateDeviceFromGatewayRequestMsg { int64 gatewayIdMSB = 1; int64 gatewayIdLSB = 2; @@ -915,8 +901,7 @@ message TransportApiRequestMsg { GetDeviceRequestMsg deviceRequestMsg = 12; GetDeviceCredentialsRequestMsg deviceCredentialsRequestMsg = 13; GetAllQueueRoutingInfoRequestMsg getAllQueueRoutingInfoRequestMsg = 14; - UpdateOrCreateDeviceX509CertRequestMsg updateOrCreateDeviceCertRequestMsg = 15; - ValidateDeviceProfileX509CertRequestMsg validateProfileX509CertRequestMsg = 16; + ValidateOrCreateDeviceX509CertRequestMsg validateOrCreateX509CertRequestMsg = 15; } /* Response from ThingsBoard Core Service to Transport Service */ @@ -932,7 +917,6 @@ message TransportApiResponseMsg { GetDeviceResponseMsg deviceResponseMsg = 9; GetDeviceCredentialsResponseMsg deviceCredentialsResponseMsg = 10; repeated GetQueueRoutingInfoResponseMsg getQueueRoutingInfoResponseMsgs = 11; - ValidateDeviceProfileCredentialsResponseMsg validateDeviceProfileResponseMsg = 12; } /* Messages that are handled by ThingsBoard Core Service */ diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsService.java index 150106e3cb..1c0f2578ed 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsService.java @@ -26,8 +26,6 @@ public interface DeviceCredentialsService { DeviceCredentials findDeviceCredentialsByCredentialsId(String credentialsId); - DeviceCredentials findDeviceCredentialsByTenantIdAndDeviceName(TenantId tenantId, String deviceName); - DeviceCredentials updateDeviceCredentials(TenantId tenantId, DeviceCredentials deviceCredentials); DeviceCredentials createDeviceCredentials(TenantId tenantId, DeviceCredentials deviceCredentials); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfile.java b/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfile.java index 54989e1aab..b9b1053d99 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfile.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfile.java @@ -66,12 +66,15 @@ public class DeviceProfile extends SearchTextBased implements H private DeviceTransportType transportType; @ApiModelProperty(position = 15, value = "Provisioning strategy.") private DeviceProfileProvisionType provisionType; - @ApiModelProperty(position = 16, value = "CA certificate value") + @ApiModelProperty(position = 16, value = "CA certificate value. ") private String certificateValue; - @ApiModelProperty(position = 17, value = "CA certificate hash") + @ApiModelProperty(position = 17, value = "CA certificate hash. ") private String certificateHash; - @ApiModelProperty(position = 18, value = "Regex to fetch deviceName from CN") + @ApiModelProperty(position = 18, value = "Regex to fetch deviceName from CN. ") private String certificateRegexPattern; + @ApiModelProperty(position = 19, value = "Allow to create new devices by x509 provision strategy. ") + private boolean allowCreateNewDevicesByX509Strategy; + @ApiModelProperty(position = 7, value = "Reference to the rule chain. " + "If present, the specified rule chain will be used to process all messages related to device, including telemetry, attribute updates, etc. " + @@ -128,6 +131,10 @@ public class DeviceProfile extends SearchTextBased implements H this.firmwareId = deviceProfile.getFirmwareId(); this.softwareId = deviceProfile.getSoftwareId(); this.defaultEdgeRuleChainId = deviceProfile.getDefaultEdgeRuleChainId(); + this.allowCreateNewDevicesByX509Strategy = deviceProfile.isAllowCreateNewDevicesByX509Strategy(); + this.certificateRegexPattern = deviceProfile.getCertificateRegexPattern(); + this.certificateValue = deviceProfile.getCertificateValue(); + this.certificateHash = deviceProfile.getCertificateHash(); this.externalId = deviceProfile.getExternalId(); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfileProvisionType.java b/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfileProvisionType.java index 319ebadcbc..7ee03d7227 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfileProvisionType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfileProvisionType.java @@ -18,5 +18,6 @@ package org.thingsboard.server.common.data; public enum DeviceProfileProvisionType { DISABLED, ALLOW_CREATE_NEW_DEVICES, - CHECK_PRE_PROVISIONED_DEVICES + CHECK_PRE_PROVISIONED_DEVICES, + ALLOW_CREATING_NEW_DEVICES_BY_X509_CERTIFICATE } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/auth/ValidateDeviceProfileCredentialsResponse.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AllowCreatingNewDevicesByX509CertificateProvisionConfiguration.java similarity index 58% rename from common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/auth/ValidateDeviceProfileCredentialsResponse.java rename to common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AllowCreatingNewDevicesByX509CertificateProvisionConfiguration.java index cfbe79bf96..e84d09829f 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/auth/ValidateDeviceProfileCredentialsResponse.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AllowCreatingNewDevicesByX509CertificateProvisionConfiguration.java @@ -13,16 +13,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.transport.auth; -import lombok.Builder; +package org.thingsboard.server.common.data.device.profile; + import lombok.Data; -import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.DeviceProfileProvisionType; @Data -@Builder -public class ValidateDeviceProfileCredentialsResponse { +public class AllowCreatingNewDevicesByX509CertificateProvisionConfiguration implements DeviceProfileProvisionConfiguration { + + private final String provisionDeviceSecret; + + @Override + public DeviceProfileProvisionType getType() { + return DeviceProfileProvisionType.ALLOW_CREATING_NEW_DEVICES_BY_X509_CERTIFICATE; + } - private final DeviceProfileId deviceProfileId; - private final boolean isDeviceProfileFound; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileProvisionConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileProvisionConfiguration.java index b29641fe6f..7a70c00b98 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileProvisionConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileProvisionConfiguration.java @@ -31,7 +31,8 @@ import java.io.Serializable; @JsonSubTypes({ @JsonSubTypes.Type(value = DisabledDeviceProfileProvisionConfiguration.class, name = "DISABLED"), @JsonSubTypes.Type(value = AllowCreateNewDevicesDeviceProfileProvisionConfiguration.class, name = "ALLOW_CREATE_NEW_DEVICES"), - @JsonSubTypes.Type(value = CheckPreProvisionedDevicesDeviceProfileProvisionConfiguration.class, name = "CHECK_PRE_PROVISIONED_DEVICES")}) + @JsonSubTypes.Type(value = CheckPreProvisionedDevicesDeviceProfileProvisionConfiguration.class, name = "CHECK_PRE_PROVISIONED_DEVICES"), + @JsonSubTypes.Type(value = AllowCreatingNewDevicesByX509CertificateProvisionConfiguration.class, name = "ALLOW_CREATING_NEW_DEVICES_BY_X509_CERTIFICATE")}) public interface DeviceProfileProvisionConfiguration extends Serializable { String getProvisionDeviceSecret(); diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java b/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java index c57c3a885b..6bccac1591 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java @@ -35,6 +35,11 @@ public class EncryptionUtil { .replaceAll("-----END CERTIFICATE-----", ""); } + public static String certTrimNewLinesWithoutBeginEnd(String input) { + return input.replaceAll("\n", "") + .replaceAll("\r", ""); + } + public static String pubkTrimNewLines(String input) { return input.replaceAll("-----BEGIN PUBLIC KEY-----", "") .replaceAll("\n", "") diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java index 2b4a24e116..67b48464c8 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java @@ -17,6 +17,7 @@ package org.thingsboard.server.transport.mqtt; import io.netty.handler.ssl.SslHandler; import lombok.extern.slf4j.Slf4j; +import org.checkerframework.checker.units.qual.C; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; @@ -26,11 +27,9 @@ import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.StringUtils; -import org.thingsboard.server.common.msg.EncryptionUtil; import org.thingsboard.server.common.transport.TransportService; import org.thingsboard.server.common.transport.TransportServiceCallback; import org.thingsboard.server.common.transport.auth.ValidateDeviceCredentialsResponse; -import org.thingsboard.server.common.transport.auth.ValidateDeviceProfileCredentialsResponse; import org.thingsboard.server.common.transport.config.ssl.SslCredentials; import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; import org.thingsboard.server.common.transport.util.SslUtil; @@ -144,84 +143,37 @@ public class MqttSslHandlerProvider { @Override public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { - String deviceCN = SslUtil.parseCommonName(chain[0]); String clientDeviceCertValue = SslUtil.getCertificateString(chain[0]); - String clientDeviceCertHash = EncryptionUtil.getSha3Hash(clientDeviceCertValue); - String credentialsBody = null; - for (X509Certificate cert : chain) { - try { - String strCert = SslUtil.getCertificateString(cert); - String sha3Hash = EncryptionUtil.getSha3Hash(strCert); - final String[] credentialsBodyHolder = new String[1]; - CountDownLatch latch = new CountDownLatch(1); - transportService.process(DeviceTransportType.MQTT, TransportProtos.ValidateDeviceX509CertRequestMsg.newBuilder().setHash(sha3Hash).build(), - new TransportServiceCallback<>() { - @Override - public void onSuccess(ValidateDeviceCredentialsResponse msg) { - if (!StringUtils.isEmpty(msg.getCredentials())) { - credentialsBodyHolder[0] = msg.getCredentials(); - latch.countDown(); - } else { - transportService.process(DeviceTransportType.MQTT, - TransportProtos.ValidateDeviceProfileX509CertRequestMsg.newBuilder().setHash(sha3Hash).build(), - new TransportServiceCallback<>() { - @Override - public void onSuccess(ValidateDeviceProfileCredentialsResponse msg) { - if (msg.isDeviceProfileFound()) { - transportService.process(DeviceTransportType.MQTT, - TransportProtos.UpdateOrCreateDeviceX509CertRequestMsg.newBuilder() - .setHash(clientDeviceCertHash) - .setValue(clientDeviceCertValue) - .setCommonName(deviceCN) - .setDeviceProfileIdMSB(msg.getDeviceProfileId().getId().getMostSignificantBits()) - .setDeviceProfileIdLSB(msg.getDeviceProfileId().getId().getLeastSignificantBits()) - .build(), - new TransportServiceCallback<>() { - @Override - public void onSuccess(ValidateDeviceCredentialsResponse msg) { - credentialsBodyHolder[0] = msg.getCredentials(); - latch.countDown(); - } - - @Override - public void onError(Throwable e) { - log.error(e.getMessage(), e); - latch.countDown(); - } - } - ); - } else { - latch.countDown(); - } - } - - @Override - public void onError(Throwable e) { - log.error(e.getMessage(), e); - latch.countDown(); - } - }); - } + final String[] credentialsBodyHolder = new String[1]; + CountDownLatch latch = new CountDownLatch(1); + try { + String certificateChain = SslUtil.getCertificateChainString(chain); + transportService.process(DeviceTransportType.MQTT, TransportProtos.ValidateOrCreateDeviceX509CertRequestMsg + .newBuilder().setCertificate(certificateChain).build(), + new TransportServiceCallback<>() { + @Override + public void onSuccess(ValidateDeviceCredentialsResponse msg) { + if (!StringUtils.isEmpty(msg.getCredentials())) { + credentialsBodyHolder[0] = msg.getCredentials(); } - - @Override - public void onError(Throwable e) { - log.error(e.getMessage(), e); - latch.countDown(); - } - }); - latch.await(10, TimeUnit.SECONDS); - if (clientDeviceCertValue.equals(credentialsBodyHolder[0])) { - credentialsBody = credentialsBodyHolder[0]; - break; - } - } catch (InterruptedException | CertificateEncodingException e) { - log.error(e.getMessage(), e); + latch.countDown(); + } + + @Override + public void onError(Throwable e) { + // to fix this error, cuz no one can understand this ... + log.error(e.getMessage(), e); + latch.countDown(); + } + }); + latch.await(10, TimeUnit.SECONDS); + if (!clientDeviceCertValue.equals(credentialsBodyHolder[0])) { + throw new CertificateException("Invalid Certificate's chain"); } - } - if (credentialsBody == null) { - throw new CertificateException("Invalid Certificate's chain"); + } catch (CertificateEncodingException | InterruptedException e) { + log.error(e.getMessage(), e); } } + } } diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java index 9159759c28..813a6f1ed2 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java @@ -67,6 +67,7 @@ import org.thingsboard.server.common.transport.service.SessionMetaData; import org.thingsboard.server.common.transport.util.SslUtil; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.ProvisionDeviceResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceX509CertRequestMsg; import org.thingsboard.server.queue.scheduler.SchedulerComponent; import org.thingsboard.server.transport.mqtt.adaptors.MqttTransportAdaptor; import org.thingsboard.server.transport.mqtt.session.DeviceSessionCtx; @@ -851,7 +852,7 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement } String strCert = SslUtil.getCertificateString(cert); String sha3Hash = EncryptionUtil.getSha3Hash(strCert); - transportService.process(DeviceTransportType.MQTT, TransportProtos.ValidateDeviceX509CertRequestMsg.newBuilder().setHash(sha3Hash).build(), + transportService.process(DeviceTransportType.MQTT, ValidateDeviceX509CertRequestMsg.newBuilder().setHash(sha3Hash).build(), new TransportServiceCallback<>() { @Override public void onSuccess(ValidateDeviceCredentialsResponse msg) { @@ -860,7 +861,9 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement @Override public void onError(Throwable e) { - log.error(e.getMessage(), e); + log.trace("[{}] Failed to process credentials: {}", address, sha3Hash, e); + ctx.writeAndFlush(createMqttConnAckMsg(ReturnCode.SERVER_UNAVAILABLE_5, connectMessage)); + ctx.close(); } }); } catch (Exception e) { @@ -873,9 +876,9 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement private X509Certificate getX509Certificate() { try { - Certificate[] certChains = sslHandler.engine().getSession().getPeerCertificates(); - if (certChains.length > 1) { - return (X509Certificate) certChains[0]; + Certificate[] certChain = sslHandler.engine().getSession().getPeerCertificates(); + if (certChain.length > 1) { + return (X509Certificate) certChain[0]; } } catch (SSLPeerUnverifiedException e) { log.warn(e.getMessage()); diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java index 21e668cf37..3e2e23e43a 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java @@ -20,7 +20,6 @@ import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.rpc.RpcStatus; import org.thingsboard.server.common.transport.auth.GetOrCreateDeviceFromGatewayResponse; import org.thingsboard.server.common.transport.auth.ValidateDeviceCredentialsResponse; -import org.thingsboard.server.common.transport.auth.ValidateDeviceProfileCredentialsResponse; import org.thingsboard.server.common.transport.service.SessionMetaData; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.ClaimDeviceMsg; @@ -53,12 +52,11 @@ import org.thingsboard.server.gen.transport.TransportProtos.ToDeviceRpcRequestMs import org.thingsboard.server.gen.transport.TransportProtos.ToDeviceRpcResponseMsg; import org.thingsboard.server.gen.transport.TransportProtos.ToServerRpcRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.TransportToDeviceActorMsg; -import org.thingsboard.server.gen.transport.TransportProtos.UpdateOrCreateDeviceX509CertRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.ValidateBasicMqttCredRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceLwM2MCredentialsRequestMsg; -import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceProfileX509CertRequestMsg; -import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceTokenRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceX509CertRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceTokenRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateOrCreateDeviceX509CertRequestMsg; import java.util.List; import java.util.concurrent.ExecutorService; @@ -90,10 +88,7 @@ public interface TransportService { void process(DeviceTransportType transportType, ValidateDeviceX509CertRequestMsg msg, TransportServiceCallback callback); - void process(DeviceTransportType transportType, ValidateDeviceProfileX509CertRequestMsg msg, - TransportServiceCallback callback); - - void process(DeviceTransportType transportType, UpdateOrCreateDeviceX509CertRequestMsg msg, + void process(DeviceTransportType transportType, ValidateOrCreateDeviceX509CertRequestMsg msg, TransportServiceCallback callback); void process(ValidateDeviceLwM2MCredentialsRequestMsg msg, diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java index a41c1d6055..4639ce0430 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java @@ -71,7 +71,6 @@ import org.thingsboard.server.common.transport.TransportTenantProfileCache; import org.thingsboard.server.common.transport.auth.GetOrCreateDeviceFromGatewayResponse; import org.thingsboard.server.common.transport.auth.TransportDeviceInfo; import org.thingsboard.server.common.transport.auth.ValidateDeviceCredentialsResponse; -import org.thingsboard.server.common.transport.auth.ValidateDeviceProfileCredentialsResponse; import org.thingsboard.server.common.transport.limits.TransportRateLimitService; import org.thingsboard.server.common.transport.util.JsonUtils; import org.thingsboard.server.gen.transport.TransportProtos; @@ -429,35 +428,16 @@ public class DefaultTransportService implements TransportService { } @Override - public void process(DeviceTransportType transportType, TransportProtos.ValidateDeviceProfileX509CertRequestMsg requestMsg, - TransportServiceCallback callback) { - log.trace("Processing msg: {}", requestMsg); - TbProtoQueueMsg protoMsg = new TbProtoQueueMsg<>(UUID.randomUUID(), - TransportApiRequestMsg.newBuilder().setValidateProfileX509CertRequestMsg(requestMsg).build()); - ListenableFuture response = Futures.transform(transportApiRequestTemplate.send(protoMsg), tmp -> { - TransportProtos.ValidateDeviceProfileCredentialsResponseMsg msg = tmp.getValue().getValidateDeviceProfileResponseMsg(); - ValidateDeviceProfileCredentialsResponse.ValidateDeviceProfileCredentialsResponseBuilder result = ValidateDeviceProfileCredentialsResponse.builder(); - DeviceProfileId deviceProfileId = new DeviceProfileId(new UUID(msg.getDeviceProfileIdMSB(), msg.getDeviceProfileIdLSB())); - result.deviceProfileId(deviceProfileId); - result.isDeviceProfileFound(msg.getIsDeviceProfileFound()); - return result.build(); - }, MoreExecutors.directExecutor()); - AsyncCallbackTemplate.withCallback(response, callback::onSuccess, callback::onError, transportCallbackExecutor); - } - - @Override - public void process(DeviceTransportType transportType, TransportProtos.UpdateOrCreateDeviceX509CertRequestMsg requestMsg, - TransportServiceCallback callback) { - log.trace("Processing msg: {}", requestMsg); - TbProtoQueueMsg protoMsg = new TbProtoQueueMsg<>(UUID.randomUUID(), TransportApiRequestMsg.newBuilder() - .setUpdateOrCreateDeviceCertRequestMsg(requestMsg).build()); + public void process(DeviceTransportType transportType, TransportProtos.ValidateDeviceX509CertRequestMsg msg, TransportServiceCallback callback) { + log.trace("Processing msg: {}", msg); + TbProtoQueueMsg protoMsg = new TbProtoQueueMsg<>(UUID.randomUUID(), TransportApiRequestMsg.newBuilder().setValidateX509CertRequestMsg(msg).build()); doProcess(transportType, protoMsg, callback); } @Override - public void process(DeviceTransportType transportType, TransportProtos.ValidateDeviceX509CertRequestMsg msg, TransportServiceCallback callback) { + public void process(DeviceTransportType transportType, TransportProtos.ValidateOrCreateDeviceX509CertRequestMsg msg, TransportServiceCallback callback) { log.trace("Processing msg: {}", msg); - TbProtoQueueMsg protoMsg = new TbProtoQueueMsg<>(UUID.randomUUID(), TransportApiRequestMsg.newBuilder().setValidateX509CertRequestMsg(msg).build()); + TbProtoQueueMsg protoMsg = new TbProtoQueueMsg<>(UUID.randomUUID(), TransportApiRequestMsg.newBuilder().setValidateOrCreateX509CertRequestMsg(msg).build()); doProcess(transportType, protoMsg, callback); } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java index 51ed2cd948..bc6f38f42c 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java @@ -27,6 +27,7 @@ import org.thingsboard.server.common.msg.EncryptionUtil; import java.security.cert.Certificate; import java.security.cert.CertificateEncodingException; import java.security.cert.X509Certificate; +import java.util.Arrays; /** * @author Valerii Sosliuk @@ -42,6 +43,17 @@ public class SslUtil { return EncryptionUtil.certTrimNewLines(Base64Utils.encodeToString(cert.getEncoded())); } + public static String getCertificateChainString(Certificate[] chain) + throws CertificateEncodingException { + String begin = "-----BEGIN CERTIFICATE-----"; + String end = "-----END CERTIFICATE-----"; + StringBuilder stringBuilder = new StringBuilder(); + for (Certificate cert: chain) { + stringBuilder.append(begin).append(EncryptionUtil.certTrimNewLinesWithoutBeginEnd(Base64Utils.encodeToString(cert.getEncoded()))).append(end).append("\n"); + } + return stringBuilder.toString(); + } + public static String parseCommonName(X509Certificate certificate) { X500Name x500name; try { diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsDao.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsDao.java index b6758af59c..10363ac5d7 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsDao.java @@ -53,12 +53,4 @@ public interface DeviceCredentialsDao extends Dao { */ DeviceCredentials findByCredentialsId(TenantId tenantId, String credentialsId); - /** - * Find device credentials by device name. - * - * @param deviceName the device name - * @return the device credentials object - */ - DeviceCredentials findByTenantIdAndDeviceName(TenantId tenantId, String deviceName); - } diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java index c21031fe65..138a8aaa9d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java @@ -83,13 +83,6 @@ public class DeviceCredentialsServiceImpl extends AbstractCachedEntityService impl @Column(name = ModelConstants.DEVICE_PROFILE_CERTIFICATE_REGEX_PATTERN_PROPERTY) private String certificateRegexPattern; + @Column(name = ModelConstants.ALLOW_CREATE_NEW_DEVICES_BY_X509_PROVISION) + private boolean allowCreateDevice; + public DeviceProfileEntity() { super(); } @@ -138,6 +141,7 @@ public final class DeviceProfileEntity extends BaseSqlEntity impl this.certificateHash = deviceProfile.getCertificateHash(); this.certificateValue = deviceProfile.getCertificateValue(); this.certificateRegexPattern = deviceProfile.getCertificateRegexPattern(); + this.allowCreateDevice = deviceProfile.isAllowCreateNewDevicesByX509Strategy(); this.description = deviceProfile.getDescription(); this.isDefault = deviceProfile.isDefault(); this.profileData = JacksonUtil.convertValue(deviceProfile.getProfileData(), ObjectNode.class); @@ -203,6 +207,7 @@ public final class DeviceProfileEntity extends BaseSqlEntity impl deviceProfile.setCertificateHash(certificateHash); deviceProfile.setCertificateValue(certificateValue); deviceProfile.setCertificateRegexPattern(certificateRegexPattern); + deviceProfile.setAllowCreateNewDevicesByX509Strategy(allowCreateDevice); if (firmwareId != null) { deviceProfile.setFirmwareId(new OtaPackageId(firmwareId)); diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java index 492ecf939a..0bf618e367 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java @@ -133,14 +133,14 @@ public class DeviceProfileDataValidator extends AbstractHasOtaPackageValidator Date: Tue, 24 Jan 2023 16:37:05 +0200 Subject: [PATCH 012/100] Fix CacheKey toString and validation logs --- .../transport/DefaultTransportApiService.java | 4 +-- common/cluster-api/src/main/proto/queue.proto | 1 + .../server/common/data/DeviceProfile.java | 8 ++--- .../mqtt/MqttSslHandlerProvider.java | 6 ++-- .../transport/mqtt/MqttTransportHandler.java | 2 +- .../dao/device/DeviceProfileCacheKey.java | 8 +++-- .../dao/device/DeviceProfileEvictEvent.java | 1 + .../dao/device/DeviceProfileServiceImpl.java | 31 ++++++++++++++----- .../validator/DeviceProfileDataValidator.java | 13 ++++---- 9 files changed, 46 insertions(+), 28 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java index 0bf0ff8035..24f6de76d7 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java @@ -258,8 +258,7 @@ public class DefaultTransportApiService implements TransportApiService { } DeviceProfile deviceProfile = deviceProfileService.findDeviceProfileByCertificateHash(certificateHash); if (deviceProfile != null) { - String deviceCN = extractDeviceNameFromCNByRegEx(SslUtil.parseCommonName(chain.get(0)), deviceProfile.getCertificateRegexPattern()); - String deviceName = extractDeviceNameFromCNByRegEx(deviceCN, deviceProfile.getCertificateRegexPattern()); + String deviceName = extractDeviceNameFromCNByRegEx(SslUtil.parseCommonName(chain.get(0)), deviceProfile.getCertificateRegexPattern()); Device device = deviceService.findDeviceByTenantIdAndName(deviceProfile.getTenantId(), deviceName); if (device != null) { DeviceCredentials deviceCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(device.getTenantId(), device.getId()); @@ -275,7 +274,6 @@ public class DefaultTransportApiService implements TransportApiService { deviceCredentials = updateDeviceCredentials(savedDevice.getTenantId(), deviceCredentials, updateDeviceCertificateValue, updateDeviceCertificateHash, credentialsType); return getDeviceInfo(deviceCredentials); } - } } } catch (CertificateEncodingException e) { diff --git a/common/cluster-api/src/main/proto/queue.proto b/common/cluster-api/src/main/proto/queue.proto index b9741429f7..9f62eca36b 100644 --- a/common/cluster-api/src/main/proto/queue.proto +++ b/common/cluster-api/src/main/proto/queue.proto @@ -422,6 +422,7 @@ message CredentialsDataProto { ValidateDeviceTokenRequestMsg validateDeviceTokenRequestMsg = 1; ValidateDeviceX509CertRequestMsg validateDeviceX509CertRequestMsg = 2; ValidateBasicMqttCredRequestMsg validateBasicMqttCredRequestMsg = 3; + ValidateOrCreateDeviceX509CertRequestMsg validateOrCreateDeviceX509CertRequestMsg = 4; } message ProvisionDeviceRequestMsg { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfile.java b/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfile.java index b9b1053d99..e226261227 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfile.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfile.java @@ -66,13 +66,13 @@ public class DeviceProfile extends SearchTextBased implements H private DeviceTransportType transportType; @ApiModelProperty(position = 15, value = "Provisioning strategy.") private DeviceProfileProvisionType provisionType; - @ApiModelProperty(position = 16, value = "CA certificate value. ") + @ApiModelProperty(position = 18, value = "CA certificate value. ") private String certificateValue; - @ApiModelProperty(position = 17, value = "CA certificate hash. ") + @ApiModelProperty(position = 19, value = "CA certificate hash. ") private String certificateHash; - @ApiModelProperty(position = 18, value = "Regex to fetch deviceName from CN. ") + @ApiModelProperty(position = 20, value = "Regex to fetch deviceName from CN. ") private String certificateRegexPattern; - @ApiModelProperty(position = 19, value = "Allow to create new devices by x509 provision strategy. ") + @ApiModelProperty(position = 21, value = "Allow to create new devices by x509 provision strategy. ") private boolean allowCreateNewDevicesByX509Strategy; diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java index 67b48464c8..3224556e09 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java @@ -17,7 +17,6 @@ package org.thingsboard.server.transport.mqtt; import io.netty.handler.ssl.SslHandler; import lombok.extern.slf4j.Slf4j; -import org.checkerframework.checker.units.qual.C; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; @@ -161,14 +160,13 @@ public class MqttSslHandlerProvider { @Override public void onError(Throwable e) { - // to fix this error, cuz no one can understand this ... - log.error(e.getMessage(), e); + log.trace("Failed to process certificate chain: {}", certificateChain, e); latch.countDown(); } }); latch.await(10, TimeUnit.SECONDS); if (!clientDeviceCertValue.equals(credentialsBodyHolder[0])) { - throw new CertificateException("Invalid Certificate's chain"); + throw new CertificateException("Invalid Certificate's chain. Cannot find such device credentials."); } } catch (CertificateEncodingException | InterruptedException e) { log.error(e.getMessage(), e); diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java index 813a6f1ed2..00d318544f 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java @@ -877,7 +877,7 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement private X509Certificate getX509Certificate() { try { Certificate[] certChain = sslHandler.engine().getSession().getPeerCertificates(); - if (certChain.length > 1) { + if (certChain.length > 0) { return (X509Certificate) certChain[0]; } } catch (SSLPeerUnverifiedException e) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileCacheKey.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileCacheKey.java index 228720f0e7..fb44092bd4 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileCacheKey.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileCacheKey.java @@ -56,14 +56,18 @@ public class DeviceProfileCacheKey implements Serializable { return new DeviceProfileCacheKey(tenantId, null, null, null, true); } + /** + * IMPORTANT: Method toString() has to return unique value, if you add additional field to this class, please also refactor toString(). + */ @Override public String toString() { if (deviceProfileId != null) { return deviceProfileId.toString(); } else if (defaultProfile) { return tenantId.toString(); - } else { - return tenantId + "_" + name; + } else if (certificateHash != null) { + return certificateHash; } + return tenantId + "_" + name; } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileEvictEvent.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileEvictEvent.java index b5b4b27a6b..acd34ff8e5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileEvictEvent.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileEvictEvent.java @@ -27,5 +27,6 @@ public class DeviceProfileEvictEvent { private final String oldName; private final DeviceProfileId deviceProfileId; private final boolean defaultProfile; + private final String certificateHash; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java index 8cd08739b3..ffa25d16d6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java @@ -98,6 +98,9 @@ public class DeviceProfileServiceImpl extends AbstractCachedEntityService 1; + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java index 0bf618e367..d737a48a9b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java @@ -138,7 +138,7 @@ public class DeviceProfileDataValidator extends AbstractHasOtaPackageValidator rootCa = new HashSet<>(); + boolean getRootCAFromJavaCacerts(String deviceProfileHash) { try { String filename = System.getProperty("java.home") + "/lib/security/cacerts".replace('/', File.separatorChar); FileInputStream is = new FileInputStream(filename); @@ -408,12 +407,14 @@ public class DeviceProfileDataValidator extends AbstractHasOtaPackageValidator Date: Tue, 31 Jan 2023 18:04:45 +0200 Subject: [PATCH 013/100] Add unit tests and update regex pattern to convert chain to list --- .../transport/DefaultTransportApiService.java | 85 +++++---- .../BaseDeviceProfileControllerTest.java | 19 ++ .../DefaultTransportApiServiceTest.java | 174 ++++++++++++++++++ .../mqtt/AbstractMqttIntegrationTest.java | 7 + .../mqtt/MqttTestConfigProperties.java | 5 + common/cluster-api/src/main/proto/queue.proto | 1 - .../server/common/msg/EncryptionUtil.java | 6 +- .../mqtt/MqttSslHandlerProvider.java | 3 +- .../server/common/transport/util/SslUtil.java | 3 +- .../dao/device/DeviceProfileServiceImpl.java | 27 ++- 10 files changed, 272 insertions(+), 58 deletions(-) create mode 100644 application/src/test/java/org/thingsboard/server/service/transport/DefaultTransportApiServiceTest.java diff --git a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java index 24f6de76d7..f434df2faa 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java @@ -107,11 +107,9 @@ import org.thingsboard.server.service.resource.TbResourceService; import java.io.ByteArrayInputStream; import java.io.InputStream; -import java.security.cert.CertificateEncodingException; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -243,41 +241,44 @@ public class DefaultTransportApiService implements TransportApiService { } } - private ListenableFuture validateOrCreateDeviceX509Certificate(String certChain, DeviceCredentialsType credentialsType) { + protected ListenableFuture validateOrCreateDeviceX509Certificate(String certChain, DeviceCredentialsType credentialsType) { + List chain = convertX509CertificateChainToList(certChain); + String updateDeviceCertificateValue = chain.get(0); + String updateDeviceCertificateHash = EncryptionUtil.getSha3Hash(updateDeviceCertificateValue); + String deviceCommonName = ""; try { - List chain = getX509CertificateChainFromString(certChain); - String updateDeviceCertificateValue = SslUtil.getCertificateString(chain.get(0)); - String updateDeviceCertificateHash = EncryptionUtil.getSha3Hash(updateDeviceCertificateValue); - - for (X509Certificate cert: chain) { - String certificateValue = SslUtil.getCertificateString(cert); - String certificateHash = EncryptionUtil.getSha3Hash(certificateValue); - DeviceCredentials credentials = deviceCredentialsService.findDeviceCredentialsByCredentialsId(certificateHash); - if (credentials != null && credentials.getCredentialsType() == credentialsType) { - return getDeviceInfo(credentials); + deviceCommonName = SslUtil.parseCommonName(readCertFile(chain.get(0))); + } catch (Exception ignored) { + } + for (String certificateValue : chain) { + String certificateHash = EncryptionUtil.getSha3Hash(certificateValue); + DeviceCredentials credentials = deviceCredentialsService.findDeviceCredentialsByCredentialsId(certificateHash); + if (credentials != null && credentials.getCredentialsType() == credentialsType) { + return getDeviceInfo(credentials); + } + DeviceProfile deviceProfile = deviceProfileService.findDeviceProfileByCertificateHash(certificateHash); + if (deviceProfile != null) { + String deviceName = extractDeviceNameFromCNByRegEx(deviceCommonName, deviceProfile.getCertificateRegexPattern()); + if (deviceName == null) { + log.error("Device name cannot be unmatched from CN!"); + return getEmptyTransportApiResponseFuture(); } - DeviceProfile deviceProfile = deviceProfileService.findDeviceProfileByCertificateHash(certificateHash); - if (deviceProfile != null) { - String deviceName = extractDeviceNameFromCNByRegEx(SslUtil.parseCommonName(chain.get(0)), deviceProfile.getCertificateRegexPattern()); - Device device = deviceService.findDeviceByTenantIdAndName(deviceProfile.getTenantId(), deviceName); - if (device != null) { - DeviceCredentials deviceCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(device.getTenantId(), device.getId()); - if (deviceCredentials != null && deviceCredentials.getCredentialsType() == credentialsType) { - deviceCredentials = updateDeviceCredentials(device.getTenantId(), deviceCredentials, updateDeviceCertificateValue, updateDeviceCertificateHash, credentialsType); - } else if (deviceCredentials == null) { - deviceCredentials = createDeviceCredentials(device.getTenantId(), device.getId(), updateDeviceCertificateValue, updateDeviceCertificateHash, credentialsType); - } - return getDeviceInfo(deviceCredentials); - } else if (deviceProfile.getProvisionType() == DeviceProfileProvisionType.ALLOW_CREATING_NEW_DEVICES_BY_X509_CERTIFICATE && deviceProfile.isAllowCreateNewDevicesByX509Strategy()) { - Device savedDevice = createDevice(deviceProfile.getTenantId(), deviceProfile.getId(), deviceName, deviceProfile.getName()); - DeviceCredentials deviceCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(savedDevice.getTenantId(), savedDevice.getId()); - deviceCredentials = updateDeviceCredentials(savedDevice.getTenantId(), deviceCredentials, updateDeviceCertificateValue, updateDeviceCertificateHash, credentialsType); - return getDeviceInfo(deviceCredentials); + Device device = deviceService.findDeviceByTenantIdAndName(deviceProfile.getTenantId(), deviceName); + if (device != null) { + DeviceCredentials deviceCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(device.getTenantId(), device.getId()); + if (deviceCredentials != null && deviceCredentials.getCredentialsType() == credentialsType) { + deviceCredentials = updateDeviceCredentials(device.getTenantId(), deviceCredentials, updateDeviceCertificateValue, updateDeviceCertificateHash, credentialsType); + } else if (deviceCredentials == null) { + deviceCredentials = createDeviceCredentials(device.getTenantId(), device.getId(), updateDeviceCertificateValue, updateDeviceCertificateHash, credentialsType); } + return getDeviceInfo(deviceCredentials); + } else if (deviceProfile.getProvisionType() == DeviceProfileProvisionType.ALLOW_CREATING_NEW_DEVICES_BY_X509_CERTIFICATE && deviceProfile.isAllowCreateNewDevicesByX509Strategy()) { + Device savedDevice = createDevice(deviceProfile.getTenantId(), deviceProfile.getId(), deviceName, deviceProfile.getName()); + DeviceCredentials deviceCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(savedDevice.getTenantId(), savedDevice.getId()); + deviceCredentials = updateDeviceCredentials(savedDevice.getTenantId(), deviceCredentials, updateDeviceCertificateValue, updateDeviceCertificateHash, credentialsType); + return getDeviceInfo(deviceCredentials); } } - } catch (CertificateEncodingException e) { - throw new RuntimeException(e); } return getEmptyTransportApiResponseFuture(); } @@ -727,19 +728,17 @@ public class DefaultTransportApiService implements TransportApiService { if (matcher.find()) { return matcher.group(0); } - return commonName; + return null; } - private List getX509CertificateChainFromString(String certificateChain) { - List chain = new ArrayList<>(); - String[] test = Arrays.stream(certificateChain.split("-----BEGIN CERTIFICATE-----")).filter(e -> e.trim().length() > 0).map(EncryptionUtil::certTrimNewLines).toArray(String[]::new); - Arrays.stream(test).forEach(s -> { - try { - chain.add(readCertFile(s)); - } catch (Exception e) { - throw new RuntimeException(e); - } - }); + private List convertX509CertificateChainToList(String certificateChain) { + List chain = new ArrayList<>(); + String regex = "-----BEGIN CERTIFICATE-----\\s*.*?\\s*-----END CERTIFICATE-----"; + Pattern pattern = Pattern.compile(regex); + Matcher matcher = pattern.matcher(certificateChain); + while (matcher.find()) { + chain.add(EncryptionUtil.certTrimNewLines(matcher.group())); + } return chain; } diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseDeviceProfileControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseDeviceProfileControllerTest.java index 3fe80b1112..43b8e51025 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseDeviceProfileControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseDeviceProfileControllerTest.java @@ -298,6 +298,25 @@ public abstract class BaseDeviceProfileControllerTest extends AbstractController tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); } + @Test + public void testSaveDeviceProfileWithSameCertificateHash() throws Exception { + DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile"); + deviceProfile.setCertificateHash("Certificate Hash"); + doPost("/api/deviceProfile", deviceProfile).andExpect(status().isOk()); + DeviceProfile deviceProfile2 = this.createDeviceProfile("Device Profile 2"); + deviceProfile2.setCertificateHash("Certificate Hash"); + + Mockito.reset(tbClusterService, auditLogService); + + String msgError = "Device profile with such certificate hash already exists"; + doPost("/api/deviceProfile", deviceProfile2) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityEqualsOneTimeServiceNeverError(deviceProfile, savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); + } + @Test public void testChangeDeviceProfileTypeNull() throws Exception { DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile"); diff --git a/application/src/test/java/org/thingsboard/server/service/transport/DefaultTransportApiServiceTest.java b/application/src/test/java/org/thingsboard/server/service/transport/DefaultTransportApiServiceTest.java new file mode 100644 index 0000000000..4a2ff07978 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/transport/DefaultTransportApiServiceTest.java @@ -0,0 +1,174 @@ +/** + * 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. + */ +package org.thingsboard.server.service.transport; + + +import com.google.common.util.concurrent.Futures; +import lombok.extern.slf4j.Slf4j; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringRunner; +import org.thingsboard.server.cache.ota.OtaPackageDataCache; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.DeviceProfileProvisionType; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentialsType; +import org.thingsboard.server.common.msg.EncryptionUtil; +import org.thingsboard.server.dao.device.DeviceCredentialsService; +import org.thingsboard.server.dao.device.DeviceProfileService; +import org.thingsboard.server.dao.device.DeviceProvisionService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.ota.OtaPackageService; +import org.thingsboard.server.dao.queue.QueueService; +import org.thingsboard.server.dao.relation.RelationService; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.queue.util.DataDecodingEncodingService; +import org.thingsboard.server.service.apiusage.TbApiUsageStateService; +import org.thingsboard.server.service.executors.DbCallbackExecutorService; +import org.thingsboard.server.service.profile.TbDeviceProfileCache; +import org.thingsboard.server.service.resource.TbResourceService; + +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@Slf4j +@RunWith(SpringRunner.class) +@ContextConfiguration(classes = DefaultTransportApiService.class) +public class DefaultTransportApiServiceTest { + + @MockBean + protected TbDeviceProfileCache deviceProfileCache; + @MockBean + protected TbTenantProfileCache tenantProfileCache; + @MockBean + protected TbApiUsageStateService apiUsageStateService; + @MockBean + protected DeviceService deviceService; + @MockBean + protected DeviceProfileService deviceProfileService; + @MockBean + protected RelationService relationService; + @MockBean + protected DeviceCredentialsService deviceCredentialsService; + @MockBean + protected DbCallbackExecutorService dbCallbackExecutorService; + @MockBean + protected TbClusterService tbClusterService; + @MockBean + protected DataDecodingEncodingService dataDecodingEncodingService; + @MockBean + protected DeviceProvisionService deviceProvisionService; + @MockBean + protected TbResourceService resourceService; + @MockBean + protected OtaPackageService otaPackageService; + @MockBean + protected OtaPackageDataCache otaPackageDataCache; + @MockBean + protected QueueService queueService; + @SpyBean + DefaultTransportApiService service; + + private final String deviceCertificate = "-----BEGIN CERTIFICATE-----Device certificate value-----END CERTIFICATE-----"; + private final String deviceProfileCertificate = "-----BEGIN CERTIFICATE-----Device profile certificate value-----END CERTIFICATE-----"; + + @Test + public void validateExistingDeviceX509Certificate() { + var device = createDevice(); + when(deviceService.findDeviceByIdAsync(any(), any())).thenReturn(Futures.immediateFuture(device)); + + var deviceCredentials = createDeviceCredentials(deviceCertificate, device.getId()); + when(deviceCredentialsService.findDeviceCredentialsByCredentialsId(any())).thenReturn(deviceCredentials); + + service.validateOrCreateDeviceX509Certificate(deviceCertificate, DeviceCredentialsType.X509_CERTIFICATE); + verify(deviceCredentialsService, times(1)).findDeviceCredentialsByCredentialsId(any()); + } + + @Test + public void updateExistingDeviceX509Certificate() { + var deviceProfile = createDeviceProfile(deviceProfileCertificate); + when(deviceProfileService.findDeviceProfileByCertificateHash(any())).thenReturn(deviceProfile); + + var device = createDevice(); + when(deviceService.findDeviceByTenantIdAndName(any(), any())).thenReturn(device); + when(deviceService.findDeviceByIdAsync(any(), any())).thenReturn(Futures.immediateFuture(device)); + + var deviceCredentials = createDeviceCredentials(deviceCertificate, device.getId()); + when(deviceCredentialsService.findDeviceCredentialsByDeviceId(any(), any())).thenReturn(deviceCredentials); + when(deviceCredentialsService.updateDeviceCredentials(any(), any())).thenReturn(deviceCredentials); + + service.validateOrCreateDeviceX509Certificate(deviceProfileCertificate, DeviceCredentialsType.X509_CERTIFICATE); + verify(deviceProfileService, times(1)).findDeviceProfileByCertificateHash(any()); + verify(deviceService, times(1)).findDeviceByTenantIdAndName(any(), any()); + verify(deviceCredentialsService, times(1)).findDeviceCredentialsByDeviceId(any(), any()); + verify(deviceCredentialsService, times(1)).updateDeviceCredentials(any(), any()); + } + + @Test + public void createDeviceByX509Provision() { + var deviceProfile = createDeviceProfile(deviceProfileCertificate); + when(deviceProfileService.findDeviceProfileByCertificateHash(any())).thenReturn(deviceProfile); + + var device = createDevice(); + when(deviceService.saveDevice(any())).thenReturn(device); + when(deviceService.findDeviceByIdAsync(any(), any())).thenReturn(Futures.immediateFuture(device)); + + var deviceCredentials = createDeviceCredentials(deviceCertificate, device.getId()); + when(deviceCredentialsService.findDeviceCredentialsByDeviceId(any(), any())).thenReturn(deviceCredentials); + when(deviceCredentialsService.updateDeviceCredentials(any(), any())).thenReturn(deviceCredentials); + + service.validateOrCreateDeviceX509Certificate(deviceProfileCertificate, DeviceCredentialsType.X509_CERTIFICATE); + verify(deviceProfileService, times(1)).findDeviceProfileByCertificateHash(any()); + verify(deviceService, times(1)).findDeviceByTenantIdAndName(any(), any()); + verify(deviceCredentialsService, times(1)).findDeviceCredentialsByDeviceId(any(), any()); + verify(deviceCredentialsService, times(1)).updateDeviceCredentials(any(), any()); + } + + private DeviceCredentials createDeviceCredentials(String certificateValue, DeviceId deviceId) { + DeviceCredentials deviceCredentials = new DeviceCredentials(); + deviceCredentials.setDeviceId(deviceId); + deviceCredentials.setCredentialsValue(certificateValue); + deviceCredentials.setCredentialsId(EncryptionUtil.getSha3Hash(certificateValue)); + deviceCredentials.setCredentialsType(DeviceCredentialsType.X509_CERTIFICATE); + return deviceCredentials; + } + + private DeviceProfile createDeviceProfile(String certificateValue) { + DeviceProfile deviceProfile = new DeviceProfile(); + deviceProfile.setCertificateValue(certificateValue); + deviceProfile.setCertificateHash(EncryptionUtil.getSha3Hash(certificateValue)); + deviceProfile.setCertificateRegexPattern("^$"); + deviceProfile.setAllowCreateNewDevicesByX509Strategy(true); + deviceProfile.setProvisionType(DeviceProfileProvisionType.ALLOW_CREATING_NEW_DEVICES_BY_X509_CERTIFICATE); + return deviceProfile; + } + + private Device createDevice() { + Device device = new Device(); + device.setId(new DeviceId(UUID.randomUUID())); + return device; + } +} diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/AbstractMqttIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/AbstractMqttIntegrationTest.java index 917e41d1b4..69b1f87502 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/AbstractMqttIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/AbstractMqttIntegrationTest.java @@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.TransportPayloadType; import org.thingsboard.server.common.data.device.profile.AllowCreateNewDevicesDeviceProfileProvisionConfiguration; +import org.thingsboard.server.common.data.device.profile.AllowCreatingNewDevicesByX509CertificateProvisionConfiguration; import org.thingsboard.server.common.data.device.profile.CheckPreProvisionedDevicesDeviceProfileProvisionConfiguration; import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileConfiguration; import org.thingsboard.server.common.data.device.profile.DeviceProfileData; @@ -94,6 +95,9 @@ public abstract class AbstractMqttIntegrationTest extends AbstractTransportInteg deviceProfile.setProvisionType(provisionType); deviceProfile.setProvisionDeviceKey(config.getProvisionKey()); deviceProfile.setDescription(transportPayloadType.name() + " Test"); + deviceProfile.setAllowCreateNewDevicesByX509Strategy(config.allowCreatingNewDeviceByX509Strategy); + deviceProfile.setCertificateValue("Device Profile certificate value"); + deviceProfile.setCertificateRegexPattern(config.getRegEx()); DeviceProfileData deviceProfileData = new DeviceProfileData(); DefaultDeviceProfileConfiguration configuration = new DefaultDeviceProfileConfiguration(); MqttDeviceProfileTransportConfiguration mqttDeviceProfileTransportConfiguration = new MqttDeviceProfileTransportConfiguration(); @@ -144,6 +148,9 @@ public abstract class AbstractMqttIntegrationTest extends AbstractTransportInteg case CHECK_PRE_PROVISIONED_DEVICES: provisionConfiguration = new CheckPreProvisionedDevicesDeviceProfileProvisionConfiguration(config.getProvisionSecret()); break; + case ALLOW_CREATING_NEW_DEVICES_BY_X509_CERTIFICATE: + provisionConfiguration = new AllowCreatingNewDevicesByX509CertificateProvisionConfiguration(config.getProvisionSecret()); + break; case DISABLED: default: provisionConfiguration = new DisabledDeviceProfileProvisionConfiguration(config.getProvisionSecret()); diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/MqttTestConfigProperties.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/MqttTestConfigProperties.java index bc535b424b..4c57f413c9 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/MqttTestConfigProperties.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/MqttTestConfigProperties.java @@ -40,9 +40,14 @@ public class MqttTestConfigProperties { boolean enableCompatibilityWithJsonPayloadFormat; boolean useJsonPayloadFormatForDefaultDownlinkTopics; boolean sendAckOnValidationException; + boolean allowCreatingNewDeviceByX509Strategy; DeviceProfileProvisionType provisionType; String provisionKey; String provisionSecret; + String x509DeviceCertificate; + String x509DeviceProfileCertificate; + String commonName; + String regEx; } diff --git a/common/cluster-api/src/main/proto/queue.proto b/common/cluster-api/src/main/proto/queue.proto index 9f62eca36b..b9741429f7 100644 --- a/common/cluster-api/src/main/proto/queue.proto +++ b/common/cluster-api/src/main/proto/queue.proto @@ -422,7 +422,6 @@ message CredentialsDataProto { ValidateDeviceTokenRequestMsg validateDeviceTokenRequestMsg = 1; ValidateDeviceX509CertRequestMsg validateDeviceX509CertRequestMsg = 2; ValidateBasicMqttCredRequestMsg validateBasicMqttCredRequestMsg = 3; - ValidateOrCreateDeviceX509CertRequestMsg validateOrCreateDeviceX509CertRequestMsg = 4; } message ProvisionDeviceRequestMsg { diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java b/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java index 6bccac1591..4cdd61eeec 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java @@ -35,9 +35,11 @@ public class EncryptionUtil { .replaceAll("-----END CERTIFICATE-----", ""); } - public static String certTrimNewLinesWithoutBeginEnd(String input) { + public static String certTrimNewLinesForChainInDeviceProfile(String input) { return input.replaceAll("\n", "") - .replaceAll("\r", ""); + .replaceAll("\r", "") + .replaceAll("-----BEGIN CERTIFICATE-----", "-----BEGIN CERTIFICATE-----\n") + .replaceAll("-----END CERTIFICATE-----", "\n-----END CERTIFICATE-----\n"); } public static String pubkTrimNewLines(String input) { diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java index 3224556e09..d73743e6b5 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java @@ -41,7 +41,6 @@ import javax.net.ssl.SSLEngine; import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509TrustManager; -import java.security.cert.CertificateEncodingException; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.util.concurrent.CountDownLatch; @@ -168,7 +167,7 @@ public class MqttSslHandlerProvider { if (!clientDeviceCertValue.equals(credentialsBodyHolder[0])) { throw new CertificateException("Invalid Certificate's chain. Cannot find such device credentials."); } - } catch (CertificateEncodingException | InterruptedException e) { + } catch (Exception e) { log.error(e.getMessage(), e); } } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java index bc6f38f42c..4245cae349 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java @@ -27,7 +27,6 @@ import org.thingsboard.server.common.msg.EncryptionUtil; import java.security.cert.Certificate; import java.security.cert.CertificateEncodingException; import java.security.cert.X509Certificate; -import java.util.Arrays; /** * @author Valerii Sosliuk @@ -49,7 +48,7 @@ public class SslUtil { String end = "-----END CERTIFICATE-----"; StringBuilder stringBuilder = new StringBuilder(); for (Certificate cert: chain) { - stringBuilder.append(begin).append(EncryptionUtil.certTrimNewLinesWithoutBeginEnd(Base64Utils.encodeToString(cert.getEncoded()))).append(end).append("\n"); + stringBuilder.append(begin).append(EncryptionUtil.certTrimNewLines(Base64Utils.encodeToString(cert.getEncoded()))).append(end).append("\n"); } return stringBuilder.toString(); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java index ffa25d16d6..48e6a5b6b6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java @@ -48,6 +48,10 @@ import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.service.PaginatedRemover; import org.thingsboard.server.dao.service.Validator; +import java.io.ByteArrayInputStream; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -342,17 +346,15 @@ public class DeviceProfileServiceImpl extends AbstractCachedEntityService 1; + private String formatCertificateValue(String certificateValue) { + try { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + ByteArrayInputStream inputStream = new ByteArrayInputStream(certificateValue.getBytes()); + Certificate[] certificates = cf.generateCertificates(inputStream).toArray(new Certificate[0]); + if (certificates.length > 1) { + return EncryptionUtil.certTrimNewLinesForChainInDeviceProfile(certificateValue); + } + return EncryptionUtil.certTrimNewLines(certificateValue); + } catch (CertificateException e) { + throw new RuntimeException(e); + } } } From 0943b80d8e4f074950648b3e0047462e037699f8 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 1 Feb 2023 16:49:00 +0200 Subject: [PATCH 014/100] Fix schema and micro refactoring --- .../main/data/upgrade/3.4.3/schema_update.sql | 3 --- .../transport/DefaultTransportApiService.java | 11 ++++++--- .../DefaultTransportApiServiceTest.java | 15 ++++++++---- .../mqtt/AbstractMqttIntegrationTest.java | 7 ------ .../mqtt/MqttTestConfigProperties.java | 5 ---- common/cluster-api/src/main/proto/queue.proto | 2 +- .../server/common/data/DeviceProfile.java | 11 +-------- .../data/DeviceProfileProvisionType.java | 2 +- .../DeviceProfileProvisionConfiguration.java | 2 +- ...rtificateChainProvisionConfiguration.java} | 11 ++++++--- .../mqtt/MqttSslHandlerProvider.java | 2 +- .../dao/device/DeviceProfileServiceImpl.java | 23 ++++++++++++------- .../dao/model/sql/DeviceProfileEntity.java | 15 ------------ .../main/resources/sql/schema-entities.sql | 3 --- 14 files changed, 47 insertions(+), 65 deletions(-) rename common/data/src/main/java/org/thingsboard/server/common/data/device/profile/{AllowCreatingNewDevicesByX509CertificateProvisionConfiguration.java => X509CertificateChainProvisionConfiguration.java} (67%) diff --git a/application/src/main/data/upgrade/3.4.3/schema_update.sql b/application/src/main/data/upgrade/3.4.3/schema_update.sql index 78203951ec..2aaa574bb4 100644 --- a/application/src/main/data/upgrade/3.4.3/schema_update.sql +++ b/application/src/main/data/upgrade/3.4.3/schema_update.sql @@ -31,9 +31,6 @@ CREATE INDEX IF NOT EXISTS idx_alarm_comment_alarm_id ON alarm_comment(alarm_id) ALTER TABLE device_profile ADD COLUMN IF NOT EXISTS certificate_hash varchar, - ADD COLUMN IF NOT EXISTS certificate_value varchar, - ADD COLUMN IF NOT EXISTS certificate_regex_pattern varchar(255), - ADD COLUMN IF NOT EXISTS allow_create_device_by_x509 boolean, DROP CONSTRAINT IF EXISTS device_profile_credentials_hash_unq_key, ADD CONSTRAINT device_profile_credentials_hash_unq_key UNIQUE (certificate_hash); diff --git a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java index f434df2faa..6a3780d529 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java @@ -50,6 +50,7 @@ import org.thingsboard.server.common.data.device.data.Lwm2mDeviceTransportConfig import org.thingsboard.server.common.data.device.data.PowerMode; import org.thingsboard.server.common.data.device.data.PowerSavingConfiguration; import org.thingsboard.server.common.data.device.profile.ProvisionDeviceProfileCredentials; +import org.thingsboard.server.common.data.device.profile.X509CertificateChainProvisionConfiguration; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceProfileId; @@ -173,7 +174,7 @@ public class DefaultTransportApiService implements TransportApiService { result = validateCredentials(msg.getHash(), DeviceCredentialsType.X509_CERTIFICATE); } else if (transportApiRequestMsg.hasValidateOrCreateX509CertRequestMsg()) { TransportProtos.ValidateOrCreateDeviceX509CertRequestMsg msg = transportApiRequestMsg.getValidateOrCreateX509CertRequestMsg(); - result = validateOrCreateDeviceX509Certificate(msg.getCertificate(), DeviceCredentialsType.X509_CERTIFICATE); + result = validateOrCreateDeviceX509Certificate(msg.getCertificateChain(), DeviceCredentialsType.X509_CERTIFICATE); } else if (transportApiRequestMsg.hasGetOrCreateDeviceRequestMsg()) { result = handle(transportApiRequestMsg.getGetOrCreateDeviceRequestMsg()); } else if (transportApiRequestMsg.hasEntityProfileRequestMsg()) { @@ -258,7 +259,11 @@ public class DefaultTransportApiService implements TransportApiService { } DeviceProfile deviceProfile = deviceProfileService.findDeviceProfileByCertificateHash(certificateHash); if (deviceProfile != null) { - String deviceName = extractDeviceNameFromCNByRegEx(deviceCommonName, deviceProfile.getCertificateRegexPattern()); + X509CertificateChainProvisionConfiguration x509Configuration = new X509CertificateChainProvisionConfiguration(); + if (deviceProfile.getProfileData().getProvisionConfiguration() instanceof X509CertificateChainProvisionConfiguration) { + x509Configuration = (X509CertificateChainProvisionConfiguration) deviceProfile.getProfileData().getProvisionConfiguration(); + } + String deviceName = extractDeviceNameFromCNByRegEx(deviceCommonName, x509Configuration.getCertificateRegExPattern()); if (deviceName == null) { log.error("Device name cannot be unmatched from CN!"); return getEmptyTransportApiResponseFuture(); @@ -272,7 +277,7 @@ public class DefaultTransportApiService implements TransportApiService { deviceCredentials = createDeviceCredentials(device.getTenantId(), device.getId(), updateDeviceCertificateValue, updateDeviceCertificateHash, credentialsType); } return getDeviceInfo(deviceCredentials); - } else if (deviceProfile.getProvisionType() == DeviceProfileProvisionType.ALLOW_CREATING_NEW_DEVICES_BY_X509_CERTIFICATE && deviceProfile.isAllowCreateNewDevicesByX509Strategy()) { + } else if (deviceProfile.getProvisionType() == DeviceProfileProvisionType.X509_CERTIFICATE_CHAIN && x509Configuration.isAllowCreateNewDevicesByX509Certificate()) { Device savedDevice = createDevice(deviceProfile.getTenantId(), deviceProfile.getId(), deviceName, deviceProfile.getName()); DeviceCredentials deviceCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(savedDevice.getTenantId(), savedDevice.getId()); deviceCredentials = updateDeviceCredentials(savedDevice.getTenantId(), deviceCredentials, updateDeviceCertificateValue, updateDeviceCertificateHash, credentialsType); diff --git a/application/src/test/java/org/thingsboard/server/service/transport/DefaultTransportApiServiceTest.java b/application/src/test/java/org/thingsboard/server/service/transport/DefaultTransportApiServiceTest.java index 4a2ff07978..71a2f8b70c 100644 --- a/application/src/test/java/org/thingsboard/server/service/transport/DefaultTransportApiServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/transport/DefaultTransportApiServiceTest.java @@ -29,6 +29,9 @@ import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.DeviceProfileProvisionType; +import org.thingsboard.server.common.data.device.profile.X509CertificateChainProvisionConfiguration; +import org.thingsboard.server.common.data.device.profile.DeviceProfileData; +import org.thingsboard.server.common.data.device.profile.DeviceProfileProvisionConfiguration; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.common.data.security.DeviceCredentialsType; @@ -158,11 +161,15 @@ public class DefaultTransportApiServiceTest { private DeviceProfile createDeviceProfile(String certificateValue) { DeviceProfile deviceProfile = new DeviceProfile(); - deviceProfile.setCertificateValue(certificateValue); + DeviceProfileData deviceProfileData = new DeviceProfileData(); + X509CertificateChainProvisionConfiguration provision = new X509CertificateChainProvisionConfiguration(); + provision.setCertificateValue(certificateValue); + provision.setCertificateRegExPattern("^$"); + provision.setAllowCreateNewDevicesByX509Certificate(true); + deviceProfileData.setProvisionConfiguration(provision); + deviceProfile.setProfileData(deviceProfileData); deviceProfile.setCertificateHash(EncryptionUtil.getSha3Hash(certificateValue)); - deviceProfile.setCertificateRegexPattern("^$"); - deviceProfile.setAllowCreateNewDevicesByX509Strategy(true); - deviceProfile.setProvisionType(DeviceProfileProvisionType.ALLOW_CREATING_NEW_DEVICES_BY_X509_CERTIFICATE); + deviceProfile.setProvisionType(DeviceProfileProvisionType.X509_CERTIFICATE_CHAIN); return deviceProfile; } diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/AbstractMqttIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/AbstractMqttIntegrationTest.java index 69b1f87502..917e41d1b4 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/AbstractMqttIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/AbstractMqttIntegrationTest.java @@ -27,7 +27,6 @@ import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.TransportPayloadType; import org.thingsboard.server.common.data.device.profile.AllowCreateNewDevicesDeviceProfileProvisionConfiguration; -import org.thingsboard.server.common.data.device.profile.AllowCreatingNewDevicesByX509CertificateProvisionConfiguration; import org.thingsboard.server.common.data.device.profile.CheckPreProvisionedDevicesDeviceProfileProvisionConfiguration; import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileConfiguration; import org.thingsboard.server.common.data.device.profile.DeviceProfileData; @@ -95,9 +94,6 @@ public abstract class AbstractMqttIntegrationTest extends AbstractTransportInteg deviceProfile.setProvisionType(provisionType); deviceProfile.setProvisionDeviceKey(config.getProvisionKey()); deviceProfile.setDescription(transportPayloadType.name() + " Test"); - deviceProfile.setAllowCreateNewDevicesByX509Strategy(config.allowCreatingNewDeviceByX509Strategy); - deviceProfile.setCertificateValue("Device Profile certificate value"); - deviceProfile.setCertificateRegexPattern(config.getRegEx()); DeviceProfileData deviceProfileData = new DeviceProfileData(); DefaultDeviceProfileConfiguration configuration = new DefaultDeviceProfileConfiguration(); MqttDeviceProfileTransportConfiguration mqttDeviceProfileTransportConfiguration = new MqttDeviceProfileTransportConfiguration(); @@ -148,9 +144,6 @@ public abstract class AbstractMqttIntegrationTest extends AbstractTransportInteg case CHECK_PRE_PROVISIONED_DEVICES: provisionConfiguration = new CheckPreProvisionedDevicesDeviceProfileProvisionConfiguration(config.getProvisionSecret()); break; - case ALLOW_CREATING_NEW_DEVICES_BY_X509_CERTIFICATE: - provisionConfiguration = new AllowCreatingNewDevicesByX509CertificateProvisionConfiguration(config.getProvisionSecret()); - break; case DISABLED: default: provisionConfiguration = new DisabledDeviceProfileProvisionConfiguration(config.getProvisionSecret()); diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/MqttTestConfigProperties.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/MqttTestConfigProperties.java index 4c57f413c9..bc535b424b 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/MqttTestConfigProperties.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/MqttTestConfigProperties.java @@ -40,14 +40,9 @@ public class MqttTestConfigProperties { boolean enableCompatibilityWithJsonPayloadFormat; boolean useJsonPayloadFormatForDefaultDownlinkTopics; boolean sendAckOnValidationException; - boolean allowCreatingNewDeviceByX509Strategy; DeviceProfileProvisionType provisionType; String provisionKey; String provisionSecret; - String x509DeviceCertificate; - String x509DeviceProfileCertificate; - String commonName; - String regEx; } diff --git a/common/cluster-api/src/main/proto/queue.proto b/common/cluster-api/src/main/proto/queue.proto index b9741429f7..6e9ec38be8 100644 --- a/common/cluster-api/src/main/proto/queue.proto +++ b/common/cluster-api/src/main/proto/queue.proto @@ -170,7 +170,7 @@ message ValidateDeviceX509CertRequestMsg { } message ValidateOrCreateDeviceX509CertRequestMsg { - string certificate = 1; + string certificateChain = 1; } message ValidateBasicMqttCredRequestMsg { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfile.java b/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfile.java index e226261227..05f8ae19ba 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfile.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfile.java @@ -66,14 +66,8 @@ public class DeviceProfile extends SearchTextBased implements H private DeviceTransportType transportType; @ApiModelProperty(position = 15, value = "Provisioning strategy.") private DeviceProfileProvisionType provisionType; - @ApiModelProperty(position = 18, value = "CA certificate value. ") - private String certificateValue; - @ApiModelProperty(position = 19, value = "CA certificate hash. ") + @ApiModelProperty(position = 18, value = "CA certificate hash. ") private String certificateHash; - @ApiModelProperty(position = 20, value = "Regex to fetch deviceName from CN. ") - private String certificateRegexPattern; - @ApiModelProperty(position = 21, value = "Allow to create new devices by x509 provision strategy. ") - private boolean allowCreateNewDevicesByX509Strategy; @ApiModelProperty(position = 7, value = "Reference to the rule chain. " + @@ -131,9 +125,6 @@ public class DeviceProfile extends SearchTextBased implements H this.firmwareId = deviceProfile.getFirmwareId(); this.softwareId = deviceProfile.getSoftwareId(); this.defaultEdgeRuleChainId = deviceProfile.getDefaultEdgeRuleChainId(); - this.allowCreateNewDevicesByX509Strategy = deviceProfile.isAllowCreateNewDevicesByX509Strategy(); - this.certificateRegexPattern = deviceProfile.getCertificateRegexPattern(); - this.certificateValue = deviceProfile.getCertificateValue(); this.certificateHash = deviceProfile.getCertificateHash(); this.externalId = deviceProfile.getExternalId(); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfileProvisionType.java b/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfileProvisionType.java index 7ee03d7227..f2daf71cca 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfileProvisionType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfileProvisionType.java @@ -19,5 +19,5 @@ public enum DeviceProfileProvisionType { DISABLED, ALLOW_CREATE_NEW_DEVICES, CHECK_PRE_PROVISIONED_DEVICES, - ALLOW_CREATING_NEW_DEVICES_BY_X509_CERTIFICATE + X509_CERTIFICATE_CHAIN } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileProvisionConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileProvisionConfiguration.java index 7a70c00b98..048e10739f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileProvisionConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileProvisionConfiguration.java @@ -32,7 +32,7 @@ import java.io.Serializable; @JsonSubTypes.Type(value = DisabledDeviceProfileProvisionConfiguration.class, name = "DISABLED"), @JsonSubTypes.Type(value = AllowCreateNewDevicesDeviceProfileProvisionConfiguration.class, name = "ALLOW_CREATE_NEW_DEVICES"), @JsonSubTypes.Type(value = CheckPreProvisionedDevicesDeviceProfileProvisionConfiguration.class, name = "CHECK_PRE_PROVISIONED_DEVICES"), - @JsonSubTypes.Type(value = AllowCreatingNewDevicesByX509CertificateProvisionConfiguration.class, name = "ALLOW_CREATING_NEW_DEVICES_BY_X509_CERTIFICATE")}) + @JsonSubTypes.Type(value = X509CertificateChainProvisionConfiguration.class, name = "X509_CERTIFICATE_CHAIN")}) public interface DeviceProfileProvisionConfiguration extends Serializable { String getProvisionDeviceSecret(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AllowCreatingNewDevicesByX509CertificateProvisionConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/X509CertificateChainProvisionConfiguration.java similarity index 67% rename from common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AllowCreatingNewDevicesByX509CertificateProvisionConfiguration.java rename to common/data/src/main/java/org/thingsboard/server/common/data/device/profile/X509CertificateChainProvisionConfiguration.java index e84d09829f..0b500f3e52 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AllowCreatingNewDevicesByX509CertificateProvisionConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/X509CertificateChainProvisionConfiguration.java @@ -17,16 +17,21 @@ package org.thingsboard.server.common.data.device.profile; import lombok.Data; +import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.DeviceProfileProvisionType; @Data -public class AllowCreatingNewDevicesByX509CertificateProvisionConfiguration implements DeviceProfileProvisionConfiguration { +@NoArgsConstructor +public class X509CertificateChainProvisionConfiguration implements DeviceProfileProvisionConfiguration { - private final String provisionDeviceSecret; + private String provisionDeviceSecret; + private String certificateValue; + private String certificateRegExPattern; + private boolean allowCreateNewDevicesByX509Certificate; @Override public DeviceProfileProvisionType getType() { - return DeviceProfileProvisionType.ALLOW_CREATING_NEW_DEVICES_BY_X509_CERTIFICATE; + return DeviceProfileProvisionType.X509_CERTIFICATE_CHAIN; } } diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java index d73743e6b5..d5ff77837b 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java @@ -147,7 +147,7 @@ public class MqttSslHandlerProvider { try { String certificateChain = SslUtil.getCertificateChainString(chain); transportService.process(DeviceTransportType.MQTT, TransportProtos.ValidateOrCreateDeviceX509CertRequestMsg - .newBuilder().setCertificate(certificateChain).build(), + .newBuilder().setCertificateChain(certificateChain).build(), new TransportServiceCallback<>() { @Override public void onSuccess(ValidateDeviceCredentialsResponse msg) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java index 48e6a5b6b6..f558eb2c7e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java @@ -34,6 +34,7 @@ import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileCon import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileTransportConfiguration; import org.thingsboard.server.common.data.device.profile.DeviceProfileData; import org.thingsboard.server.common.data.device.profile.DisabledDeviceProfileProvisionConfiguration; +import org.thingsboard.server.common.data.device.profile.X509CertificateChainProvisionConfiguration; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.HasId; @@ -134,8 +135,12 @@ public class DeviceProfileServiceImpl extends AbstractCachedEntityService impl @Column(name = ModelConstants.EXTERNAL_ID_PROPERTY) private UUID externalId; - @Column(name = ModelConstants.DEVICE_PROFILE_CERTIFICATE_VALUE_PROPERTY) - private String certificateValue; - @Column(name = ModelConstants.DEVICE_PROFILE_CERTIFICATE_HASH_PROPERTY) private String certificateHash; - @Column(name = ModelConstants.DEVICE_PROFILE_CERTIFICATE_REGEX_PATTERN_PROPERTY) - private String certificateRegexPattern; - - @Column(name = ModelConstants.ALLOW_CREATE_NEW_DEVICES_BY_X509_PROVISION) - private boolean allowCreateDevice; - public DeviceProfileEntity() { super(); } @@ -139,9 +130,6 @@ public final class DeviceProfileEntity extends BaseSqlEntity impl this.transportType = deviceProfile.getTransportType(); this.provisionType = deviceProfile.getProvisionType(); this.certificateHash = deviceProfile.getCertificateHash(); - this.certificateValue = deviceProfile.getCertificateValue(); - this.certificateRegexPattern = deviceProfile.getCertificateRegexPattern(); - this.allowCreateDevice = deviceProfile.isAllowCreateNewDevicesByX509Strategy(); this.description = deviceProfile.getDescription(); this.isDefault = deviceProfile.isDefault(); this.profileData = JacksonUtil.convertValue(deviceProfile.getProfileData(), ObjectNode.class); @@ -205,9 +193,6 @@ public final class DeviceProfileEntity extends BaseSqlEntity impl } deviceProfile.setProvisionDeviceKey(provisionDeviceKey); deviceProfile.setCertificateHash(certificateHash); - deviceProfile.setCertificateValue(certificateValue); - deviceProfile.setCertificateRegexPattern(certificateRegexPattern); - deviceProfile.setAllowCreateNewDevicesByX509Strategy(allowCreateDevice); if (firmwareId != null) { deviceProfile.setFirmwareId(new OtaPackageId(firmwareId)); diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index eed6f237b6..bd61d9a7c3 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -292,12 +292,9 @@ CREATE TABLE IF NOT EXISTS device_profile ( default_dashboard_id uuid, default_queue_name varchar(255), provision_device_key varchar, - certificate_value varchar, certificate_hash varchar, - certificate_regex_pattern varchar(255), default_edge_rule_chain_id uuid, external_id uuid, - allow_create_device_by_x509 boolean, CONSTRAINT device_profile_credentials_hash_unq_key UNIQUE (certificate_hash), CONSTRAINT device_profile_name_unq_key UNIQUE (tenant_id, name), CONSTRAINT device_provision_key_unq_key UNIQUE (provision_device_key), From 058c97e4de6e6af470a4e5d7dae661253f6bb4e7 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Thu, 2 Feb 2023 10:20:24 +0200 Subject: [PATCH 015/100] Fix headers and remove extra constants from Model Constants --- .../service/transport/DefaultTransportApiServiceTest.java | 5 ++--- .../profile/X509CertificateChainProvisionConfiguration.java | 2 +- .../server/dao/device/DeviceProfileServiceImpl.java | 3 +-- .../org/thingsboard/server/dao/model/ModelConstants.java | 3 --- 4 files changed, 4 insertions(+), 9 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/service/transport/DefaultTransportApiServiceTest.java b/application/src/test/java/org/thingsboard/server/service/transport/DefaultTransportApiServiceTest.java index 71a2f8b70c..0309bec131 100644 --- a/application/src/test/java/org/thingsboard/server/service/transport/DefaultTransportApiServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/transport/DefaultTransportApiServiceTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2022 The Thingsboard Authors + * Copyright © 2016-2023 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. @@ -29,9 +29,8 @@ import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.DeviceProfileProvisionType; -import org.thingsboard.server.common.data.device.profile.X509CertificateChainProvisionConfiguration; import org.thingsboard.server.common.data.device.profile.DeviceProfileData; -import org.thingsboard.server.common.data.device.profile.DeviceProfileProvisionConfiguration; +import org.thingsboard.server.common.data.device.profile.X509CertificateChainProvisionConfiguration; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.common.data.security.DeviceCredentialsType; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/X509CertificateChainProvisionConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/X509CertificateChainProvisionConfiguration.java index 0b500f3e52..9257053b4f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/X509CertificateChainProvisionConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/X509CertificateChainProvisionConfiguration.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2022 The Thingsboard Authors + * Copyright © 2016-2023 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. diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java index 9d5c0608d3..767fd805cf 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java @@ -379,9 +379,8 @@ public class DeviceProfileServiceImpl extends AbstractCachedEntityService 1) { return EncryptionUtil.certTrimNewLinesForChainInDeviceProfile(certificateValue); } - return EncryptionUtil.certTrimNewLines(certificateValue); } catch (CertificateException ignored) {} - return certificateValue; + return EncryptionUtil.certTrimNewLines(certificateValue); } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index 76a5423359..7b0ca50d0d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -186,10 +186,7 @@ public class ModelConstants { public static final String DEVICE_PROFILE_FIRMWARE_ID_PROPERTY = "firmware_id"; public static final String DEVICE_PROFILE_SOFTWARE_ID_PROPERTY = "software_id"; public static final String DEVICE_PROFILE_CERTIFICATE_HASH_PROPERTY = "certificate_hash"; - public static final String DEVICE_PROFILE_CERTIFICATE_VALUE_PROPERTY = "certificate_value"; - public static final String DEVICE_PROFILE_CERTIFICATE_REGEX_PATTERN_PROPERTY = "certificate_regex_pattern"; public static final String DEVICE_PROFILE_DEFAULT_EDGE_RULE_CHAIN_ID_PROPERTY = "default_edge_rule_chain_id"; - public static final String ALLOW_CREATE_NEW_DEVICES_BY_X509_PROVISION = "allow_create_device_by_x509"; /** * Asset profile constants. From 5bf7b6162e95ece7e2a7a6607e64c6ffb1381fb6 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Fri, 10 Mar 2023 15:48:52 +0200 Subject: [PATCH 016/100] Fix sql schema --- application/src/main/data/upgrade/3.4.4/schema_update.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/main/data/upgrade/3.4.4/schema_update.sql b/application/src/main/data/upgrade/3.4.4/schema_update.sql index 9f8a81daaf..d208585ed7 100644 --- a/application/src/main/data/upgrade/3.4.4/schema_update.sql +++ b/application/src/main/data/upgrade/3.4.4/schema_update.sql @@ -353,7 +353,7 @@ $$; ALTER TABLE device_profile ADD COLUMN IF NOT EXISTS certificate_hash varchar, -DROP CONSTRAINT IF EXISTS device_profile_credentials_hash_unq_key, + DROP CONSTRAINT IF EXISTS device_profile_credentials_hash_unq_key, ADD CONSTRAINT device_profile_credentials_hash_unq_key UNIQUE (certificate_hash); -- DEVICE PROFILE CERTIFICATE END From 0e019967e2e45bdc33139e80b1fb720ae53c1e78 Mon Sep 17 00:00:00 2001 From: deaflynx Date: Wed, 22 Mar 2023 12:13:59 +0200 Subject: [PATCH 017/100] Feature X509 Device provision added ui form --- ...ile-provision-configuration.component.html | 36 ++++++++++++++++--- ...ofile-provision-configuration.component.ts | 16 ++++++++- ui-ngx/src/app/shared/models/device.models.ts | 6 ++-- .../assets/locale/locale.constant-en_US.json | 13 +++++++ 4 files changed, 63 insertions(+), 8 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html index b588a5ec34..ffe4a4732a 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html @@ -27,18 +27,20 @@ {{ 'device-profile.provision-strategy-required' | translate }} -

+
device-profile.provision-device-key - + --> {{ 'device-profile.provision-device-key-required' | translate }} @@ -46,17 +48,41 @@ device-profile.provision-device-secret - + --> {{ 'device-profile.provision-device-secret-required' | translate }}
+
+
+ + {{ 'device-profile.provision-strategy-x509.allow-create-new-devices' | translate }} + +
+ + device-profile.provision-strategy-x509.certificate-value + + + {{ 'device-profile.provision-strategy-x509.certificate-value-required' | translate }} + + + + device-profile.provision-strategy-x509.cn-regex-variable + + + {{ 'device-profile.provision-strategy-x509.cn-regex-variable-required' | translate }} + + +
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts index 872571dc20..17ccd40df0 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts @@ -86,7 +86,10 @@ export class DeviceProfileProvisionConfigurationComponent implements ControlValu this.provisionConfigurationFormGroup = this.fb.group({ type: [DeviceProvisionType.DISABLED, Validators.required], provisionDeviceSecret: [{value: null, disabled: true}, Validators.required], - provisionDeviceKey: [{value: null, disabled: true}, Validators.required] + provisionDeviceKey: [{value: null, disabled: true}, Validators.required], + certificateValue: [{value: null, disabled: true}, Validators.required], + certificateRegExPattern: [{value: '[\\w]*', disabled: true}, Validators.required], + allowCreateNewDevicesByX509Certificate: [{value: true, disabled: true}] }); this.provisionConfigurationFormGroup.get('type').valueChanges.subscribe((type) => { if (type === DeviceProvisionType.DISABLED) { @@ -94,6 +97,17 @@ export class DeviceProfileProvisionConfigurationComponent implements ControlValu this.provisionConfigurationFormGroup.get('provisionDeviceSecret').patchValue(null, {emitEvent: false}); this.provisionConfigurationFormGroup.get('provisionDeviceKey').disable({emitEvent: false}); this.provisionConfigurationFormGroup.get('provisionDeviceKey').patchValue(null); + this.provisionConfigurationFormGroup.get('certificateValue').disable({emitEvent: false}); + this.provisionConfigurationFormGroup.get('certificateValue').patchValue(null); + this.provisionConfigurationFormGroup.get('certificateRegExPattern').disable({emitEvent: false}); + this.provisionConfigurationFormGroup.get('certificateRegExPattern').patchValue(null); + this.provisionConfigurationFormGroup.get('allowCreateNewDevicesByX509Certificate').disable({emitEvent: false}); + this.provisionConfigurationFormGroup.get('allowCreateNewDevicesByX509Certificate').patchValue(null); + + } else if (type === DeviceProvisionType.X509_CERTIFICATE_CHAIN) { + this.provisionConfigurationFormGroup.get('certificateValue').enable({emitEvent: false}); + this.provisionConfigurationFormGroup.get('certificateRegExPattern').enable({emitEvent: false}); + this.provisionConfigurationFormGroup.get('allowCreateNewDevicesByX509Certificate').enable({emitEvent: false}); } else { const provisionDeviceSecret: string = this.provisionConfigurationFormGroup.get('provisionDeviceSecret').value; if (!provisionDeviceSecret || !provisionDeviceSecret.length) { diff --git a/ui-ngx/src/app/shared/models/device.models.ts b/ui-ngx/src/app/shared/models/device.models.ts index ae601b0039..382b32799b 100644 --- a/ui-ngx/src/app/shared/models/device.models.ts +++ b/ui-ngx/src/app/shared/models/device.models.ts @@ -62,7 +62,8 @@ export enum CoapTransportDeviceType { export enum DeviceProvisionType { DISABLED = 'DISABLED', ALLOW_CREATE_NEW_DEVICES = 'ALLOW_CREATE_NEW_DEVICES', - CHECK_PRE_PROVISIONED_DEVICES = 'CHECK_PRE_PROVISIONED_DEVICES' + CHECK_PRE_PROVISIONED_DEVICES = 'CHECK_PRE_PROVISIONED_DEVICES', + X509_CERTIFICATE_CHAIN = 'X509_CERTIFICATE_CHAIN' } export interface DeviceConfigurationFormInfo { @@ -110,7 +111,8 @@ export const deviceProvisionTypeTranslationMap = new MapMore", + "allow-create-new-devices": "Create new devices", + "hint-allow-create-new-devices": "Hint: if selected new devices will be created with correct device credentials from client certificate.", + "certificate-value": "Certificate in PEM format", + "certificate-value-required": "Certificate in PEM format is required", + "cn-regex-variable": "CN Regular Expression variable", + "cn-regex-variable-required": "CN Regular Expression variable is required", + "hint-cn-regex-variable-title": "Examples of RegEx usage:", + "hint-cn-regex-variable-examples": "1. Pattern: .* - matches any character (until line terminators)
CN sample: DeviceName\\nAdditionalInfo
Pattern matches: DeviceName OR DeviceName\\nAdditionalInfo

2. Pattern: ^([^@]+) - matches any string that starts with one or more characters that are not the “@” symbol (@ could be replaced by any other symbol)
CN sample: DeviceName@AdditionalInfo
Pattern matches: DeviceName

3. Pattern: [\\w]*$ (equivalent to [a-zA-Z0-9_]*$) - matches zero or more occurences of any word character (letter, digit or underscore) at the end of the string
CN sample: AdditionalInfo2110#DeviceName_01
Pattern matches: DeviceNane_01", + "hint-cn-regex-variable-note": "Note: Client will get error response in case regex is failed to match." + }, "condition": "Condition", "condition-type": "Condition type", "condition-type-simple": "Simple", From d3803f828dba5b90545f47ecf7280caee71cb2e5 Mon Sep 17 00:00:00 2001 From: deaflynx Date: Wed, 22 Mar 2023 13:51:56 +0200 Subject: [PATCH 018/100] Device profile provision strategy -> fix patch values form for X509 Certificate Chain --- ...ile-provision-configuration.component.html | 13 ++++++------- ...ofile-provision-configuration.component.ts | 19 ++++++++++++++++--- .../profile/device-profile.component.ts | 10 ++++++++-- ui-ngx/src/app/shared/models/device.models.ts | 3 +++ .../assets/locale/locale.constant-en_US.json | 5 ++--- 5 files changed, 35 insertions(+), 15 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html index ffe4a4732a..8b44586f50 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html @@ -33,14 +33,14 @@ device-profile.provision-device-key - + {{ 'device-profile.provision-device-key-required' | translate }} @@ -48,14 +48,14 @@ device-profile.provision-device-secret - + {{ 'device-profile.provision-device-secret-required' | translate }} @@ -80,9 +80,8 @@ {{ 'device-profile.provision-strategy-x509.cn-regex-variable-required' | translate }} + device-profile.provision-strategy-x509.hint-cn-regex-variable -
-
-
+
diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts index 17ccd40df0..bb936080da 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts @@ -88,8 +88,8 @@ export class DeviceProfileProvisionConfigurationComponent implements ControlValu provisionDeviceSecret: [{value: null, disabled: true}, Validators.required], provisionDeviceKey: [{value: null, disabled: true}, Validators.required], certificateValue: [{value: null, disabled: true}, Validators.required], - certificateRegExPattern: [{value: '[\\w]*', disabled: true}, Validators.required], - allowCreateNewDevicesByX509Certificate: [{value: true, disabled: true}] + certificateRegExPattern: [{value: null, disabled: true}, Validators.required], + allowCreateNewDevicesByX509Certificate: [{value: null, disabled: true}] }); this.provisionConfigurationFormGroup.get('type').valueChanges.subscribe((type) => { if (type === DeviceProvisionType.DISABLED) { @@ -103,8 +103,19 @@ export class DeviceProfileProvisionConfigurationComponent implements ControlValu this.provisionConfigurationFormGroup.get('certificateRegExPattern').patchValue(null); this.provisionConfigurationFormGroup.get('allowCreateNewDevicesByX509Certificate').disable({emitEvent: false}); this.provisionConfigurationFormGroup.get('allowCreateNewDevicesByX509Certificate').patchValue(null); - } else if (type === DeviceProvisionType.X509_CERTIFICATE_CHAIN) { + const certificateValue: string = this.provisionConfigurationFormGroup.get('certificateValue').value; + if (!certificateValue || !certificateValue.length) { + this.provisionConfigurationFormGroup.get('certificateValue').patchValue(null, {emitEvent: false}); + } + const certificateRegExPattern: string = this.provisionConfigurationFormGroup.get('certificateRegExPattern').value; + if (!certificateRegExPattern || !certificateRegExPattern.length) { + this.provisionConfigurationFormGroup.get('certificateRegExPattern').patchValue('[\\w]*', {emitEvent: false}); + } + const allowCreateNewDevicesByX509Certificate: boolean | null = this.provisionConfigurationFormGroup.get('allowCreateNewDevicesByX509Certificate').value; + if (typeof allowCreateNewDevicesByX509Certificate !== 'boolean') { + this.provisionConfigurationFormGroup.get('allowCreateNewDevicesByX509Certificate').patchValue(true, {emitEvent: false}); + } this.provisionConfigurationFormGroup.get('certificateValue').enable({emitEvent: false}); this.provisionConfigurationFormGroup.get('certificateRegExPattern').enable({emitEvent: false}); this.provisionConfigurationFormGroup.get('allowCreateNewDevicesByX509Certificate').enable({emitEvent: false}); @@ -119,6 +130,8 @@ export class DeviceProfileProvisionConfigurationComponent implements ControlValu } this.provisionConfigurationFormGroup.get('provisionDeviceSecret').enable({emitEvent: false}); this.provisionConfigurationFormGroup.get('provisionDeviceKey').enable({emitEvent: false}); + this.provisionConfigurationFormGroup.get('certificateValue').disable({emitEvent: false}); + this.provisionConfigurationFormGroup.get('certificateRegExPattern').disable({emitEvent: false}); } }); this.provisionConfigurationFormGroup.valueChanges.subscribe(() => { diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile.component.ts b/ui-ngx/src/app/modules/home/components/profile/device-profile.component.ts index 9adcf9c168..9a6d150ac1 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile.component.ts @@ -104,7 +104,10 @@ export class DeviceProfileComponent extends EntityComponent { const deviceProvisionConfiguration: DeviceProvisionConfiguration = { type: entity?.provisionType ? entity.provisionType : DeviceProvisionType.DISABLED, provisionDeviceKey: entity?.provisionDeviceKey, - provisionDeviceSecret: entity?.profileData?.provisionConfiguration?.provisionDeviceSecret + provisionDeviceSecret: entity?.profileData?.provisionConfiguration?.provisionDeviceSecret, + certificateValue: entity?.profileData?.provisionConfiguration?.certificateValue, + certificateRegExPattern: entity?.profileData?.provisionConfiguration?.certificateRegExPattern, + allowCreateNewDevicesByX509Certificate: entity?.profileData?.provisionConfiguration?.allowCreateNewDevicesByX509Certificate }; const form = this.fb.group( { @@ -185,7 +188,10 @@ export class DeviceProfileComponent extends EntityComponent { const deviceProvisionConfiguration: DeviceProvisionConfiguration = { type: entity?.provisionType ? entity.provisionType : DeviceProvisionType.DISABLED, provisionDeviceKey: entity?.provisionDeviceKey, - provisionDeviceSecret: entity?.profileData?.provisionConfiguration?.provisionDeviceSecret + provisionDeviceSecret: entity?.profileData?.provisionConfiguration?.provisionDeviceSecret, + certificateValue: entity?.profileData?.provisionConfiguration?.certificateValue, + certificateRegExPattern: entity?.profileData?.provisionConfiguration?.certificateRegExPattern, + allowCreateNewDevicesByX509Certificate: entity?.profileData?.provisionConfiguration?.allowCreateNewDevicesByX509Certificate }; this.entityForm.patchValue({name: entity.name}); this.entityForm.patchValue({type: entity.type}, {emitEvent: false}); diff --git a/ui-ngx/src/app/shared/models/device.models.ts b/ui-ngx/src/app/shared/models/device.models.ts index 382b32799b..de240934c4 100644 --- a/ui-ngx/src/app/shared/models/device.models.ts +++ b/ui-ngx/src/app/shared/models/device.models.ts @@ -322,6 +322,9 @@ export interface DeviceProvisionConfiguration { type: DeviceProvisionType; provisionDeviceSecret?: string; provisionDeviceKey?: string; + certificateValue?: string; + certificateRegExPattern?: string; + allowCreateNewDevicesByX509Certificate?: boolean; } export function createDeviceProfileConfiguration(type: DeviceProfileType): DeviceProfileConfiguration { 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 d6e70ddd51..09ae98d682 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -1504,9 +1504,8 @@ "certificate-value-required": "Certificate in PEM format is required", "cn-regex-variable": "CN Regular Expression variable", "cn-regex-variable-required": "CN Regular Expression variable is required", - "hint-cn-regex-variable-title": "Examples of RegEx usage:", - "hint-cn-regex-variable-examples": "1. Pattern: .* - matches any character (until line terminators)
CN sample: DeviceName\\nAdditionalInfo
Pattern matches: DeviceName OR DeviceName\\nAdditionalInfo

2. Pattern: ^([^@]+) - matches any string that starts with one or more characters that are not the “@” symbol (@ could be replaced by any other symbol)
CN sample: DeviceName@AdditionalInfo
Pattern matches: DeviceName

3. Pattern: [\\w]*$ (equivalent to [a-zA-Z0-9_]*$) - matches zero or more occurences of any word character (letter, digit or underscore) at the end of the string
CN sample: AdditionalInfo2110#DeviceName_01
Pattern matches: DeviceNane_01", - "hint-cn-regex-variable-note": "Note: Client will get error response in case regex is failed to match." + "hint-cn-regex-variable": "Required to fetch device name from device's X509 certificate's common name.", + "regex-examples": "Examples of RegEx usage:
  1. Pattern: .* - matches any character (until line terminators)
    CN sample: DeviceName\\nAdditionalInfo
    Pattern matches: DeviceName OR DeviceName\\nAdditionalInfo

  2. Pattern: ^([^@]+) - matches any string that starts with one or more characters that are not the @ symbol (@ could be replaced by any other symbol)
    CN sample: DeviceName@AdditionalInfo
    Pattern matches: DeviceName

  3. Pattern: [\\w]*$ (equivalent to [a-zA-Z0-9_]*$) - matches zero or more occurences of any word character (letter, digit or underscore) at the end of the string
    CN sample: AdditionalInfo2110#DeviceName_01
    Pattern matches: DeviceNane_01
Note: Client will get error response in case regex is failed to match." }, "condition": "Condition", "condition-type": "Condition type", From 86423fdc7b2e2e3755e61efd172b2303de5b0c67 Mon Sep 17 00:00:00 2001 From: deaflynx Date: Wed, 22 Mar 2023 17:31:19 +0200 Subject: [PATCH 019/100] Device profile provision X509 Certificate Chain added dynamic readMore for hint; updated text in hint --- ...ice-profile-provision-configuration.component.html | 11 +++++++---- ...evice-profile-provision-configuration.component.ts | 1 + ui-ngx/src/assets/locale/locale.constant-en_US.json | 8 +++++--- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html index 8b44586f50..99cddbd576 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html @@ -27,13 +27,13 @@ {{ 'device-profile.provision-strategy-required' | translate }} -
device-profile.provision-device-key - <
-
+ {{ 'device-profile.provision-strategy-x509.allow-create-new-devices' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts index bb936080da..687bc069b1 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts @@ -62,6 +62,7 @@ export class DeviceProfileProvisionConfigurationComponent implements ControlValu deviceProvisionType = DeviceProvisionType; deviceProvisionTypes = Object.keys(DeviceProvisionType); deviceProvisionTypeTranslateMap = deviceProvisionTypeTranslationMap; + readMore: boolean; private requiredValue: boolean; get required(): boolean { 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 09ae98d682..e9e839ce8b 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -61,7 +61,9 @@ "done": "Done", "print": "Print", "restore": "Restore", - "confirm": "Confirm" + "confirm": "Confirm", + "more": "More", + "less": "Less" }, "aggregation": { "aggregation": "Aggregation", @@ -1497,7 +1499,7 @@ "provision-secret-copied-message": "Provision secret has been copied to clipboard", "provision-strategy-x509": { "certificate-chain": "X509 Certificates Chain", - "hint-certificate-chain": "X.509 certificates strategy is used to provision devices by client certificates in two-way TLS communication. This strategy can check for pre-provisioned devices, update X.509 device credentials, or create new devices. The user uploads X.509 certificate to the device profile and sets a regular expression to fetch the device name from Common Name (CN)... More", + "hint-certificate-chain": "X.509 certificates strategy is used to provision devices by client certificates in two-way TLS communication. This strategy can check for pre-provisioned devices, update X.509 device credentials, or create new devices. The user uploads X.509 certificate to the device profile and sets a regular expression to fetch the device name from Common Name (CN).

Client certificates must be signed by X.509 certificate, pre-uploaded for this device profile to provision devices by the strategy. The client must establish a TLS connection using the entire chain of certificates (this chain must include device profile X.509 certificate on the last level). If a device already exists with outdated X.509 credentials, this strategy automatically updates it with the device certificate's credentials from the chain.

Important: Uploaded certificates should be neither root nor intermediate certificates that are provided by a well-known Certificate Authority (CA).", "allow-create-new-devices": "Create new devices", "hint-allow-create-new-devices": "Hint: if selected new devices will be created with correct device credentials from client certificate.", "certificate-value": "Certificate in PEM format", @@ -1505,7 +1507,7 @@ "cn-regex-variable": "CN Regular Expression variable", "cn-regex-variable-required": "CN Regular Expression variable is required", "hint-cn-regex-variable": "Required to fetch device name from device's X509 certificate's common name.", - "regex-examples": "Examples of RegEx usage:
  1. Pattern: .* - matches any character (until line terminators)
    CN sample: DeviceName\\nAdditionalInfo
    Pattern matches: DeviceName OR DeviceName\\nAdditionalInfo

  2. Pattern: ^([^@]+) - matches any string that starts with one or more characters that are not the @ symbol (@ could be replaced by any other symbol)
    CN sample: DeviceName@AdditionalInfo
    Pattern matches: DeviceName

  3. Pattern: [\\w]*$ (equivalent to [a-zA-Z0-9_]*$) - matches zero or more occurences of any word character (letter, digit or underscore) at the end of the string
    CN sample: AdditionalInfo2110#DeviceName_01
    Pattern matches: DeviceNane_01
Note: Client will get error response in case regex is failed to match." + "regex-examples": "Examples of RegEx usage:
  1. Pattern: .* - matches any character (until line terminators)
    CN sample: DeviceName\\nAdditionalInfo
    Pattern matches: DeviceName

  2. Pattern: ^([^@]+) - matches any string that starts with one or more characters that are not the @ symbol (@ could be replaced by any other symbol)
    CN sample: DeviceName@AdditionalInfo
    Pattern matches: DeviceName

  3. Pattern: [\\w]*$ (equivalent to [a-zA-Z0-9_]*$) - matches zero or more occurences of any word character (letter, digit or underscore) at the end of the string
    CN sample: AdditionalInfo2110#DeviceName_01
    Pattern matches: DeviceName_01
Note: Client will get error response in case regex is failed to match." }, "condition": "Condition", "condition-type": "Condition type", From 8703a2083efd9de64e51e03ddae7271be43b9d27 Mon Sep 17 00:00:00 2001 From: deaflynx Date: Thu, 23 Mar 2023 11:21:44 +0200 Subject: [PATCH 020/100] Device provision feature X509: 1. Added ng-templates with ngSwitchCase attribute 2. Fix NG0300: Multiple components match node with tagname button 3. Fix max-width for tb-hints --- ...ile-provision-configuration.component.html | 130 ++++++++++-------- ...ofile-provision-configuration.component.ts | 4 +- 2 files changed, 72 insertions(+), 62 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html index 99cddbd576..3b2b92ce98 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html @@ -27,64 +27,74 @@ {{ 'device-profile.provision-strategy-required' | translate }} -
- - device-profile.provision-device-key - - - - {{ 'device-profile.provision-device-key-required' | translate }} - - - - device-profile.provision-device-secret - - - - {{ 'device-profile.provision-device-secret-required' | translate }} - - -
-
- - - {{ 'device-profile.provision-strategy-x509.allow-create-new-devices' | translate }} - -
- - device-profile.provision-strategy-x509.certificate-value - - - {{ 'device-profile.provision-strategy-x509.certificate-value-required' | translate }} - - - - device-profile.provision-strategy-x509.cn-regex-variable - - - {{ 'device-profile.provision-strategy-x509.cn-regex-variable-required' | translate }} - - device-profile.provision-strategy-x509.hint-cn-regex-variable - -
-
+
+ + + + + + + + + + {{ 'device-profile.provision-strategy-x509.allow-create-new-devices' | translate }} + +
+ + device-profile.provision-strategy-x509.certificate-value + + + {{ 'device-profile.provision-strategy-x509.certificate-value-required' | translate }} + + + + device-profile.provision-strategy-x509.cn-regex-variable + + + {{ 'device-profile.provision-strategy-x509.cn-regex-variable-required' | translate }} + + device-profile.provision-strategy-x509.hint-cn-regex-variable + +
+
+
+ +
+ + device-profile.provision-device-key + + + + {{ 'device-profile.provision-device-key-required' | translate }} + + + + device-profile.provision-device-secret + + + + {{ 'device-profile.provision-device-secret-required' | translate }} + + +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts index 687bc069b1..beab4828a5 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts @@ -32,7 +32,7 @@ import { DeviceProvisionType, deviceProvisionTypeTranslationMap } from '@shared/models/device.models'; -import { generateSecret, isDefinedAndNotNull } from '@core/utils'; +import { generateSecret, isBoolean, isDefinedAndNotNull } from '@core/utils'; import { ActionNotificationShow } from '@core/notification/notification.actions'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @@ -114,7 +114,7 @@ export class DeviceProfileProvisionConfigurationComponent implements ControlValu this.provisionConfigurationFormGroup.get('certificateRegExPattern').patchValue('[\\w]*', {emitEvent: false}); } const allowCreateNewDevicesByX509Certificate: boolean | null = this.provisionConfigurationFormGroup.get('allowCreateNewDevicesByX509Certificate').value; - if (typeof allowCreateNewDevicesByX509Certificate !== 'boolean') { + if (isBoolean(allowCreateNewDevicesByX509Certificate)) { this.provisionConfigurationFormGroup.get('allowCreateNewDevicesByX509Certificate').patchValue(true, {emitEvent: false}); } this.provisionConfigurationFormGroup.get('certificateValue').enable({emitEvent: false}); From d603e7dbb709c59f0ba4143c869eeabff9b1dde3 Mon Sep 17 00:00:00 2001 From: deaflynx Date: Thu, 23 Mar 2023 11:52:41 +0200 Subject: [PATCH 021/100] Device profile Transport configuration MQTT profile set max-width tb-hint --- .../mqtt-device-profile-transport-configuration.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.html index 1ef7892ecb..9e3ae02098 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.html @@ -163,6 +163,6 @@ {{ 'device-profile.mqtt-send-ack-on-validation-exception' | translate }} -
+ From 1498f264f68f2252c0f2f7ee4eaa1cd6824db390 Mon Sep 17 00:00:00 2001 From: deaflynx Date: Thu, 23 Mar 2023 12:20:20 +0200 Subject: [PATCH 022/100] MQTT device profile fix tb-hint --- .../mqtt-device-profile-transport-configuration.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.html index 9e3ae02098..4dc8b2a7b8 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.html @@ -163,6 +163,6 @@ {{ 'device-profile.mqtt-send-ack-on-validation-exception' | translate }} - +
From a3615f11fe1c286da9eab4444168ba03873ebd5a Mon Sep 17 00:00:00 2001 From: deaflynx Date: Thu, 23 Mar 2023 14:03:06 +0200 Subject: [PATCH 023/100] Device profile feature X509: - fix edit form after creation; - added fields iteration in provisionConfigurationFormGroup for type DISABLED; --- ...ofile-provision-configuration.component.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts index beab4828a5..465588fa24 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts @@ -94,16 +94,13 @@ export class DeviceProfileProvisionConfigurationComponent implements ControlValu }); this.provisionConfigurationFormGroup.get('type').valueChanges.subscribe((type) => { if (type === DeviceProvisionType.DISABLED) { - this.provisionConfigurationFormGroup.get('provisionDeviceSecret').disable({emitEvent: false}); - this.provisionConfigurationFormGroup.get('provisionDeviceSecret').patchValue(null, {emitEvent: false}); - this.provisionConfigurationFormGroup.get('provisionDeviceKey').disable({emitEvent: false}); - this.provisionConfigurationFormGroup.get('provisionDeviceKey').patchValue(null); - this.provisionConfigurationFormGroup.get('certificateValue').disable({emitEvent: false}); - this.provisionConfigurationFormGroup.get('certificateValue').patchValue(null); - this.provisionConfigurationFormGroup.get('certificateRegExPattern').disable({emitEvent: false}); - this.provisionConfigurationFormGroup.get('certificateRegExPattern').patchValue(null); - this.provisionConfigurationFormGroup.get('allowCreateNewDevicesByX509Certificate').disable({emitEvent: false}); - this.provisionConfigurationFormGroup.get('allowCreateNewDevicesByX509Certificate').patchValue(null); + for (const field in this.provisionConfigurationFormGroup.controls) { + if (field !== 'type') { + const control = this.provisionConfigurationFormGroup.get(field); + control.disable({emitEvent: false}); + control.patchValue(null, {emitEvent: false}); + } + } } else if (type === DeviceProvisionType.X509_CERTIFICATE_CHAIN) { const certificateValue: string = this.provisionConfigurationFormGroup.get('certificateValue').value; if (!certificateValue || !certificateValue.length) { @@ -114,12 +111,14 @@ export class DeviceProfileProvisionConfigurationComponent implements ControlValu this.provisionConfigurationFormGroup.get('certificateRegExPattern').patchValue('[\\w]*', {emitEvent: false}); } const allowCreateNewDevicesByX509Certificate: boolean | null = this.provisionConfigurationFormGroup.get('allowCreateNewDevicesByX509Certificate').value; - if (isBoolean(allowCreateNewDevicesByX509Certificate)) { + if (!isBoolean(allowCreateNewDevicesByX509Certificate)) { this.provisionConfigurationFormGroup.get('allowCreateNewDevicesByX509Certificate').patchValue(true, {emitEvent: false}); } this.provisionConfigurationFormGroup.get('certificateValue').enable({emitEvent: false}); this.provisionConfigurationFormGroup.get('certificateRegExPattern').enable({emitEvent: false}); this.provisionConfigurationFormGroup.get('allowCreateNewDevicesByX509Certificate').enable({emitEvent: false}); + this.provisionConfigurationFormGroup.get('provisionDeviceSecret').disable({emitEvent: false}); + this.provisionConfigurationFormGroup.get('provisionDeviceKey').disable({emitEvent: false}); } else { const provisionDeviceSecret: string = this.provisionConfigurationFormGroup.get('provisionDeviceSecret').value; if (!provisionDeviceSecret || !provisionDeviceSecret.length) { @@ -133,6 +132,7 @@ export class DeviceProfileProvisionConfigurationComponent implements ControlValu this.provisionConfigurationFormGroup.get('provisionDeviceKey').enable({emitEvent: false}); this.provisionConfigurationFormGroup.get('certificateValue').disable({emitEvent: false}); this.provisionConfigurationFormGroup.get('certificateRegExPattern').disable({emitEvent: false}); + this.provisionConfigurationFormGroup.get('allowCreateNewDevicesByX509Certificate').disable({emitEvent: false}); } }); this.provisionConfigurationFormGroup.valueChanges.subscribe(() => { From f43e543e844a931df2a7882445c3e01268ed7d3a Mon Sep 17 00:00:00 2001 From: Artem Dzhereleiko Date: Thu, 23 Mar 2023 14:21:31 +0200 Subject: [PATCH 024/100] UI: color picker --- ui-ngx/package.json | 1 + .../color-picker-input.component.html | 18 +++ .../color-picker-input.component.ts | 70 +++++++++++ .../color-picker/color-picker.component.html | 37 ++++++ .../color-picker/color-picker.component.scss | 110 ++++++++++++++++++ .../color-picker/color-picker.component.ts | 106 +++++++++++++++++ .../dialog/color-picker-dialog.component.html | 13 ++- .../dialog/color-picker-dialog.component.ts | 18 ++- ui-ngx/src/app/shared/shared.module.ts | 11 +- ui-ngx/yarn.lock | 7 ++ 10 files changed, 380 insertions(+), 11 deletions(-) create mode 100644 ui-ngx/src/app/shared/components/color-picker/color-picker-input.component.html create mode 100644 ui-ngx/src/app/shared/components/color-picker/color-picker-input.component.ts create mode 100644 ui-ngx/src/app/shared/components/color-picker/color-picker.component.html create mode 100644 ui-ngx/src/app/shared/components/color-picker/color-picker.component.scss create mode 100644 ui-ngx/src/app/shared/components/color-picker/color-picker.component.ts diff --git a/ui-ngx/package.json b/ui-ngx/package.json index a8abb6fbeb..96577444fd 100644 --- a/ui-ngx/package.json +++ b/ui-ngx/package.json @@ -31,6 +31,7 @@ "@flowjs/flow.js": "^2.14.1", "@flowjs/ngx-flow": "~0.6.0", "@geoman-io/leaflet-geoman-free": "^2.13.0", + "@iplab/ngx-color-picker": "^15.0.2", "@juggle/resize-observer": "^3.3.1", "@mat-datetimepicker/core": "~11.0.3", "@material-ui/core": "4.12.3", diff --git a/ui-ngx/src/app/shared/components/color-picker/color-picker-input.component.html b/ui-ngx/src/app/shared/components/color-picker/color-picker-input.component.html new file mode 100644 index 0000000000..564f1d64f2 --- /dev/null +++ b/ui-ngx/src/app/shared/components/color-picker/color-picker-input.component.html @@ -0,0 +1,18 @@ +
+
+ + + + + + + + + +
+ + + + +
+ diff --git a/ui-ngx/src/app/shared/components/color-picker/color-picker-input.component.ts b/ui-ngx/src/app/shared/components/color-picker/color-picker-input.component.ts new file mode 100644 index 0000000000..22ce2c0849 --- /dev/null +++ b/ui-ngx/src/app/shared/components/color-picker/color-picker-input.component.ts @@ -0,0 +1,70 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + EventEmitter, + Input, + OnInit, + Output +} from '@angular/core'; +import { Color } from '@iplab/ngx-color-picker'; +import { ColorType } from '@shared/components/color-picker/color-picker.component'; + +@Component({ + selector: `tb-color-picker-input-component`, + templateUrl: `./color-picker-input.component.html`, + styleUrls: [], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ColorPickerInputComponent { + + @Input() + public hue: Color; + + @Output() + public hueChange = new EventEmitter(false); + + @Input() + public color: Color; + + @Output() + public colorChange = new EventEmitter(false); + + public labelVisible: boolean; + + @Input() + public set label(value) { + this.labelVisible = true; + } + + @Input() + public colorFormat: string; + + public isAlphaVisible = true; + + @Input() + public set alpha(isVisible: boolean) { + this.isAlphaVisible = isVisible; + } + + public get value() { + return this.color ? this.color.getRgba() : null; + } + + constructor() { + } + + public onInputChange(newValue, color: 'R' | 'G' | 'B' | 'A') { + const value = this.value; + const red = color === 'R' ? newValue : value.red; + const green = color === 'G' ? newValue : value.green; + const blue = color === 'B' ? newValue : value.blue; + const alpha = color === 'A' ? newValue : value.alpha; + + const newColor = new Color().setRgba(red, green, blue, alpha); + const hue = new Color().setHsva(newColor.getHsva().hue); + + this.hueChange.emit(hue); + this.colorChange.emit(newColor); + } +} diff --git a/ui-ngx/src/app/shared/components/color-picker/color-picker.component.html b/ui-ngx/src/app/shared/components/color-picker/color-picker.component.html new file mode 100644 index 0000000000..dba4605736 --- /dev/null +++ b/ui-ngx/src/app/shared/components/color-picker/color-picker.component.html @@ -0,0 +1,37 @@ + + +
+
+
+ +
+
+ + +
+
+ + + + + {{type}} + + + + + + + + + + + + + + + + + +
+ + diff --git a/ui-ngx/src/app/shared/components/color-picker/color-picker.component.scss b/ui-ngx/src/app/shared/components/color-picker/color-picker.component.scss new file mode 100644 index 0000000000..39a0c7444c --- /dev/null +++ b/ui-ngx/src/app/shared/components/color-picker/color-picker.component.scss @@ -0,0 +1,110 @@ +:host { + display: block; + width: 100%; + max-width: 320px; + min-width: 100%; + border-radius: 2px; + background: #fff; + + ::ng-deep { + .saturation-component { + height: 200px; + border-radius: 8px; + div.pointer { + border-width: 2px; + width: 16px; + height: 16px; + } + } + + indicator-component { + svg { + vertical-align: -70% !important; + } + } + + .hue-alpha-range { + alpha-component, hue-component { + height: 18px; + border: 1px solid rgba(0, 0, 0, 0.1); + .pointer { + height: 18px; + width: 18px; + background: none; + border: 2px solid #fff; + } + .gradient-color { + border-radius: 24px; + } + } + } + } +} + + + +.controls { + padding: 15px 0; +} + +.controls-row { + display: table; + width: 100%; +} + +.column { + display: table-cell; + vertical-align: middle; +} + +.hue-alpha .column:first-child { + width: 42px; + padding: 0 10px 0 0; +} + +indicator-component { + height: 48px; + width: 48px; + border-radius: 8px; + background: transparent url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==') repeat; +} + +.hue-alpha-range { + hue-component, alpha-component { + border-radius: 24px; + } +} + +hue-component { + margin-bottom: 16px; +} + +color-presets-component { + border-top: 1px solid #d0d0d0; + padding: 12px; + + ::ng-deep .presets-row { + padding: 12px 0 0; + } +} + +.type-btn { + display: inline-block; + height: 20px; + width: 20px; + background: transparent url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAAgCAYAAAAffCjxAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAACewAAAnsB01CO3AAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAIASURBVEiJ7ZY9axRRFIafsxMStrLQJpAgpBFhi+C9w1YSo00I6RZ/g9vZpBf/QOr4GyRgkSKNSrAadsZqQGwCkuAWyRZJsySwvhZ7N/vhzrgbLH3Ld8597jlzz50zJokyxXH8DqDVar0qi6v8BbItqSGpEcfxdlmsFWXkvX8AfAVWg3UKPEnT9GKujMzsAFgZsVaCN1VTQd77XUnrgE1kv+6935268WRpzrnHZvYRWC7YvC3pRZZl3wozqtVqiyH9IgjAspkd1Gq1xUJQtVrdB9ZKIAOthdg/Qc65LUk7wNIMoCVJO865rYFhkqjX6/d7vV4GPJwBMqofURS5JEk6FYBer/eeYb/Mo9WwFnPOvQbeAvfuAAK4BN4sAJtAG/gJIElmNuiJyba3EGNmZiPeZuEVmVell/Y/6N+CzDn3AXhEOOo7Hv/3BeAz8IzQkMPnJbuPx1wC+yYJ7/0nYIP5S/0FHKdp+rwCEEXRS/rf5Hl1Gtb2M0iSpCOpCZzPATmX1EySpHMLAsiy7MjMDoHrGSDXZnaYZdnRwBh7J91utwmczAA6CbG3GgPleX4jqUH/a1CktqRGnuc3hSCAMB32gKspkCtgb3KCQMmkjeP4WNJThrNNZval1WptTIsv7JtQ4tmIdRa8qSoEpWl6YWZNoAN0zKxZNPehpLSBZv2t+Q0CJ9lLnARQLAAAAABJRU5ErkJggg==') no-repeat center; + background-size: 6px 12px; + + &:hover { + background-color: #eee; + } +} + +.type-column { + width: 25px; + text-align: right; +} + +.presentation { + padding: 12px 0 0; +} diff --git a/ui-ngx/src/app/shared/components/color-picker/color-picker.component.ts b/ui-ngx/src/app/shared/components/color-picker/color-picker.component.ts new file mode 100644 index 0000000000..e11d11f880 --- /dev/null +++ b/ui-ngx/src/app/shared/components/color-picker/color-picker.component.ts @@ -0,0 +1,106 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + EventEmitter, + Input, + OnChanges, + OnDestroy, + OnInit, + Output, SimpleChanges +} from '@angular/core'; +import { Color, ColorPickerControl } from '@iplab/ngx-color-picker'; +import { Subscription } from 'rxjs'; +export enum ColorType { + hex = 'hex', + hexa = 'hexa', + rgba = 'rgba', + rgb = 'rgb', + hsla = 'hsla', + hsl = 'hsl', + cmyk = 'cmyk' +} + +@Component({ + selector: `tb-color-picker`, + templateUrl: `./color-picker.component.html`, + styleUrls: [`./color-picker.component.scss`], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ColorPickerComponent implements OnInit, OnChanges, OnDestroy { + + public selectedPresentation = 0; + public presentations = [ColorType.hex, ColorType.hexa, ColorType.rgb, ColorType.rgba, ColorType.hsl, ColorType.hsla]; + + @Input() + public color: string; + + @Input() + public control: ColorPickerControl; + + @Output() + public colorChange: EventEmitter = new EventEmitter(false); + + private subscriptions: Array = []; + + constructor(private readonly cdr: ChangeDetectorRef) { + } + + public ngOnInit(): void { + if (!this.control) { + this.control = new ColorPickerControl(); + } + + if (this.color) { + this.control.setValueFrom(this.color); + } + + this.subscriptions.push( + this.control.valueChanges.subscribe((value) => { + this.cdr.markForCheck(); + this.colorChange.emit(this.getValueByType(value, this.control.initType)); + }) + ); + } + + changeColorFormat(event: Event) { + this.colorChange.emit(this.getValueByType(this.control.value, this.control.initType)); + } + + public ngOnDestroy(): void { + this.cdr.detach(); + this.subscriptions.forEach((subscription) => subscription.unsubscribe()); + this.subscriptions.length = 0; + } + + public ngOnChanges(changes: SimpleChanges): void { + if (this.color && this.control && this.getValueByType(this.control.value, this.control.initType) !== this.color) { + this.control.setValueFrom(this.color); + } + } + + public changePresentation(): void { + this.selectedPresentation = + this.selectedPresentation === this.presentations.length - 1 ? 0 : this.selectedPresentation + 1; + } + + getValueByType(color: Color, type: ColorType): string { + switch (type) { + case ColorType.hex: + return color.toHexString(); + case ColorType.hexa: + return color.toHexString(true); + case ColorType.rgb: + return color.toRgbString(); + case ColorType.rgba: + return color.toRgbaString(); + case ColorType.hsl: + return color.toHslString(); + case ColorType.hsla: + return color.toHslaString(); + default: + return color.toRgbaString(); + } + } + +} diff --git a/ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.html b/ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.html index a7d4835547..4ea0a5f8c5 100644 --- a/ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.html +++ b/ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.html @@ -17,12 +17,13 @@ -->
- - + + + + + + +
diff --git a/ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.ts b/ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.ts index e986ddc331..784da8da25 100644 --- a/ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.ts +++ b/ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.ts @@ -19,9 +19,19 @@ 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 { UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, FormGroupDirective, NgForm, Validators } from '@angular/forms'; +import { + FormGroupDirective, + NgForm, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validators +} from '@angular/forms'; import { Router } from '@angular/router'; import { DialogComponent } from '@shared/components/dialog.component'; +import { ColorPickerControl } from '@iplab/ngx-color-picker'; +import { ColorType } from '@shared/components/color-picker/color-picker.component'; +import * as tinycolor_ from 'tinycolor2'; export interface ColorPickerDialogData { color: string; @@ -40,12 +50,16 @@ export class ColorPickerDialogComponent extends DialogComponent, protected router: Router, @Inject(MAT_DIALOG_DATA) public data: ColorPickerDialogData, @SkipSelf() private errorStateMatcher: ErrorStateMatcher, public dialogRef: MatDialogRef, - public fb: UntypedFormBuilder) { + public fb: UntypedFormBuilder,) { super(store, router, dialogRef); } diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts index 950532adab..10dbbef4aa 100644 --- a/ui-ngx/src/app/shared/shared.module.ts +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -62,7 +62,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { RouterModule } from '@angular/router'; import { ShareModule as ShareButtonsModule } from 'ngx-sharebuttons'; import { HotkeyModule } from 'angular2-hotkeys'; -import { ColorPickerModule } from 'ngx-color-picker'; +import { ColorPickerModule } from '@iplab/ngx-color-picker'; import { NgxHmCarouselModule } from 'ngx-hm-carousel'; import { UserMenuComponent } from '@shared/components/user-menu.component'; import { NospacePipe } from '@shared/pipe/nospace.pipe'; @@ -169,6 +169,8 @@ import { CustomDateAdapter } from '@shared/adapter/custom-datatime-adapter'; import { CustomPaginatorIntl } from '@shared/services/custom-paginator-intl'; import { TbScriptLangComponent } from '@shared/components/script-lang.component'; import { DateAgoPipe } from '@shared/pipe/date-ago.pipe'; +import { ColorPickerComponent } from '@shared/components/color-picker/color-picker.component'; +import { ColorPickerInputComponent } from '@shared/components/color-picker/color-picker-input.component'; export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) { return markedOptionsService; @@ -309,7 +311,9 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) BranchAutocompleteComponent, PhoneInputComponent, TbScriptLangComponent, - DateAgoPipe + DateAgoPipe, + ColorPickerComponent, + ColorPickerInputComponent ], imports: [ CommonModule, @@ -514,7 +518,8 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) BranchAutocompleteComponent, PhoneInputComponent, TbScriptLangComponent, - DateAgoPipe + DateAgoPipe, + ColorPickerComponent ] }) export class SharedModule { } diff --git a/ui-ngx/yarn.lock b/ui-ngx/yarn.lock index b939f898f1..e4c90c98f3 100644 --- a/ui-ngx/yarn.lock +++ b/ui-ngx/yarn.lock @@ -1565,6 +1565,13 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA== +"@iplab/ngx-color-picker@^15.0.2": + version "15.0.2" + resolved "https://registry.yarnpkg.com/@iplab/ngx-color-picker/-/ngx-color-picker-15.0.2.tgz#856bf2571378e792e5e42b566ac1aa79a9262763" + integrity sha512-wum0Hg4Ky/6mhvzolEpFpjGckOjN8L2nkXvy3mWUVclFHP9MZqqgpJaxghtax7/tMdEWQnwQI3PYsNyKfF15ug== + dependencies: + tslib "^2.3.0" + "@istanbuljs/load-nyc-config@^1.0.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" From 24d69651cb39fe6bedfdac64be89f8dc0b41dd8c Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Thu, 23 Mar 2023 15:20:08 +0200 Subject: [PATCH 025/100] Fix space inside EncryptionUtil and fix failed-test, while doing validation --- .../java/org/thingsboard/server/common/msg/EncryptionUtil.java | 3 ++- .../server/dao/device/DeviceProfileServiceImpl.java | 2 +- transport/mqtt/src/main/resources/tb-mqtt-transport.yml | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java b/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java index 668dd28edc..e944a8dd92 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java @@ -39,7 +39,8 @@ public class EncryptionUtil { return input.replaceAll("\n", "") .replaceAll("\r", "") .replaceAll("-----BEGIN CERTIFICATE-----", "-----BEGIN CERTIFICATE-----\n") - .replaceAll("-----END CERTIFICATE-----", "\n-----END CERTIFICATE-----\n"); + .replaceAll("-----END CERTIFICATE-----", "\n-----END CERTIFICATE-----\n") + .trim(); } public static String pubkTrimNewLines(String input) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java index 6d19190199..c4ee12395f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java @@ -136,7 +136,7 @@ public class DeviceProfileServiceImpl extends AbstractCachedEntityService Date: Fri, 24 Mar 2023 13:33:28 +0200 Subject: [PATCH 026/100] Device profile provision - fix for X509 provision strategy in provisionConfigurationFormGroup: - added resetFormControls() to reset redundant form controls; - allowCreateNewDevicesByX509Certificate added required validator; --- ...ofile-provision-configuration.component.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts index 465588fa24..caebc701d2 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts @@ -90,7 +90,7 @@ export class DeviceProfileProvisionConfigurationComponent implements ControlValu provisionDeviceKey: [{value: null, disabled: true}, Validators.required], certificateValue: [{value: null, disabled: true}, Validators.required], certificateRegExPattern: [{value: null, disabled: true}, Validators.required], - allowCreateNewDevicesByX509Certificate: [{value: null, disabled: true}] + allowCreateNewDevicesByX509Certificate: [{value: null, disabled: true}, Validators.required] }); this.provisionConfigurationFormGroup.get('type').valueChanges.subscribe((type) => { if (type === DeviceProvisionType.DISABLED) { @@ -117,8 +117,6 @@ export class DeviceProfileProvisionConfigurationComponent implements ControlValu this.provisionConfigurationFormGroup.get('certificateValue').enable({emitEvent: false}); this.provisionConfigurationFormGroup.get('certificateRegExPattern').enable({emitEvent: false}); this.provisionConfigurationFormGroup.get('allowCreateNewDevicesByX509Certificate').enable({emitEvent: false}); - this.provisionConfigurationFormGroup.get('provisionDeviceSecret').disable({emitEvent: false}); - this.provisionConfigurationFormGroup.get('provisionDeviceKey').disable({emitEvent: false}); } else { const provisionDeviceSecret: string = this.provisionConfigurationFormGroup.get('provisionDeviceSecret').value; if (!provisionDeviceSecret || !provisionDeviceSecret.length) { @@ -130,9 +128,6 @@ export class DeviceProfileProvisionConfigurationComponent implements ControlValu } this.provisionConfigurationFormGroup.get('provisionDeviceSecret').enable({emitEvent: false}); this.provisionConfigurationFormGroup.get('provisionDeviceKey').enable({emitEvent: false}); - this.provisionConfigurationFormGroup.get('certificateValue').disable({emitEvent: false}); - this.provisionConfigurationFormGroup.get('certificateRegExPattern').disable({emitEvent: false}); - this.provisionConfigurationFormGroup.get('allowCreateNewDevicesByX509Certificate').disable({emitEvent: false}); } }); this.provisionConfigurationFormGroup.valueChanges.subscribe(() => { @@ -178,6 +173,7 @@ export class DeviceProfileProvisionConfigurationComponent implements ControlValu private updateModel(): void { let deviceProvisionConfiguration: DeviceProvisionConfiguration = null; + this.resetFormControls(this.provisionConfigurationFormGroup.value); if (this.provisionConfigurationFormGroup.valid) { deviceProvisionConfiguration = this.provisionConfigurationFormGroup.getRawValue(); } @@ -194,4 +190,15 @@ export class DeviceProfileProvisionConfigurationComponent implements ControlValu horizontalPosition: 'right' })); } + + private resetFormControls(value: DeviceProvisionConfiguration) { + if (value.type === DeviceProvisionType.CHECK_PRE_PROVISIONED_DEVICES || value.type === DeviceProvisionType.ALLOW_CREATE_NEW_DEVICES) { + this.provisionConfigurationFormGroup.get('certificateValue').reset({value: null, disabled: true}, {emitEvent: false}); + this.provisionConfigurationFormGroup.get('certificateRegExPattern').reset({value: null, disabled: true}, {emitEvent: false}); + this.provisionConfigurationFormGroup.get('allowCreateNewDevicesByX509Certificate').reset({value: null, disabled: true}, {emitEvent: false}); + } else if (value.type === DeviceProvisionType.X509_CERTIFICATE_CHAIN) { + this.provisionConfigurationFormGroup.get('provisionDeviceSecret').reset({value: null, disabled: true}, {emitEvent: false}); + this.provisionConfigurationFormGroup.get('provisionDeviceKey').reset({value: null, disabled: true}, {emitEvent: false}); + } + } } From be826687eb9bee9b29b969f4d7d366f3f3c89745 Mon Sep 17 00:00:00 2001 From: deaflynx Date: Fri, 24 Mar 2023 14:42:01 +0200 Subject: [PATCH 027/100] Device profile provision strategy X509: updated hint locale hint-allow-create-new-devices --- ui-ngx/src/assets/locale/locale.constant-en_US.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 e9e839ce8b..3dceb760ef 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -1501,7 +1501,7 @@ "certificate-chain": "X509 Certificates Chain", "hint-certificate-chain": "X.509 certificates strategy is used to provision devices by client certificates in two-way TLS communication. This strategy can check for pre-provisioned devices, update X.509 device credentials, or create new devices. The user uploads X.509 certificate to the device profile and sets a regular expression to fetch the device name from Common Name (CN).

Client certificates must be signed by X.509 certificate, pre-uploaded for this device profile to provision devices by the strategy. The client must establish a TLS connection using the entire chain of certificates (this chain must include device profile X.509 certificate on the last level). If a device already exists with outdated X.509 credentials, this strategy automatically updates it with the device certificate's credentials from the chain.

Important: Uploaded certificates should be neither root nor intermediate certificates that are provided by a well-known Certificate Authority (CA).", "allow-create-new-devices": "Create new devices", - "hint-allow-create-new-devices": "Hint: if selected new devices will be created with correct device credentials from client certificate.", + "hint-allow-create-new-devices": "Hint: if selected new devices will be created and client certificate will be used as device credentials.", "certificate-value": "Certificate in PEM format", "certificate-value-required": "Certificate in PEM format is required", "cn-regex-variable": "CN Regular Expression variable", From 2ac861876e1bc0f78e618cce7a15285158a8f0ce Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 29 Mar 2023 11:40:41 +0300 Subject: [PATCH 028/100] Refactoring: fix conflict, add logs --- .../update/DefaultCacheCleanupService.java | 3 + .../transport/DefaultTransportApiService.java | 77 +++++++++++-------- .../src/main/resources/thingsboard.yml | 8 +- .../DefaultTransportApiServiceTest.java | 17 ++-- ...ertificateChainProvisionConfiguration.java | 7 +- .../mqtt/MqttSslHandlerProvider.java | 10 ++- .../server/common/transport/util/SslUtil.java | 1 + .../dao/device/DeviceProfileServiceImpl.java | 22 +++--- 8 files changed, 85 insertions(+), 60 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java b/application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java index bd840b2f52..1e54d8d48f 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java @@ -82,6 +82,9 @@ public class DefaultCacheCleanupService implements CacheCleanupService { log.info("Clearing cache to upgrade from version 3.4.2 to 3.4.3 ..."); clearCacheByName("repositorySettings"); break; + case "3.4.4": + log.info("Clearing cache to upgrade from version 3.4.4 to 3.5.0"); + clearCacheByName("deviceProfiles"); default: //Do nothing, since cache cleanup is optional. } diff --git a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java index ba0c2e48ca..ca886cf49b 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java @@ -174,7 +174,7 @@ public class DefaultTransportApiService implements TransportApiService { result = validateCredentials(msg.getHash(), DeviceCredentialsType.X509_CERTIFICATE); } else if (transportApiRequestMsg.hasValidateOrCreateX509CertRequestMsg()) { TransportProtos.ValidateOrCreateDeviceX509CertRequestMsg msg = transportApiRequestMsg.getValidateOrCreateX509CertRequestMsg(); - result = validateOrCreateDeviceX509Certificate(msg.getCertificateChain(), DeviceCredentialsType.X509_CERTIFICATE); + result = validateOrCreateDeviceX509Certificate(msg.getCertificateChain()); } else if (transportApiRequestMsg.hasGetOrCreateDeviceRequestMsg()) { result = handle(transportApiRequestMsg.getGetOrCreateDeviceRequestMsg()); } else if (transportApiRequestMsg.hasEntityProfileRequestMsg()) { @@ -242,19 +242,12 @@ public class DefaultTransportApiService implements TransportApiService { } } - protected ListenableFuture validateOrCreateDeviceX509Certificate(String certChain, DeviceCredentialsType credentialsType) { + protected ListenableFuture validateOrCreateDeviceX509Certificate(String certChain) { List chain = convertX509CertificateChainToList(certChain); - String updateDeviceCertificateValue = chain.get(0); - String updateDeviceCertificateHash = EncryptionUtil.getSha3Hash(updateDeviceCertificateValue); - String deviceCommonName = ""; - try { - deviceCommonName = SslUtil.parseCommonName(readCertFile(chain.get(0))); - } catch (Exception ignored) { - } for (String certificateValue : chain) { String certificateHash = EncryptionUtil.getSha3Hash(certificateValue); DeviceCredentials credentials = deviceCredentialsService.findDeviceCredentialsByCredentialsId(certificateHash); - if (credentials != null && credentials.getCredentialsType() == credentialsType) { + if (credentials != null && credentials.getCredentialsType() == DeviceCredentialsType.X509_CERTIFICATE) { return getDeviceInfo(credentials); } DeviceProfile deviceProfile = deviceProfileService.findDeviceProfileByCertificateHash(certificateHash); @@ -262,26 +255,31 @@ public class DefaultTransportApiService implements TransportApiService { X509CertificateChainProvisionConfiguration x509Configuration = new X509CertificateChainProvisionConfiguration(); if (deviceProfile.getProfileData().getProvisionConfiguration() instanceof X509CertificateChainProvisionConfiguration) { x509Configuration = (X509CertificateChainProvisionConfiguration) deviceProfile.getProfileData().getProvisionConfiguration(); + } else { + log.warn("Device Profile provision configuration is not X509CertificateChainProvisionConfiguration"); } - String deviceName = extractDeviceNameFromCNByRegEx(deviceCommonName, x509Configuration.getCertificateRegExPattern()); + String deviceName = extractDeviceNameFromCertificateCNByRegEx(chain.get(0), x509Configuration.getCertificateRegExPattern()); if (deviceName == null) { - log.error("Device name cannot be unmatched from CN!"); - return getEmptyTransportApiResponseFuture(); + log.warn("Device name has to be extract by regex from CN."); } Device device = deviceService.findDeviceByTenantIdAndName(deviceProfile.getTenantId(), deviceName); + String updateDeviceCertificateValue = chain.get(0); + String updateDeviceCertificateHash = EncryptionUtil.getSha3Hash(updateDeviceCertificateValue); if (device != null) { DeviceCredentials deviceCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(device.getTenantId(), device.getId()); - if (deviceCredentials != null && deviceCredentials.getCredentialsType() == credentialsType) { - deviceCredentials = updateDeviceCredentials(device.getTenantId(), deviceCredentials, updateDeviceCertificateValue, updateDeviceCertificateHash, credentialsType); + if (deviceCredentials != null && deviceCredentials.getCredentialsType() == DeviceCredentialsType.X509_CERTIFICATE) { + deviceCredentials = updateDeviceCredentials(device.getTenantId(), deviceCredentials, updateDeviceCertificateValue, updateDeviceCertificateHash, DeviceCredentialsType.X509_CERTIFICATE); } else if (deviceCredentials == null) { - deviceCredentials = createDeviceCredentials(device.getTenantId(), device.getId(), updateDeviceCertificateValue, updateDeviceCertificateHash, credentialsType); + deviceCredentials = createDeviceCredentials(device.getTenantId(), device.getId(), updateDeviceCertificateValue, updateDeviceCertificateHash, DeviceCredentialsType.X509_CERTIFICATE); } return getDeviceInfo(deviceCredentials); } else if (deviceProfile.getProvisionType() == DeviceProfileProvisionType.X509_CERTIFICATE_CHAIN && x509Configuration.isAllowCreateNewDevicesByX509Certificate()) { Device savedDevice = createDevice(deviceProfile.getTenantId(), deviceProfile.getId(), deviceName, deviceProfile.getName()); DeviceCredentials deviceCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(savedDevice.getTenantId(), savedDevice.getId()); - deviceCredentials = updateDeviceCredentials(savedDevice.getTenantId(), deviceCredentials, updateDeviceCertificateValue, updateDeviceCertificateHash, credentialsType); + deviceCredentials = updateDeviceCredentials(savedDevice.getTenantId(), deviceCredentials, updateDeviceCertificateValue, updateDeviceCertificateHash, DeviceCredentialsType.X509_CERTIFICATE); return getDeviceInfo(deviceCredentials); + } else { + log.info("Device doesn't exist and cannot be created due incorrect configuration for X509CertificateChainProvisionConfiguration"); } } } @@ -728,12 +726,16 @@ public class DefaultTransportApiService implements TransportApiService { return l != null ? l : 0; } - private String extractDeviceNameFromCNByRegEx(String commonName, String regex) { - Pattern pattern = Pattern.compile(regex); - Matcher matcher = pattern.matcher(commonName); - if (matcher.find()) { - return matcher.group(0); - } + private String extractDeviceNameFromCertificateCNByRegEx(String x509Value, String regex) { + try { + String commonName = SslUtil.parseCommonName(readCertFile(x509Value)); + log.trace("Extract CN [{}] by regex pattern [{}]", commonName, regex); + Pattern pattern = Pattern.compile(regex); + Matcher matcher = pattern.matcher(commonName); + if (matcher.find()) { + return matcher.group(0); + } + } catch (Exception ignored) {} return null; } @@ -748,23 +750,26 @@ public class DefaultTransportApiService implements TransportApiService { return chain; } - private X509Certificate readCertFile(String fileContent) throws Exception { + private X509Certificate readCertFile(String fileContent) { X509Certificate certificate = null; - if (fileContent != null && !fileContent.trim().isEmpty()) { - fileContent = fileContent.replace("-----BEGIN CERTIFICATE-----", "") - .replace("-----END CERTIFICATE-----", "") - .replaceAll("\\s", ""); - byte[] decoded = Base64.decodeBase64(fileContent); - CertificateFactory certFactory = CertificateFactory.getInstance("X.509"); - try (InputStream inStream = new ByteArrayInputStream(decoded)) { - certificate = (X509Certificate) certFactory.generateCertificate(inStream); + try { + if (fileContent != null && !fileContent.trim().isEmpty()) { + fileContent = fileContent.replace("-----BEGIN CERTIFICATE-----", "") + .replace("-----END CERTIFICATE-----", "") + .replaceAll("\\s", ""); + byte[] decoded = Base64.decodeBase64(fileContent); + CertificateFactory certFactory = CertificateFactory.getInstance("X.509"); + try (InputStream inStream = new ByteArrayInputStream(decoded)) { + certificate = (X509Certificate) certFactory.generateCertificate(inStream); + } } - } + } catch (Exception ignored) {} return certificate; } private DeviceCredentials updateDeviceCredentials(TenantId tenantId, DeviceCredentials deviceCredentials, String certificateValue, String certificateHash, DeviceCredentialsType credentialsType) { + log.trace("Updating device credentials [{}] with certificate id [{}]", deviceCredentials, certificateHash); deviceCredentials.setCredentialsId(certificateHash); deviceCredentials.setCredentialsValue(certificateValue); deviceCredentials.setCredentialsType(credentialsType); @@ -773,6 +778,7 @@ public class DefaultTransportApiService implements TransportApiService { private DeviceCredentials createDeviceCredentials(TenantId tenantId, DeviceId deviceId, String certificateValue, String certificateHash, DeviceCredentialsType credentialsType) { + log.trace("Creating new deviceCredentials for device [{}] with certificate id [{}]", deviceId, certificateHash); DeviceCredentials createDevCredentials = new DeviceCredentials(); createDevCredentials.setDeviceId(deviceId); createDevCredentials.setCredentialsType(credentialsType); @@ -782,11 +788,14 @@ public class DefaultTransportApiService implements TransportApiService { } private Device createDevice(TenantId tenantId, DeviceProfileId deviceProfileId, String deviceName, String type) { + log.trace("Creating new device for deviceProfile [{}] with device name [{}]", deviceProfileId, deviceName); Device device = new Device(); device.setTenantId(tenantId); device.setDeviceProfileId(deviceProfileId); device.setName(deviceName); device.setType(type); - return deviceService.saveDevice(device); + device = deviceService.saveDevice(device); + tbClusterService.onDeviceUpdated(device, null); + return device; } } diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index d96e50ca7f..c7102f40a0 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -735,7 +735,7 @@ transport: # MQTT SSL configuration ssl: # Enable/disable SSL support - enabled: "${MQTT_SSL_ENABLED:false}" + enabled: "${MQTT_SSL_ENABLED:true}" # MQTT SSL bind address bind_address: "${MQTT_SSL_BIND_ADDRESS:0.0.0.0}" # MQTT SSL bind port @@ -749,11 +749,11 @@ transport: # PEM server credentials pem: # Path to the server certificate file (holds server certificate or certificate chain, may include server private key) - cert_file: "${MQTT_SSL_PEM_CERT:mqttserver.pem}" + cert_file: "${MQTT_SSL_PEM_CERT:/home/developer/server.pem}" # Path to the server certificate private key file. Optional by default. Required if the private key is not present in server certificate file; - key_file: "${MQTT_SSL_PEM_KEY:mqttserver_key.pem}" + key_file: "${MQTT_SSL_PEM_KEY:/home/developer/server_key.pem}" # Server certificate private key password (optional) - key_password: "${MQTT_SSL_PEM_KEY_PASSWORD:server_key_password}" + key_password: "${MQTT_SSL_PEM_KEY_PASSWORD:}" # Keystore server credentials keystore: # Type of the key store (JKS or PKCS12) diff --git a/application/src/test/java/org/thingsboard/server/service/transport/DefaultTransportApiServiceTest.java b/application/src/test/java/org/thingsboard/server/service/transport/DefaultTransportApiServiceTest.java index 0309bec131..e98513826a 100644 --- a/application/src/test/java/org/thingsboard/server/service/transport/DefaultTransportApiServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/transport/DefaultTransportApiServiceTest.java @@ -96,33 +96,34 @@ public class DefaultTransportApiServiceTest { private final String deviceCertificate = "-----BEGIN CERTIFICATE-----Device certificate value-----END CERTIFICATE-----"; private final String deviceProfileCertificate = "-----BEGIN CERTIFICATE-----Device profile certificate value-----END CERTIFICATE-----"; + private final String[] chain = new String[]{deviceCertificate, deviceProfileCertificate}; @Test public void validateExistingDeviceX509Certificate() { var device = createDevice(); when(deviceService.findDeviceByIdAsync(any(), any())).thenReturn(Futures.immediateFuture(device)); - var deviceCredentials = createDeviceCredentials(deviceCertificate, device.getId()); + var deviceCredentials = createDeviceCredentials(chain[0], device.getId()); when(deviceCredentialsService.findDeviceCredentialsByCredentialsId(any())).thenReturn(deviceCredentials); - service.validateOrCreateDeviceX509Certificate(deviceCertificate, DeviceCredentialsType.X509_CERTIFICATE); + service.validateOrCreateDeviceX509Certificate(chain[0]); verify(deviceCredentialsService, times(1)).findDeviceCredentialsByCredentialsId(any()); } @Test public void updateExistingDeviceX509Certificate() { - var deviceProfile = createDeviceProfile(deviceProfileCertificate); + var deviceProfile = createDeviceProfile(chain[1]); when(deviceProfileService.findDeviceProfileByCertificateHash(any())).thenReturn(deviceProfile); var device = createDevice(); when(deviceService.findDeviceByTenantIdAndName(any(), any())).thenReturn(device); when(deviceService.findDeviceByIdAsync(any(), any())).thenReturn(Futures.immediateFuture(device)); - var deviceCredentials = createDeviceCredentials(deviceCertificate, device.getId()); + var deviceCredentials = createDeviceCredentials(chain[0], device.getId()); when(deviceCredentialsService.findDeviceCredentialsByDeviceId(any(), any())).thenReturn(deviceCredentials); when(deviceCredentialsService.updateDeviceCredentials(any(), any())).thenReturn(deviceCredentials); - service.validateOrCreateDeviceX509Certificate(deviceProfileCertificate, DeviceCredentialsType.X509_CERTIFICATE); + service.validateOrCreateDeviceX509Certificate(chain[1]); verify(deviceProfileService, times(1)).findDeviceProfileByCertificateHash(any()); verify(deviceService, times(1)).findDeviceByTenantIdAndName(any(), any()); verify(deviceCredentialsService, times(1)).findDeviceCredentialsByDeviceId(any(), any()); @@ -131,18 +132,18 @@ public class DefaultTransportApiServiceTest { @Test public void createDeviceByX509Provision() { - var deviceProfile = createDeviceProfile(deviceProfileCertificate); + var deviceProfile = createDeviceProfile(chain[1]); when(deviceProfileService.findDeviceProfileByCertificateHash(any())).thenReturn(deviceProfile); var device = createDevice(); when(deviceService.saveDevice(any())).thenReturn(device); when(deviceService.findDeviceByIdAsync(any(), any())).thenReturn(Futures.immediateFuture(device)); - var deviceCredentials = createDeviceCredentials(deviceCertificate, device.getId()); + var deviceCredentials = createDeviceCredentials(chain[0], device.getId()); when(deviceCredentialsService.findDeviceCredentialsByDeviceId(any(), any())).thenReturn(deviceCredentials); when(deviceCredentialsService.updateDeviceCredentials(any(), any())).thenReturn(deviceCredentials); - service.validateOrCreateDeviceX509Certificate(deviceProfileCertificate, DeviceCredentialsType.X509_CERTIFICATE); + service.validateOrCreateDeviceX509Certificate(chain[1]); verify(deviceProfileService, times(1)).findDeviceProfileByCertificateHash(any()); verify(deviceService, times(1)).findDeviceByTenantIdAndName(any(), any()); verify(deviceCredentialsService, times(1)).findDeviceCredentialsByDeviceId(any(), any()); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/X509CertificateChainProvisionConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/X509CertificateChainProvisionConfiguration.java index 9257053b4f..34fee34ab3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/X509CertificateChainProvisionConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/X509CertificateChainProvisionConfiguration.java @@ -24,11 +24,16 @@ import org.thingsboard.server.common.data.DeviceProfileProvisionType; @NoArgsConstructor public class X509CertificateChainProvisionConfiguration implements DeviceProfileProvisionConfiguration { - private String provisionDeviceSecret; private String certificateValue; private String certificateRegExPattern; private boolean allowCreateNewDevicesByX509Certificate; + @Override + public String getProvisionDeviceSecret() { + // ignore device secret for this strategy + return null; + } + @Override public DeviceProfileProvisionType getType() { return DeviceProfileProvisionType.X509_CERTIFICATE_CHAIN; diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java index 6025a44cb4..4b6e15292a 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java @@ -164,8 +164,14 @@ public class MqttSslHandlerProvider { } }); latch.await(10, TimeUnit.SECONDS); - if (!clientDeviceCertValue.equals(credentialsBodyHolder[0])) { - throw new CertificateException("Invalid Certificate's chain. Cannot find such device credentials."); + if (chain.length == 1) { + if (!clientDeviceCertValue.equals(credentialsBodyHolder[0])) { + throw new CertificateException("Invalid Device Certificate"); + } + } else { + if (!clientDeviceCertValue.equals(credentialsBodyHolder[0])) { + throw new CertificateException("Invalid Chain of X509 Certificates"); + } } } catch (Exception e) { log.error(e.getMessage(), e); diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java index 51ec881490..da04d50f50 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java @@ -58,6 +58,7 @@ public class SslUtil { try { x500name = new JcaX509CertificateHolder(certificate).getSubject(); } catch (CertificateEncodingException e) { + log.warn("Cannot parse CN from device certificate"); throw new RuntimeException(e); } RDN cn = x500name.getRDNs(BCStyle.CN)[0]; diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java index c4ee12395f..3b4be7aef4 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java @@ -135,12 +135,11 @@ public class DeviceProfileServiceImpl extends AbstractCachedEntityService Date: Wed, 29 Mar 2023 13:26:14 +0300 Subject: [PATCH 029/100] Fix yml extra-config --- application/src/main/resources/thingsboard.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index c7102f40a0..d96e50ca7f 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -735,7 +735,7 @@ transport: # MQTT SSL configuration ssl: # Enable/disable SSL support - enabled: "${MQTT_SSL_ENABLED:true}" + enabled: "${MQTT_SSL_ENABLED:false}" # MQTT SSL bind address bind_address: "${MQTT_SSL_BIND_ADDRESS:0.0.0.0}" # MQTT SSL bind port @@ -749,11 +749,11 @@ transport: # PEM server credentials pem: # Path to the server certificate file (holds server certificate or certificate chain, may include server private key) - cert_file: "${MQTT_SSL_PEM_CERT:/home/developer/server.pem}" + cert_file: "${MQTT_SSL_PEM_CERT:mqttserver.pem}" # Path to the server certificate private key file. Optional by default. Required if the private key is not present in server certificate file; - key_file: "${MQTT_SSL_PEM_KEY:/home/developer/server_key.pem}" + key_file: "${MQTT_SSL_PEM_KEY:mqttserver_key.pem}" # Server certificate private key password (optional) - key_password: "${MQTT_SSL_PEM_KEY_PASSWORD:}" + key_password: "${MQTT_SSL_PEM_KEY_PASSWORD:server_key_password}" # Keystore server credentials keystore: # Type of the key store (JKS or PKCS12) From e842c3da080cc8ff3af08ca1a6ff330a93f2d243 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 29 Mar 2023 18:53:36 +0300 Subject: [PATCH 030/100] Rewrite test using self-signed test x509 certificate --- .../transport/DefaultTransportApiService.java | 6 ++- .../DefaultTransportApiServiceTest.java | 52 ++++++++++++++++--- .../resources/mqtt/x509ChainProvision.pem | 28 ++++++++++ 3 files changed, 77 insertions(+), 9 deletions(-) create mode 100644 application/src/test/resources/mqtt/x509ChainProvision.pem diff --git a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java index ca886cf49b..f1ff95d3f2 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java @@ -252,15 +252,17 @@ public class DefaultTransportApiService implements TransportApiService { } DeviceProfile deviceProfile = deviceProfileService.findDeviceProfileByCertificateHash(certificateHash); if (deviceProfile != null) { - X509CertificateChainProvisionConfiguration x509Configuration = new X509CertificateChainProvisionConfiguration(); + X509CertificateChainProvisionConfiguration x509Configuration; if (deviceProfile.getProfileData().getProvisionConfiguration() instanceof X509CertificateChainProvisionConfiguration) { x509Configuration = (X509CertificateChainProvisionConfiguration) deviceProfile.getProfileData().getProvisionConfiguration(); } else { log.warn("Device Profile provision configuration is not X509CertificateChainProvisionConfiguration"); + return getEmptyTransportApiResponseFuture(); } String deviceName = extractDeviceNameFromCertificateCNByRegEx(chain.get(0), x509Configuration.getCertificateRegExPattern()); if (deviceName == null) { - log.warn("Device name has to be extract by regex from CN."); + log.warn("Cannot extract device name from device's CN using regex [{}]", x509Configuration.getCertificateRegExPattern()); + return getEmptyTransportApiResponseFuture(); } Device device = deviceService.findDeviceByTenantIdAndName(deviceProfile.getTenantId(), deviceName); String updateDeviceCertificateValue = chain.get(0); diff --git a/application/src/test/java/org/thingsboard/server/service/transport/DefaultTransportApiServiceTest.java b/application/src/test/java/org/thingsboard/server/service/transport/DefaultTransportApiServiceTest.java index e98513826a..a31d867242 100644 --- a/application/src/test/java/org/thingsboard/server/service/transport/DefaultTransportApiServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/transport/DefaultTransportApiServiceTest.java @@ -18,6 +18,7 @@ package org.thingsboard.server.service.transport; import com.google.common.util.concurrent.Futures; import lombok.extern.slf4j.Slf4j; +import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.boot.test.mock.mockito.MockBean; @@ -49,7 +50,14 @@ import org.thingsboard.server.service.executors.DbCallbackExecutorService; import org.thingsboard.server.service.profile.TbDeviceProfileCache; import org.thingsboard.server.service.resource.TbResourceService; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.times; @@ -94,9 +102,20 @@ public class DefaultTransportApiServiceTest { @SpyBean DefaultTransportApiService service; - private final String deviceCertificate = "-----BEGIN CERTIFICATE-----Device certificate value-----END CERTIFICATE-----"; - private final String deviceProfileCertificate = "-----BEGIN CERTIFICATE-----Device profile certificate value-----END CERTIFICATE-----"; - private final String[] chain = new String[]{deviceCertificate, deviceProfileCertificate}; + private String certificateChain; + private String[] chain; + + @Before + public void setUp() { + String filePath = "src/test/resources/mqtt/x509ChainProvisionTest.pem"; + try { + certificateChain = Files.readString(Paths.get(filePath)); + certificateChain = certTrimNewLinesForChainInDeviceProfile(certificateChain); + chain = fetchLeafCertificateFromChain(certificateChain); + } catch (IOException e) { + throw new RuntimeException(e); + } + } @Test public void validateExistingDeviceX509Certificate() { @@ -106,7 +125,7 @@ public class DefaultTransportApiServiceTest { var deviceCredentials = createDeviceCredentials(chain[0], device.getId()); when(deviceCredentialsService.findDeviceCredentialsByCredentialsId(any())).thenReturn(deviceCredentials); - service.validateOrCreateDeviceX509Certificate(chain[0]); + service.validateOrCreateDeviceX509Certificate(certificateChain); verify(deviceCredentialsService, times(1)).findDeviceCredentialsByCredentialsId(any()); } @@ -123,7 +142,7 @@ public class DefaultTransportApiServiceTest { when(deviceCredentialsService.findDeviceCredentialsByDeviceId(any(), any())).thenReturn(deviceCredentials); when(deviceCredentialsService.updateDeviceCredentials(any(), any())).thenReturn(deviceCredentials); - service.validateOrCreateDeviceX509Certificate(chain[1]); + service.validateOrCreateDeviceX509Certificate(certificateChain); verify(deviceProfileService, times(1)).findDeviceProfileByCertificateHash(any()); verify(deviceService, times(1)).findDeviceByTenantIdAndName(any(), any()); verify(deviceCredentialsService, times(1)).findDeviceCredentialsByDeviceId(any(), any()); @@ -143,7 +162,7 @@ public class DefaultTransportApiServiceTest { when(deviceCredentialsService.findDeviceCredentialsByDeviceId(any(), any())).thenReturn(deviceCredentials); when(deviceCredentialsService.updateDeviceCredentials(any(), any())).thenReturn(deviceCredentials); - service.validateOrCreateDeviceX509Certificate(chain[1]); + service.validateOrCreateDeviceX509Certificate(certificateChain); verify(deviceProfileService, times(1)).findDeviceProfileByCertificateHash(any()); verify(deviceService, times(1)).findDeviceByTenantIdAndName(any(), any()); verify(deviceCredentialsService, times(1)).findDeviceCredentialsByDeviceId(any(), any()); @@ -164,7 +183,7 @@ public class DefaultTransportApiServiceTest { DeviceProfileData deviceProfileData = new DeviceProfileData(); X509CertificateChainProvisionConfiguration provision = new X509CertificateChainProvisionConfiguration(); provision.setCertificateValue(certificateValue); - provision.setCertificateRegExPattern("^$"); + provision.setCertificateRegExPattern("([^@]+)"); provision.setAllowCreateNewDevicesByX509Certificate(true); deviceProfileData.setProvisionConfiguration(provision); deviceProfile.setProfileData(deviceProfileData); @@ -178,4 +197,23 @@ public class DefaultTransportApiServiceTest { device.setId(new DeviceId(UUID.randomUUID())); return device; } + + public static String certTrimNewLinesForChainInDeviceProfile(String input) { + return input.replaceAll("\n", "") + .replaceAll("\r", "") + .replaceAll("-----BEGIN CERTIFICATE-----", "-----BEGIN CERTIFICATE-----\n") + .replaceAll("-----END CERTIFICATE-----", "\n-----END CERTIFICATE-----\n") + .trim(); + } + + private String[] fetchLeafCertificateFromChain(String value) { + List chain = new ArrayList<>(); + String regex = "-----BEGIN CERTIFICATE-----\\s*.*?\\s*-----END CERTIFICATE-----"; + Pattern pattern = Pattern.compile(regex); + Matcher matcher = pattern.matcher(value); + while (matcher.find()) { + chain.add(matcher.group(0)); + } + return chain.toArray(new String[0]); + } } diff --git a/application/src/test/resources/mqtt/x509ChainProvision.pem b/application/src/test/resources/mqtt/x509ChainProvision.pem new file mode 100644 index 0000000000..b2ec300f78 --- /dev/null +++ b/application/src/test/resources/mqtt/x509ChainProvision.pem @@ -0,0 +1,28 @@ +-----BEGIN CERTIFICATE----- +MIICMTCCAdegAwIBAgIUI9dBuwN6pTtK6uZ03rkiCwV4wEYwCgYIKoZIzj0EAwIw +bjELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE5ldyBZb3JrMRowGAYDVQQKDBFUaGlu +Z3NCb2FyZCwgSW5jLjEwMC4GA1UEAwwnZGV2aWNlQ2VydGlmaWNhdGVAWDUwOVBy +b3Zpc2lvblN0cmF0ZWd5MB4XDTIzMDMyOTE0NTYxN1oXDTI0MDMyODE0NTYxN1ow +bjELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE5ldyBZb3JrMRowGAYDVQQKDBFUaGlu +Z3NCb2FyZCwgSW5jLjEwMC4GA1UEAwwnZGV2aWNlQ2VydGlmaWNhdGVAWDUwOVBy +b3Zpc2lvblN0cmF0ZWd5MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE9Zo791qK +QiGNBm11r4ZGxh+w+ossZL3xc46ufq5QckQHP7zkD2XDAcmP5GvdkM1sBFN9AWaC +kQfNnWmfERsOOKNTMFEwHQYDVR0OBBYEFFFc5uyCyglQoZiKhzXzMcQ3BKORMB8G +A1UdIwQYMBaAFFFc5uyCyglQoZiKhzXzMcQ3BKORMA8GA1UdEwEB/wQFMAMBAf8w +CgYIKoZIzj0EAwIDSAAwRQIhANbA9CuhoOifZMMmqkpuld+65CR+ItKdXeRAhLMZ +uccuAiB0FSQB34zMutXrZj1g8Gl5OkE7YryFHbei1z0SveHR8g== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICMTCCAdegAwIBAgIUUEKxS9hTz4l+oLUMF0LV6TC/gCIwCgYIKoZIzj0EAwIw +bjELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE5ldyBZb3JrMRowGAYDVQQKDBFUaGlu +Z3NCb2FyZCwgSW5jLjEwMC4GA1UEAwwnZGV2aWNlUHJvZmlsZUNlcnRAWDUwOVBy +b3Zpc2lvblN0cmF0ZWd5MB4XDTIzMDMyOTE0NTczNloXDTI0MDMyODE0NTczNlow +bjELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE5ldyBZb3JrMRowGAYDVQQKDBFUaGlu +Z3NCb2FyZCwgSW5jLjEwMC4GA1UEAwwnZGV2aWNlUHJvZmlsZUNlcnRAWDUwOVBy +b3Zpc2lvblN0cmF0ZWd5MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAECMlWO72k +rDoUL9FQjUmSCetkhaEGJUfQkdSfkLSNa0GyAEIMbfmzI4zITeapunu4rGet3EMy +LydQzuQanBicp6NTMFEwHQYDVR0OBBYEFHpZ78tPnztNii4Da/yCw6mhEIL3MB8G +A1UdIwQYMBaAFHpZ78tPnztNii4Da/yCw6mhEIL3MA8GA1UdEwEB/wQFMAMBAf8w +CgYIKoZIzj0EAwIDSAAwRQIgJ7qyMFqNcwSYkH6o+UlQXzLWfwZbNjVk+aR7foAZ +NGsCIQDsd7v3WQIGHiArfZeDs1DLEDuV/2h6L+ZNoGNhEKL+1A== +-----END CERTIFICATE----- From 07e00e487c666ce000d1144a0c2b14d2e893ba1c Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Thu, 30 Mar 2023 09:45:38 +0300 Subject: [PATCH 031/100] Rename file for x509 test chain --- .../mqtt/{x509ChainProvision.pem => x509ChainProvisionTest.pem} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename application/src/test/resources/mqtt/{x509ChainProvision.pem => x509ChainProvisionTest.pem} (100%) diff --git a/application/src/test/resources/mqtt/x509ChainProvision.pem b/application/src/test/resources/mqtt/x509ChainProvisionTest.pem similarity index 100% rename from application/src/test/resources/mqtt/x509ChainProvision.pem rename to application/src/test/resources/mqtt/x509ChainProvisionTest.pem From 99bc639cbe33eff4d26449584913a113c3326fe3 Mon Sep 17 00:00:00 2001 From: deaflynx Date: Thu, 30 Mar 2023 16:03:39 +0300 Subject: [PATCH 032/100] Device profile -> Device provisioning -> X509 feature: - change tb-hints Info and Regex examples as tb-help-popup - fix locales for hint- --- ...ile-provision-configuration.component.html | 22 ++++++++++++------- .../en_US/device-profile/x509-chain-hint.md | 18 +++++++++++++++ .../x509-chain-regex-examples.md | 15 +++++++++++++ .../assets/locale/locale.constant-en_US.json | 7 +++--- 4 files changed, 50 insertions(+), 12 deletions(-) create mode 100644 ui-ngx/src/assets/help/en_US/device-profile/x509-chain-hint.md create mode 100644 ui-ngx/src/assets/help/en_US/device-profile/x509-chain-regex-examples.md diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html index 3b2b92ce98..6c5da8b531 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html @@ -35,14 +35,18 @@ -
- - {{ readMore ? (' ' + ('action.less' | translate)) : ('action.more' | translate) }} +
+ +
{{ 'device-profile.provision-strategy-x509.allow-create-new-devices' | translate }} -
+
device-profile.provision-strategy-x509.certificate-value @@ -53,14 +57,16 @@ device-profile.provision-strategy-x509.cn-regex-variable +
{{ 'device-profile.provision-strategy-x509.cn-regex-variable-required' | translate }} - device-profile.provision-strategy-x509.hint-cn-regex-variable + device-profile.provision-strategy-x509.cn-regex-variable-hint
-
-
diff --git a/ui-ngx/src/assets/help/en_US/device-profile/x509-chain-hint.md b/ui-ngx/src/assets/help/en_US/device-profile/x509-chain-hint.md new file mode 100644 index 0000000000..f1aa10f921 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/device-profile/x509-chain-hint.md @@ -0,0 +1,18 @@ +##### X509 Certificate Chain info + +X.509 certificates strategy is used to provision devices by client certificates in two-way TLS communication. + +This strategy can: +* check for pre-provisioned devices +* update X.509 device credentials +* create new devices + +The user uploads X.509 certificate to the device profile and sets a regular expression to fetch the device name from *Common Name (CN)*. + +Client certificates must be signed by X.509 certificate, pre-uploaded for this device profile to provision devices by the strategy. + +The client must establish a TLS connection using the entire chain of certificates (this chain must include device profile X.509 certificate on the last level). + +If a device already exists with outdated X.509 credentials, this strategy automatically updates it with the device certificate's credentials from the chain. + +Important: Uploaded certificates should be neither root nor intermediate certificates that are provided by a well-known *Certificate Authority (CA)*. diff --git a/ui-ngx/src/assets/help/en_US/device-profile/x509-chain-regex-examples.md b/ui-ngx/src/assets/help/en_US/device-profile/x509-chain-regex-examples.md new file mode 100644 index 0000000000..bb8a069473 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/device-profile/x509-chain-regex-examples.md @@ -0,0 +1,15 @@ +#### Examples of RegEx usage + +* **Pattern:** .* - matches any character (until line terminators) +
**CN sample:** DeviceName\nAdditionalInfo +
**Pattern matches:** DeviceName + +* **Pattern:** ^([^@]+) - matches any string that starts with one or more characters that are not the @ symbol (@ could be replaced by any other symbol) +
**CN sample:** DeviceName@AdditionalInfo +
**Pattern matches:** DeviceName + +* **Pattern:** [\w]*$ (equivalent to [a-zA-Z0-9_]\*$) - matches zero or more occurences of any word character (letter, digit or underscore) at the end of the string +
**CN sample:** AdditionalInfo2110#DeviceName_01 +
**Pattern matches:** DeviceName_01 + +**Note:** Client will get error response in case regex is failed to match. 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 ec9a151ede..de26259c00 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -1505,15 +1505,14 @@ "provision-secret-copied-message": "Provision secret has been copied to clipboard", "provision-strategy-x509": { "certificate-chain": "X509 Certificates Chain", - "hint-certificate-chain": "X.509 certificates strategy is used to provision devices by client certificates in two-way TLS communication. This strategy can check for pre-provisioned devices, update X.509 device credentials, or create new devices. The user uploads X.509 certificate to the device profile and sets a regular expression to fetch the device name from Common Name (CN).

Client certificates must be signed by X.509 certificate, pre-uploaded for this device profile to provision devices by the strategy. The client must establish a TLS connection using the entire chain of certificates (this chain must include device profile X.509 certificate on the last level). If a device already exists with outdated X.509 credentials, this strategy automatically updates it with the device certificate's credentials from the chain.

Important: Uploaded certificates should be neither root nor intermediate certificates that are provided by a well-known Certificate Authority (CA).", + "certificate-chain-hint": "X.509 certificates strategy is used to provision devices by client certificates in two-way TLS communication.", "allow-create-new-devices": "Create new devices", - "hint-allow-create-new-devices": "Hint: if selected new devices will be created and client certificate will be used as device credentials.", + "allow-create-new-devices-hint": "Hint: if selected new devices will be created and client certificate will be used as device credentials.", "certificate-value": "Certificate in PEM format", "certificate-value-required": "Certificate in PEM format is required", "cn-regex-variable": "CN Regular Expression variable", "cn-regex-variable-required": "CN Regular Expression variable is required", - "hint-cn-regex-variable": "Required to fetch device name from device's X509 certificate's common name.", - "regex-examples": "Examples of RegEx usage:
  1. Pattern: .* - matches any character (until line terminators)
    CN sample: DeviceName\\nAdditionalInfo
    Pattern matches: DeviceName

  2. Pattern: ^([^@]+) - matches any string that starts with one or more characters that are not the @ symbol (@ could be replaced by any other symbol)
    CN sample: DeviceName@AdditionalInfo
    Pattern matches: DeviceName

  3. Pattern: [\\w]*$ (equivalent to [a-zA-Z0-9_]*$) - matches zero or more occurences of any word character (letter, digit or underscore) at the end of the string
    CN sample: AdditionalInfo2110#DeviceName_01
    Pattern matches: DeviceName_01
Note: Client will get error response in case regex is failed to match." + "cn-regex-variable-hint": "Required to fetch device name from device's X509 certificate's common name." }, "condition": "Condition", "condition-type": "Condition type", From 722b6f992cc5d61d979cee219ed93aedf8d1a71a Mon Sep 17 00:00:00 2001 From: deaflynx Date: Thu, 30 Mar 2023 16:37:47 +0300 Subject: [PATCH 033/100] Device profile -> Device provision stratetgy X509 minor changes in tb-help-popup html --- ...ce-profile-provision-configuration.component.html | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html index 6c5da8b531..afbaa924ac 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html @@ -37,11 +37,11 @@
-
+ trigger-text="{{ 'action.more' | translate }}">
{{ 'device-profile.provision-strategy-x509.allow-create-new-devices' | translate }} @@ -57,11 +57,11 @@ device-profile.provision-strategy-x509.cn-regex-variable -
+ [tb-help-popup-style]="{maxWidth: '820px'}"> {{ 'device-profile.provision-strategy-x509.cn-regex-variable-required' | translate }} From 98528f73eba0d81c44320223cd81483f8f1b30e6 Mon Sep 17 00:00:00 2001 From: deaflynx Date: Thu, 30 Mar 2023 16:54:06 +0300 Subject: [PATCH 034/100] Remove 'Hint:' in locale allow-create-new-devices-hint --- ui-ngx/src/assets/locale/locale.constant-en_US.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 de26259c00..6f9653ca96 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -1507,7 +1507,7 @@ "certificate-chain": "X509 Certificates Chain", "certificate-chain-hint": "X.509 certificates strategy is used to provision devices by client certificates in two-way TLS communication.", "allow-create-new-devices": "Create new devices", - "allow-create-new-devices-hint": "Hint: if selected new devices will be created and client certificate will be used as device credentials.", + "allow-create-new-devices-hint": "If selected new devices will be created and client certificate will be used as device credentials.", "certificate-value": "Certificate in PEM format", "certificate-value-required": "Certificate in PEM format is required", "cn-regex-variable": "CN Regular Expression variable", From bcfab48a79c78e538406dc1710108bf844f2c37c Mon Sep 17 00:00:00 2001 From: kalytka Date: Fri, 31 Mar 2023 11:30:33 +0300 Subject: [PATCH 035/100] Added label parsing in select entity dialog --- .../select-entity-dialog.component.html | 2 +- .../dialogs/select-entity-dialog.component.ts | 24 ++++++++++++++++++- .../components/widget/lib/maps/leaflet-map.ts | 22 ++++++++++++++++- 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.html index a2169cc129..fd0013236d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.html @@ -31,7 +31,7 @@ entity.entity - {{ entity.entityName }} + {{ parseName(entity) }}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.ts index 9822f6dca9..f7e94dd390 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.ts @@ -22,9 +22,18 @@ import { Router } from '@angular/router'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { FormattedData } from '@shared/models/widget.models'; +import { GenericFunction } from '@home/components/widget/lib/maps/map-models'; +import { fillDataPattern, processDataPattern, safeExecute } from '@core/utils'; +import { parseWithTranslation } from '@home/components/widget/lib/maps/common-maps-utils'; export interface SelectEntityDialogData { entities: FormattedData[]; + labelSettings: { + showLabel: boolean; + useLabelFunction: boolean; + parsedLabelFunction: GenericFunction; + label: string; + }; } @Component({ @@ -33,7 +42,6 @@ export interface SelectEntityDialogData { styleUrls: ['./select-entity-dialog.component.scss'] }) export class SelectEntityDialogComponent extends DialogComponent { - selectEntityFormGroup: UntypedFormGroup; constructor(protected store: Store, @@ -50,6 +58,20 @@ export class SelectEntityDialogComponent extends DialogComponent { let entities; + let labelSettings; switch (shapes) { case 'Polygon': case 'Rectangle': entities = this.datasources.filter(pData => !this.isValidPolygonPosition(pData)); + labelSettings = { + showLabel: this.options.showPolygonLabel, + useLabelFunction: this.options.usePolygonLabelFunction, + parsedLabelFunction: this.options.parsedPolygonLabelFunction, + label: this.options.polygonLabel + }; break; case 'Marker': entities = this.datasources.filter(mData => !this.extractPosition(mData)); + labelSettings = { + showLabel: this.options.showLabel, + useLabelFunction: this.options.useLabelFunction, + parsedLabelFunction: this.options.parsedLabelFunction, + label: this.options.label + }; break; case 'Circle': entities = this.datasources.filter(mData => !this.isValidCircle(mData)); + labelSettings = { + showLabel: this.options.showCircleLabel, + useLabelFunction: this.options.useCircleLabelFunction, + parsedLabelFunction: this.options.parsedCircleLabelFunction, + label: this.options.circleLabel + }; break; default: return of(null); @@ -205,7 +224,8 @@ export default abstract class LeafletMap { disableClose: true, panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], data: { - entities + entities, + labelSettings } }).afterClosed(); } From 4022f4a8c36fe94773c2278d340a6b1a2fb77729 Mon Sep 17 00:00:00 2001 From: Artem Dzhereleiko Date: Fri, 31 Mar 2023 11:40:04 +0300 Subject: [PATCH 036/100] UI: color-picker --- .../color-picker-input.component.html | 25 ++++++++++++++++--- .../color-picker-input.component.ts | 16 ++++++++++++ .../color-picker/color-picker.component.html | 19 ++++++++++++++ .../color-picker/color-picker.component.scss | 15 +++++++++++ .../color-picker/color-picker.component.ts | 16 ++++++++++++ .../dialog/color-picker-dialog.component.html | 2 +- 6 files changed, 88 insertions(+), 5 deletions(-) diff --git a/ui-ngx/src/app/shared/components/color-picker/color-picker-input.component.html b/ui-ngx/src/app/shared/components/color-picker/color-picker-input.component.html index 564f1d64f2..c135369793 100644 --- a/ui-ngx/src/app/shared/components/color-picker/color-picker-input.component.html +++ b/ui-ngx/src/app/shared/components/color-picker/color-picker-input.component.html @@ -1,18 +1,35 @@ +
- + - + - +
- +
diff --git a/ui-ngx/src/app/shared/components/color-picker/color-picker-input.component.ts b/ui-ngx/src/app/shared/components/color-picker/color-picker-input.component.ts index 22ce2c0849..445b65a969 100644 --- a/ui-ngx/src/app/shared/components/color-picker/color-picker-input.component.ts +++ b/ui-ngx/src/app/shared/components/color-picker/color-picker-input.component.ts @@ -1,3 +1,19 @@ +/// +/// Copyright © 2016-2023 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, diff --git a/ui-ngx/src/app/shared/components/color-picker/color-picker.component.html b/ui-ngx/src/app/shared/components/color-picker/color-picker.component.html index dba4605736..4751d498f2 100644 --- a/ui-ngx/src/app/shared/components/color-picker/color-picker.component.html +++ b/ui-ngx/src/app/shared/components/color-picker/color-picker.component.html @@ -1,3 +1,20 @@ +
@@ -11,6 +28,7 @@
+
@@ -32,6 +50,7 @@ +
diff --git a/ui-ngx/src/app/shared/components/color-picker/color-picker.component.scss b/ui-ngx/src/app/shared/components/color-picker/color-picker.component.scss index 39a0c7444c..24fd427576 100644 --- a/ui-ngx/src/app/shared/components/color-picker/color-picker.component.scss +++ b/ui-ngx/src/app/shared/components/color-picker/color-picker.component.scss @@ -1,3 +1,18 @@ +/** + * Copyright © 2016-2023 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: block; width: 100%; diff --git a/ui-ngx/src/app/shared/components/color-picker/color-picker.component.ts b/ui-ngx/src/app/shared/components/color-picker/color-picker.component.ts index e11d11f880..f2140e02a5 100644 --- a/ui-ngx/src/app/shared/components/color-picker/color-picker.component.ts +++ b/ui-ngx/src/app/shared/components/color-picker/color-picker.component.ts @@ -1,3 +1,19 @@ +/// +/// Copyright © 2016-2023 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, diff --git a/ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.html b/ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.html index 4ea0a5f8c5..8de970de65 100644 --- a/ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.html +++ b/ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.html @@ -16,7 +16,7 @@ --> -
+
From e2114f49c06fdef137dfecc6672132b8b8653b48 Mon Sep 17 00:00:00 2001 From: kalytka Date: Fri, 31 Mar 2023 13:14:20 +0300 Subject: [PATCH 037/100] Added parsing to the point tooltip, Update parsing HTML forms --- .../dialogs/select-entity-dialog.component.ts | 8 ++++++-- .../components/widget/lib/maps/leaflet-map.ts | 16 ++++++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.ts index f7e94dd390..9402da4c15 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.ts @@ -65,7 +65,9 @@ export class SelectEntityDialogComponent extends DialogComponent Date: Fri, 31 Mar 2023 14:57:10 +0300 Subject: [PATCH 038/100] Refactoring --- .../select-entity-dialog.component.html | 2 +- .../dialogs/select-entity-dialog.component.ts | 35 +++---------------- .../components/widget/lib/maps/leaflet-map.ts | 22 ++++++------ .../components/widget/lib/maps/maps-utils.ts | 29 ++++++++++++++- 4 files changed, 44 insertions(+), 44 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.html index fd0013236d..99740ac601 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.html @@ -31,7 +31,7 @@ entity.entity - {{ parseName(entity) }} + {{ entity.entityParseName }} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.ts index 9402da4c15..f2b3d09b5c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.ts @@ -20,20 +20,11 @@ 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 { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { FormattedData } from '@shared/models/widget.models'; -import { GenericFunction } from '@home/components/widget/lib/maps/map-models'; -import { fillDataPattern, processDataPattern, safeExecute } from '@core/utils'; -import { parseWithTranslation } from '@home/components/widget/lib/maps/common-maps-utils'; export interface SelectEntityDialogData { entities: FormattedData[]; - labelSettings: { - showLabel: boolean; - useLabelFunction: boolean; - parsedLabelFunction: GenericFunction; - label: string; - }; } @Component({ @@ -42,13 +33,13 @@ export interface SelectEntityDialogData { styleUrls: ['./select-entity-dialog.component.scss'] }) export class SelectEntityDialogComponent extends DialogComponent { - selectEntityFormGroup: UntypedFormGroup; + selectEntityFormGroup: FormGroup; constructor(protected store: Store, protected router: Router, @Inject(MAT_DIALOG_DATA) public data: SelectEntityDialogData, public dialogRef: MatDialogRef, - public fb: UntypedFormBuilder) { + public fb: FormBuilder) { super(store, router, dialogRef); this.selectEntityFormGroup = this.fb.group( @@ -58,25 +49,7 @@ export class SelectEntityDialogComponent extends DialogComponent, @@ -83,3 +86,27 @@ export function isJSON(data: string): boolean { return false; } } + +interface labelSettings { + showLabel: boolean; + useLabelFunction: boolean; + parsedLabelFunction: GenericFunction; + label: string; +} + +export function entitiesParseName(entities: FormattedData[], labelSettings: labelSettings): FormattedData[] { + for (const entity of entities) { + if (labelSettings?.showLabel) { + const pattern = labelSettings.useLabelFunction ? safeExecute(labelSettings.parsedLabelFunction, + [entity, entities, entity.dsIndex]) : labelSettings.label; + const markerLabelText = parseWithTranslation.prepareProcessPattern(pattern, true); + const replaceInfoLabelMarker = processDataPattern(pattern, entity); + const div = document.createElement('div'); + div.innerHTML = fillDataPattern(markerLabelText, replaceInfoLabelMarker, entity); + entity.entityParseName = div.textContent || div.innerText || ''; + } else { + entity.entityParseName = entity.entityName; + } + } + return entities; +} From 1d2368e98f0f6f299c9c233b8a8dcd911478916d Mon Sep 17 00:00:00 2001 From: kalytka Date: Mon, 3 Apr 2023 11:03:58 +0300 Subject: [PATCH 039/100] Refactoring --- .../app/modules/home/components/widget/lib/maps/maps-utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/maps-utils.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/maps-utils.ts index 585f0cd54a..4aad829eac 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/maps-utils.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/maps-utils.ts @@ -95,13 +95,13 @@ interface labelSettings { } export function entitiesParseName(entities: FormattedData[], labelSettings: labelSettings): FormattedData[] { + const div = document.createElement('div'); for (const entity of entities) { if (labelSettings?.showLabel) { const pattern = labelSettings.useLabelFunction ? safeExecute(labelSettings.parsedLabelFunction, [entity, entities, entity.dsIndex]) : labelSettings.label; const markerLabelText = parseWithTranslation.prepareProcessPattern(pattern, true); const replaceInfoLabelMarker = processDataPattern(pattern, entity); - const div = document.createElement('div'); div.innerHTML = fillDataPattern(markerLabelText, replaceInfoLabelMarker, entity); entity.entityParseName = div.textContent || div.innerText || ''; } else { From 27595dc1b7277c433dbe297103a350fa4b666f92 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Mon, 3 Apr 2023 11:17:36 +0300 Subject: [PATCH 040/100] UI: Disables the ability for the user to interact with the tooltip element --- ui-ngx/src/app/shared/shared.module.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts index 6108fdbeaf..d58498911d 100644 --- a/ui-ngx/src/app/shared/shared.module.ts +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -53,7 +53,7 @@ import { MatStepperModule } from '@angular/material/stepper'; import { MatTableModule } from '@angular/material/table'; import { MatTabsModule } from '@angular/material/tabs'; import { MatToolbarModule } from '@angular/material/toolbar'; -import { MatTooltipModule } from '@angular/material/tooltip'; +import { MAT_TOOLTIP_DEFAULT_OPTIONS, MatTooltipModule } from '@angular/material/tooltip'; import { MatListModule } from '@angular/material/list'; import { DatetimeAdapter, MatDatetimepickerModule, MatNativeDatetimeModule } from '@mat-datetimepicker/core'; import { NgxDaterangepickerMd } from 'ngx-daterangepicker-material'; @@ -220,6 +220,12 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) useValue: { hideSingleSelectionIndicator: true } + }, + { + provide: MAT_TOOLTIP_DEFAULT_OPTIONS, + useValue: { + disableTooltipInteractivity: true + } } ], declarations: [ From 193c1d030613b3b9ddb03a584fd20fe1d508e105 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Tue, 4 Apr 2023 12:37:24 +0300 Subject: [PATCH 041/100] UI: Fix disable time interval in timewindow panel --- .../app/shared/components/time/timeinterval.component.ts | 4 +--- .../shared/components/time/timewindow-panel.component.ts | 9 ++++----- 2 files changed, 5 insertions(+), 8 deletions(-) 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 9d7605c59d..dca556b221 100644 --- a/ui-ngx/src/app/shared/components/time/timeinterval.component.ts +++ b/ui-ngx/src/app/shared/components/time/timeinterval.component.ts @@ -64,8 +64,6 @@ export class TimeintervalComponent implements OnInit, ControlValueAccessor { @coerceBoolean() isEdit = false; - hideFlagValue = false; - @Input() @coerceBoolean() hideFlag = false; @@ -208,7 +206,7 @@ export class TimeintervalComponent implements OnInit, ControlValueAccessor { } onHideFlagChange() { - this.hideFlagChange.emit(this.hideFlagValue); + this.hideFlagChange.emit(this.hideFlag); } onTimeInputChange(type: string) { diff --git a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.ts b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.ts index 7b30759cdf..171a692e44 100644 --- a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.ts +++ b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.ts @@ -157,9 +157,9 @@ export class TimewindowPanelComponent extends PageComponent implements OnInit { disabled: hideTimezone }] }); - this.updateValidators(); - this.timewindowForm.get('aggregation.type').valueChanges.subscribe(() => { - this.updateValidators(); + this.updateValidators(this.timewindowForm.get('aggregation.type').value); + this.timewindowForm.get('aggregation.type').valueChanges.subscribe((aggregationType: AggregationType) => { + this.updateValidators(aggregationType); }); } @@ -172,8 +172,7 @@ export class TimewindowPanelComponent extends PageComponent implements OnInit { return limit; } - private updateValidators() { - const aggType = this.timewindowForm.get('aggregation.type').value; + private updateValidators(aggType: AggregationType) { if (aggType !== AggregationType.NONE) { this.timewindowForm.get('aggregation.limit').clearValidators(); } else { From b7b1ed6150197046e2a7ebdd6a70603a9d681406 Mon Sep 17 00:00:00 2001 From: Seraphym-Tuhai Date: Thu, 6 Apr 2023 20:43:40 +0300 Subject: [PATCH 042/100] fix test --- .../msa/ui/tests/ruleChainsSmoke/RuleChainEditMenuTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/tests/ruleChainsSmoke/RuleChainEditMenuTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/tests/ruleChainsSmoke/RuleChainEditMenuTest.java index 5b380221b6..3c65bc8f0a 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/tests/ruleChainsSmoke/RuleChainEditMenuTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/tests/ruleChainsSmoke/RuleChainEditMenuTest.java @@ -151,12 +151,12 @@ public class RuleChainEditMenuTest extends AbstractDriverBaseTest { ruleChainsPage.editPencilBtn().click(); ruleChainsPage.debugCheckboxEdit().click(); ruleChainsPage.doneBtnEditView().click(); - boolean debugMode = Boolean.parseBoolean(ruleChainsPage.debugCheckboxView().getAttribute("aria-checked")); + boolean debugMode = ruleChainsPage.debugCheckboxView().getAttribute("class").contains("selected"); ruleChainsPage.editPencilBtn().click(); ruleChainsPage.debugCheckboxEdit().click(); ruleChainsPage.doneBtnEditView().click(); - Assert.assertFalse(Boolean.parseBoolean(ruleChainsPage.debugCheckboxView().getAttribute("aria-checked"))); - Assert.assertTrue(debugMode); + Assert.assertFalse(ruleChainsPage.debugCheckboxView().getAttribute("class").contains("selected"), "Debug mode disable"); + Assert.assertTrue(debugMode, "Debug mode enable"); } } \ No newline at end of file From bf3346d1ec8c5215d3a5ee9e008d0d945c0bad55 Mon Sep 17 00:00:00 2001 From: deaflynx Date: Fri, 7 Apr 2023 11:51:45 +0300 Subject: [PATCH 043/100] Device Profile provision X.509 strategy update: save certificateValue as provisionDeviceSecret --- ...device-profile-provision-configuration.component.ts | 10 ++++++---- .../components/profile/device-profile.component.ts | 2 -- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts index caebc701d2..1ad497ddbb 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts @@ -62,7 +62,6 @@ export class DeviceProfileProvisionConfigurationComponent implements ControlValu deviceProvisionType = DeviceProvisionType; deviceProvisionTypes = Object.keys(DeviceProvisionType); deviceProvisionTypeTranslateMap = deviceProvisionTypeTranslationMap; - readMore: boolean; private requiredValue: boolean; get required(): boolean { @@ -143,16 +142,16 @@ export class DeviceProfileProvisionConfigurationComponent implements ControlValu } writeValue(value: DeviceProvisionConfiguration | null): void { - if (isDefinedAndNotNull(value)){ + if (isDefinedAndNotNull(value)) { this.provisionConfigurationFormGroup.patchValue(value, {emitEvent: false}); } else { this.provisionConfigurationFormGroup.patchValue({type: DeviceProvisionType.DISABLED}); } } - setDisabledState(isDisabled: boolean){ + setDisabledState(isDisabled: boolean) { this.disabled = isDisabled; - if (this.disabled){ + if (this.disabled) { this.provisionConfigurationFormGroup.disable({emitEvent: false}); } else { if (this.provisionConfigurationFormGroup.get('type').value !== DeviceProvisionType.DISABLED) { @@ -176,6 +175,9 @@ export class DeviceProfileProvisionConfigurationComponent implements ControlValu this.resetFormControls(this.provisionConfigurationFormGroup.value); if (this.provisionConfigurationFormGroup.valid) { deviceProvisionConfiguration = this.provisionConfigurationFormGroup.getRawValue(); + if (deviceProvisionConfiguration.type === DeviceProvisionType.X509_CERTIFICATE_CHAIN) { + deviceProvisionConfiguration.provisionDeviceSecret = deviceProvisionConfiguration.certificateValue; + } } this.propagateChange(deviceProvisionConfiguration); } diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile.component.ts b/ui-ngx/src/app/modules/home/components/profile/device-profile.component.ts index 9a6d150ac1..db6539318d 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile.component.ts @@ -105,7 +105,6 @@ export class DeviceProfileComponent extends EntityComponent { type: entity?.provisionType ? entity.provisionType : DeviceProvisionType.DISABLED, provisionDeviceKey: entity?.provisionDeviceKey, provisionDeviceSecret: entity?.profileData?.provisionConfiguration?.provisionDeviceSecret, - certificateValue: entity?.profileData?.provisionConfiguration?.certificateValue, certificateRegExPattern: entity?.profileData?.provisionConfiguration?.certificateRegExPattern, allowCreateNewDevicesByX509Certificate: entity?.profileData?.provisionConfiguration?.allowCreateNewDevicesByX509Certificate }; @@ -189,7 +188,6 @@ export class DeviceProfileComponent extends EntityComponent { type: entity?.provisionType ? entity.provisionType : DeviceProvisionType.DISABLED, provisionDeviceKey: entity?.provisionDeviceKey, provisionDeviceSecret: entity?.profileData?.provisionConfiguration?.provisionDeviceSecret, - certificateValue: entity?.profileData?.provisionConfiguration?.certificateValue, certificateRegExPattern: entity?.profileData?.provisionConfiguration?.certificateRegExPattern, allowCreateNewDevicesByX509Certificate: entity?.profileData?.provisionConfiguration?.allowCreateNewDevicesByX509Certificate }; From d1a21900f0e713155d4f2cf29a161e8695826aca Mon Sep 17 00:00:00 2001 From: deaflynx Date: Fri, 7 Apr 2023 12:00:38 +0300 Subject: [PATCH 044/100] DeviceProfileProvisionConfigurationComponent writeValue set certificateValue as provisionDeviceSecret --- .../device-profile-provision-configuration.component.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts index 1ad497ddbb..54a9b98aa5 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts @@ -143,6 +143,9 @@ export class DeviceProfileProvisionConfigurationComponent implements ControlValu writeValue(value: DeviceProvisionConfiguration | null): void { if (isDefinedAndNotNull(value)) { + if (value.type === DeviceProvisionType.X509_CERTIFICATE_CHAIN) { + value.certificateValue = value.provisionDeviceSecret; + } this.provisionConfigurationFormGroup.patchValue(value, {emitEvent: false}); } else { this.provisionConfigurationFormGroup.patchValue({type: DeviceProvisionType.DISABLED}); From 55adb3d12a56a0a044263e4fc5eb486c4fa48c92 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Fri, 7 Apr 2023 13:07:08 +0300 Subject: [PATCH 045/100] Refactor x509: add logic of rotating x509 credentials and creating new device to DeviceProvisionService --- .../main/data/upgrade/3.4.4/schema_update.sql | 10 -- .../device/DeviceProvisionServiceImpl.java | 76 ++++++----- .../transport/DefaultTransportApiService.java | 124 ++++++------------ .../src/main/resources/thingsboard.yml | 8 +- .../BaseDeviceProfileControllerTest.java | 11 +- .../DefaultTransportApiServiceTest.java | 65 ++++----- .../dao/device/DeviceProfileService.java | 2 +- .../server/common/data/DeviceProfile.java | 5 - ...ertificateChainProvisionConfiguration.java | 8 +- .../mqtt/MqttSslHandlerProvider.java | 9 +- .../server/common/transport/util/SslUtil.java | 21 +++ .../dao/device/DeviceProfileCacheKey.java | 14 +- .../server/dao/device/DeviceProfileDao.java | 1 - .../dao/device/DeviceProfileEvictEvent.java | 2 +- .../dao/device/DeviceProfileServiceImpl.java | 40 +++--- .../server/dao/model/ModelConstants.java | 1 - .../dao/model/sql/DeviceProfileEntity.java | 5 - .../validator/DeviceProfileDataValidator.java | 24 ++-- .../sql/device/DeviceProfileRepository.java | 2 - .../dao/sql/device/JpaDeviceProfileDao.java | 5 - .../main/resources/sql/schema-entities.sql | 2 - 21 files changed, 188 insertions(+), 247 deletions(-) diff --git a/application/src/main/data/upgrade/3.4.4/schema_update.sql b/application/src/main/data/upgrade/3.4.4/schema_update.sql index 9bb85e3447..c9406cb38c 100644 --- a/application/src/main/data/upgrade/3.4.4/schema_update.sql +++ b/application/src/main/data/upgrade/3.4.4/schema_update.sql @@ -424,16 +424,6 @@ $$; -- ALARM FUNCTIONS END --- DEVICE PROFILE CERTIFICATE START - -ALTER TABLE device_profile - ADD COLUMN IF NOT EXISTS certificate_hash varchar, - DROP CONSTRAINT IF EXISTS device_profile_credentials_hash_unq_key, - ADD CONSTRAINT device_profile_credentials_hash_unq_key UNIQUE (certificate_hash); - --- DEVICE PROFILE CERTIFICATE END - - -- TTL DROP PARTITIONS FUNCTIONS UPDATE START DROP PROCEDURE IF EXISTS drop_partitions_by_max_ttl(character varying, bigint, bigint); diff --git a/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java index 80b51f7ad5..dacddc8a1d 100644 --- a/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java @@ -20,7 +20,6 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.cluster.TbClusterService; @@ -29,6 +28,7 @@ import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.device.profile.X509CertificateChainProvisionConfiguration; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; @@ -36,15 +36,19 @@ import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentialsType; +import org.thingsboard.server.common.msg.EncryptionUtil; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.common.transport.util.SslUtil; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.audit.AuditLogService; import org.thingsboard.server.dao.device.DeviceCredentialsService; import org.thingsboard.server.dao.device.DeviceDao; import org.thingsboard.server.dao.device.DeviceProfileDao; +import org.thingsboard.server.dao.device.DeviceProfileService; import org.thingsboard.server.dao.device.DeviceProvisionService; import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.device.provision.ProvisionFailedException; @@ -77,35 +81,23 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService { private static final String DEVICE_PROVISION_STATE = "provisionState"; private static final String PROVISIONED_STATE = "provisioned"; - @Autowired - TbClusterService clusterService; + private final TbClusterService clusterService; + private final DeviceProfileService deviceProfileService; + private final DeviceService deviceService; + private final DeviceCredentialsService deviceCredentialsService; + private final AttributesService attributesService; + private final AuditLogService auditLogService; + private final PartitionService partitionService; - @Autowired - DeviceDao deviceDao; - - @Autowired - DeviceProfileDao deviceProfileDao; - - @Autowired - DeviceService deviceService; - - @Autowired - DeviceCredentialsService deviceCredentialsService; - - @Autowired - AttributesService attributesService; - - @Autowired - DeviceStateService deviceStateService; - - @Autowired - AuditLogService auditLogService; - - @Autowired - PartitionService partitionService; - - public DeviceProvisionServiceImpl(TbQueueProducerProvider producerProvider) { + public DeviceProvisionServiceImpl(TbQueueProducerProvider producerProvider, TbClusterService clusterService, DeviceDao deviceDao, DeviceProfileDao deviceProfileDao, DeviceProfileService deviceProfileService, DeviceService deviceService, DeviceCredentialsService deviceCredentialsService, AttributesService attributesService, DeviceStateService deviceStateService, AuditLogService auditLogService, PartitionService partitionService) { ruleEngineMsgProducer = producerProvider.getRuleEngineMsgProducer(); + this.clusterService = clusterService; + this.deviceProfileService = deviceProfileService; + this.deviceService = deviceService; + this.deviceCredentialsService = deviceCredentialsService; + this.attributesService = attributesService; + this.auditLogService = auditLogService; + this.partitionService = partitionService; } @Override @@ -124,14 +116,14 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService { throw new ProvisionFailedException(ProvisionResponseStatus.NOT_FOUND.name()); } - DeviceProfile targetProfile = deviceProfileDao.findByProvisionDeviceKey(provisionRequestKey); + DeviceProfile targetProfile = deviceProfileService.findDeviceProfileByProvisionDeviceKey(provisionRequestKey); if (targetProfile == null || targetProfile.getProfileData().getProvisionConfiguration() == null || targetProfile.getProfileData().getProvisionConfiguration().getProvisionDeviceSecret() == null) { throw new ProvisionFailedException(ProvisionResponseStatus.NOT_FOUND.name()); } - Device targetDevice = deviceDao.findDeviceByTenantIdAndName(targetProfile.getTenantId().getId(), provisionRequest.getDeviceName()).orElse(null); + Device targetDevice = deviceService.findDeviceByTenantIdAndName(targetProfile.getTenantId(), provisionRequest.getDeviceName()); switch (targetProfile.getProvisionType()) { case ALLOW_CREATE_NEW_DEVICES: @@ -155,6 +147,22 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService { } } break; + case X509_CERTIFICATE_CHAIN: + X509CertificateChainProvisionConfiguration x509Configuration = (X509CertificateChainProvisionConfiguration) targetProfile.getProfileData().getProvisionConfiguration(); + if (targetDevice != null && targetDevice.getDeviceProfileId().equals(targetProfile.getId())) { + DeviceCredentials deviceCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(targetDevice.getTenantId(), targetDevice.getId()); + if (deviceCredentials.getCredentialsType() == DeviceCredentialsType.X509_CERTIFICATE) { + String updatedDeviceCertificateValue = provisionRequest.getCredentialsData().getX509CertHash(); + deviceCredentials = updateDeviceCredentials(targetDevice.getTenantId(), deviceCredentials, + updatedDeviceCertificateValue, DeviceCredentialsType.X509_CERTIFICATE); + } + return new ProvisionResponse(deviceCredentials, ProvisionResponseStatus.SUCCESS); + } else if (x509Configuration.isAllowCreateNewDevicesByX509Certificate()) { + return createDevice(provisionRequest, targetProfile); + } else { + log.info("Device doesn't exist and cannot be created due incorrect configuration for X509CertificateChainProvisionConfiguration"); + throw new ProvisionFailedException(ProvisionResponseStatus.NOT_FOUND.name()); + } } throw new ProvisionFailedException(ProvisionResponseStatus.NOT_FOUND.name()); } @@ -209,6 +217,14 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService { } } + private DeviceCredentials updateDeviceCredentials(TenantId tenantId, DeviceCredentials deviceCredentials, String certificateValue, + DeviceCredentialsType credentialsType) { + log.trace("Updating device credentials [{}] with certificate value [{}]", deviceCredentials, certificateValue); + deviceCredentials.setCredentialsValue(certificateValue); + deviceCredentials.setCredentialsType(credentialsType); + return deviceCredentialsService.updateDeviceCredentials(tenantId, deviceCredentials); + } + private ListenableFuture> saveProvisionStateAttribute(Device device) { return attributesService.save(device.getTenantId(), device.getId(), DataConstants.SERVER_SCOPE, Collections.singletonList(new BaseAttributeKvEntry(new StringDataEntry(DEVICE_PROVISION_STATE, PROVISIONED_STATE), diff --git a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java index f1ff95d3f2..dada1985f3 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java @@ -25,7 +25,6 @@ import com.google.common.util.concurrent.MoreExecutors; import com.google.protobuf.ByteString; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.codec.binary.Base64; import org.springframework.stereotype.Service; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.cache.ota.OtaPackageDataCache; @@ -34,7 +33,6 @@ import org.thingsboard.server.common.data.ApiUsageState; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; -import org.thingsboard.server.common.data.DeviceProfileProvisionType; import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.OtaPackage; @@ -49,6 +47,7 @@ import org.thingsboard.server.common.data.device.data.CoapDeviceTransportConfigu import org.thingsboard.server.common.data.device.data.Lwm2mDeviceTransportConfiguration; import org.thingsboard.server.common.data.device.data.PowerMode; import org.thingsboard.server.common.data.device.data.PowerSavingConfiguration; +import org.thingsboard.server.common.data.device.profile.DeviceProfileProvisionConfiguration; import org.thingsboard.server.common.data.device.profile.ProvisionDeviceProfileCredentials; import org.thingsboard.server.common.data.device.profile.X509CertificateChainProvisionConfiguration; import org.thingsboard.server.common.data.id.CustomerId; @@ -76,6 +75,7 @@ import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.device.provision.ProvisionFailedException; import org.thingsboard.server.dao.device.provision.ProvisionRequest; import org.thingsboard.server.dao.device.provision.ProvisionResponse; +import org.thingsboard.server.dao.device.provision.ProvisionResponseStatus; import org.thingsboard.server.dao.ota.OtaPackageService; import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.dao.relation.RelationService; @@ -106,10 +106,6 @@ import org.thingsboard.server.service.executors.DbCallbackExecutorService; import org.thingsboard.server.service.profile.TbDeviceProfileCache; import org.thingsboard.server.service.resource.TbResourceService; -import java.io.ByteArrayInputStream; -import java.io.InputStream; -import java.security.cert.CertificateFactory; -import java.security.cert.X509Certificate; import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -250,38 +246,13 @@ public class DefaultTransportApiService implements TransportApiService { if (credentials != null && credentials.getCredentialsType() == DeviceCredentialsType.X509_CERTIFICATE) { return getDeviceInfo(credentials); } - DeviceProfile deviceProfile = deviceProfileService.findDeviceProfileByCertificateHash(certificateHash); + DeviceProfile deviceProfile = deviceProfileService.findDeviceProfileByProvisionDeviceKey(certificateHash); if (deviceProfile != null) { - X509CertificateChainProvisionConfiguration x509Configuration; - if (deviceProfile.getProfileData().getProvisionConfiguration() instanceof X509CertificateChainProvisionConfiguration) { - x509Configuration = (X509CertificateChainProvisionConfiguration) deviceProfile.getProfileData().getProvisionConfiguration(); - } else { - log.warn("Device Profile provision configuration is not X509CertificateChainProvisionConfiguration"); - return getEmptyTransportApiResponseFuture(); - } - String deviceName = extractDeviceNameFromCertificateCNByRegEx(chain.get(0), x509Configuration.getCertificateRegExPattern()); - if (deviceName == null) { - log.warn("Cannot extract device name from device's CN using regex [{}]", x509Configuration.getCertificateRegExPattern()); - return getEmptyTransportApiResponseFuture(); - } - Device device = deviceService.findDeviceByTenantIdAndName(deviceProfile.getTenantId(), deviceName); - String updateDeviceCertificateValue = chain.get(0); - String updateDeviceCertificateHash = EncryptionUtil.getSha3Hash(updateDeviceCertificateValue); - if (device != null) { - DeviceCredentials deviceCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(device.getTenantId(), device.getId()); - if (deviceCredentials != null && deviceCredentials.getCredentialsType() == DeviceCredentialsType.X509_CERTIFICATE) { - deviceCredentials = updateDeviceCredentials(device.getTenantId(), deviceCredentials, updateDeviceCertificateValue, updateDeviceCertificateHash, DeviceCredentialsType.X509_CERTIFICATE); - } else if (deviceCredentials == null) { - deviceCredentials = createDeviceCredentials(device.getTenantId(), device.getId(), updateDeviceCertificateValue, updateDeviceCertificateHash, DeviceCredentialsType.X509_CERTIFICATE); - } - return getDeviceInfo(deviceCredentials); - } else if (deviceProfile.getProvisionType() == DeviceProfileProvisionType.X509_CERTIFICATE_CHAIN && x509Configuration.isAllowCreateNewDevicesByX509Certificate()) { - Device savedDevice = createDevice(deviceProfile.getTenantId(), deviceProfile.getId(), deviceName, deviceProfile.getName()); - DeviceCredentials deviceCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(savedDevice.getTenantId(), savedDevice.getId()); - deviceCredentials = updateDeviceCredentials(savedDevice.getTenantId(), deviceCredentials, updateDeviceCertificateValue, updateDeviceCertificateHash, DeviceCredentialsType.X509_CERTIFICATE); - return getDeviceInfo(deviceCredentials); - } else { - log.info("Device doesn't exist and cannot be created due incorrect configuration for X509CertificateChainProvisionConfiguration"); + String updatedDeviceProvisionSecret = chain.get(0); + ProvisionRequest provisionRequest = createProvisionRequest(deviceProfile, updatedDeviceProvisionSecret); + ProvisionResponse provisionResponse = deviceProvisionService.provisionDevice(provisionRequest); + if (provisionResponse.getResponseStatus().equals(ProvisionResponseStatus.SUCCESS)) { + return getDeviceInfo(provisionResponse.getDeviceCredentials()); } } } @@ -728,9 +699,37 @@ public class DefaultTransportApiService implements TransportApiService { return l != null ? l : 0; } + private ProvisionRequest createProvisionRequest(DeviceProfile deviceProfile, String certificateValue) { + String deviceName = getDeviceName(deviceProfile, certificateValue); + + ProvisionDeviceProfileCredentials provisionDeviceProfileCredentials = new ProvisionDeviceProfileCredentials( + deviceProfile.getProvisionDeviceKey(), + deviceProfile.getProfileData().getProvisionConfiguration().getProvisionDeviceSecret() + ); + ProvisionDeviceCredentialsData provisionDeviceCredentialsData = new ProvisionDeviceCredentialsData(null, null, null, null, certificateValue); + + return new ProvisionRequest(deviceName, DeviceCredentialsType.X509_CERTIFICATE, provisionDeviceCredentialsData, provisionDeviceProfileCredentials); + } + + + private String getDeviceName(DeviceProfile deviceProfile, String certificateValue) { + X509CertificateChainProvisionConfiguration configuration = new X509CertificateChainProvisionConfiguration(); + String deviceName = null; + if (deviceProfile.getProfileData().getProvisionConfiguration() instanceof X509CertificateChainProvisionConfiguration) { + configuration = (X509CertificateChainProvisionConfiguration) deviceProfile.getProfileData().getProvisionConfiguration(); + deviceName = extractDeviceNameFromCertificateCNByRegEx(certificateValue, configuration.getCertificateRegExPattern()); + if (deviceName == null) { + log.warn("Cannot extract device name from device's CN using regex [{}]", configuration.getCertificateRegExPattern()); + } + } else { + log.warn("Device Profile configuration: expected [{}], actual [{}]", configuration.getType(), deviceProfile.getProvisionType()); + } + return deviceName; + } + private String extractDeviceNameFromCertificateCNByRegEx(String x509Value, String regex) { try { - String commonName = SslUtil.parseCommonName(readCertFile(x509Value)); + String commonName = SslUtil.parseCommonName(SslUtil.readCertFile(x509Value)); log.trace("Extract CN [{}] by regex pattern [{}]", commonName, regex); Pattern pattern = Pattern.compile(regex); Matcher matcher = pattern.matcher(commonName); @@ -751,53 +750,4 @@ public class DefaultTransportApiService implements TransportApiService { } return chain; } - - private X509Certificate readCertFile(String fileContent) { - X509Certificate certificate = null; - try { - if (fileContent != null && !fileContent.trim().isEmpty()) { - fileContent = fileContent.replace("-----BEGIN CERTIFICATE-----", "") - .replace("-----END CERTIFICATE-----", "") - .replaceAll("\\s", ""); - byte[] decoded = Base64.decodeBase64(fileContent); - CertificateFactory certFactory = CertificateFactory.getInstance("X.509"); - try (InputStream inStream = new ByteArrayInputStream(decoded)) { - certificate = (X509Certificate) certFactory.generateCertificate(inStream); - } - } - } catch (Exception ignored) {} - return certificate; - } - - private DeviceCredentials updateDeviceCredentials(TenantId tenantId, DeviceCredentials deviceCredentials, String certificateValue, - String certificateHash, DeviceCredentialsType credentialsType) { - log.trace("Updating device credentials [{}] with certificate id [{}]", deviceCredentials, certificateHash); - deviceCredentials.setCredentialsId(certificateHash); - deviceCredentials.setCredentialsValue(certificateValue); - deviceCredentials.setCredentialsType(credentialsType); - return deviceCredentialsService.updateDeviceCredentials(tenantId, deviceCredentials); - } - - private DeviceCredentials createDeviceCredentials(TenantId tenantId, DeviceId deviceId, String certificateValue, - String certificateHash, DeviceCredentialsType credentialsType) { - log.trace("Creating new deviceCredentials for device [{}] with certificate id [{}]", deviceId, certificateHash); - DeviceCredentials createDevCredentials = new DeviceCredentials(); - createDevCredentials.setDeviceId(deviceId); - createDevCredentials.setCredentialsType(credentialsType); - createDevCredentials.setCredentialsId(certificateHash); - createDevCredentials.setCredentialsValue(certificateValue); - return deviceCredentialsService.createDeviceCredentials(tenantId, createDevCredentials); - } - - private Device createDevice(TenantId tenantId, DeviceProfileId deviceProfileId, String deviceName, String type) { - log.trace("Creating new device for deviceProfile [{}] with device name [{}]", deviceProfileId, deviceName); - Device device = new Device(); - device.setTenantId(tenantId); - device.setDeviceProfileId(deviceProfileId); - device.setName(deviceName); - device.setType(type); - device = deviceService.saveDevice(device); - tbClusterService.onDeviceUpdated(device, null); - return device; - } } diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index d96e50ca7f..1a845b2188 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -735,7 +735,7 @@ transport: # MQTT SSL configuration ssl: # Enable/disable SSL support - enabled: "${MQTT_SSL_ENABLED:false}" + enabled: "${MQTT_SSL_ENABLED:true}" # MQTT SSL bind address bind_address: "${MQTT_SSL_BIND_ADDRESS:0.0.0.0}" # MQTT SSL bind port @@ -749,11 +749,11 @@ transport: # PEM server credentials pem: # Path to the server certificate file (holds server certificate or certificate chain, may include server private key) - cert_file: "${MQTT_SSL_PEM_CERT:mqttserver.pem}" + cert_file: "${MQTT_SSL_PEM_CERT:/home/developer/x509/server.pem}" # Path to the server certificate private key file. Optional by default. Required if the private key is not present in server certificate file; - key_file: "${MQTT_SSL_PEM_KEY:mqttserver_key.pem}" + key_file: "${MQTT_SSL_PEM_KEY:/home/developer/x509/server_key.pem}" # Server certificate private key password (optional) - key_password: "${MQTT_SSL_PEM_KEY_PASSWORD:server_key_password}" + key_password: "${MQTT_SSL_PEM_KEY_PASSWORD:}" # Keystore server credentials keystore: # Type of the key store (JKS or PKCS12) diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseDeviceProfileControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseDeviceProfileControllerTest.java index e3017e4e19..e715e418ec 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseDeviceProfileControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseDeviceProfileControllerTest.java @@ -302,14 +302,17 @@ public abstract class BaseDeviceProfileControllerTest extends AbstractController @Test public void testSaveDeviceProfileWithSameCertificateHash() throws Exception { DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile"); - deviceProfile.setCertificateHash("Certificate Hash"); - doPost("/api/deviceProfile", deviceProfile).andExpect(status().isOk()); + deviceProfile.setProvisionDeviceKey("Certificate hash"); + + doPost("/api/deviceProfile", deviceProfile) + .andExpect(status().isOk()); + DeviceProfile deviceProfile2 = this.createDeviceProfile("Device Profile 2"); - deviceProfile2.setCertificateHash("Certificate Hash"); + deviceProfile2.setProvisionDeviceKey("Certificate hash"); Mockito.reset(tbClusterService, auditLogService); - String msgError = "Device profile with such certificate hash already exists"; + String msgError = "Device profile with such provision device key already exists!"; doPost("/api/deviceProfile", deviceProfile2) .andExpect(status().isBadRequest()) .andExpect(statusReason(containsString(msgError))); diff --git a/application/src/test/java/org/thingsboard/server/service/transport/DefaultTransportApiServiceTest.java b/application/src/test/java/org/thingsboard/server/service/transport/DefaultTransportApiServiceTest.java index a31d867242..21a03c6167 100644 --- a/application/src/test/java/org/thingsboard/server/service/transport/DefaultTransportApiServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/transport/DefaultTransportApiServiceTest.java @@ -40,6 +40,8 @@ import org.thingsboard.server.dao.device.DeviceCredentialsService; import org.thingsboard.server.dao.device.DeviceProfileService; import org.thingsboard.server.dao.device.DeviceProvisionService; import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.device.provision.ProvisionResponse; +import org.thingsboard.server.dao.device.provision.ProvisionResponseStatus; import org.thingsboard.server.dao.ota.OtaPackageService; import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.dao.relation.RelationService; @@ -130,43 +132,42 @@ public class DefaultTransportApiServiceTest { } @Test - public void updateExistingDeviceX509Certificate() { + public void provisionDeviceX509Certificate() { var deviceProfile = createDeviceProfile(chain[1]); - when(deviceProfileService.findDeviceProfileByCertificateHash(any())).thenReturn(deviceProfile); + when(deviceProfileService.findDeviceProfileByProvisionDeviceKey(any())).thenReturn(deviceProfile); var device = createDevice(); when(deviceService.findDeviceByTenantIdAndName(any(), any())).thenReturn(device); when(deviceService.findDeviceByIdAsync(any(), any())).thenReturn(Futures.immediateFuture(device)); var deviceCredentials = createDeviceCredentials(chain[0], device.getId()); - when(deviceCredentialsService.findDeviceCredentialsByDeviceId(any(), any())).thenReturn(deviceCredentials); + when(deviceCredentialsService.findDeviceCredentialsByCredentialsId(any())).thenReturn(null); when(deviceCredentialsService.updateDeviceCredentials(any(), any())).thenReturn(deviceCredentials); + var provisionResponse = createProvisionResponse(deviceCredentials); + when(deviceProvisionService.provisionDevice(any())).thenReturn(provisionResponse); + service.validateOrCreateDeviceX509Certificate(certificateChain); - verify(deviceProfileService, times(1)).findDeviceProfileByCertificateHash(any()); - verify(deviceService, times(1)).findDeviceByTenantIdAndName(any(), any()); - verify(deviceCredentialsService, times(1)).findDeviceCredentialsByDeviceId(any(), any()); - verify(deviceCredentialsService, times(1)).updateDeviceCredentials(any(), any()); + verify(deviceProfileService, times(1)).findDeviceProfileByProvisionDeviceKey(any()); + verify(deviceService, times(1)).findDeviceByIdAsync(any(), any()); + verify(deviceCredentialsService, times(1)).findDeviceCredentialsByCredentialsId(any()); + verify(deviceProvisionService, times(1)).provisionDevice(any()); } - @Test - public void createDeviceByX509Provision() { - var deviceProfile = createDeviceProfile(chain[1]); - when(deviceProfileService.findDeviceProfileByCertificateHash(any())).thenReturn(deviceProfile); - - var device = createDevice(); - when(deviceService.saveDevice(any())).thenReturn(device); - when(deviceService.findDeviceByIdAsync(any(), any())).thenReturn(Futures.immediateFuture(device)); + private DeviceProfile createDeviceProfile(String certificateValue) { + X509CertificateChainProvisionConfiguration provision = new X509CertificateChainProvisionConfiguration(); + provision.setProvisionDeviceSecret(certificateValue); + provision.setCertificateRegExPattern("([^@]+)"); + provision.setAllowCreateNewDevicesByX509Certificate(true); - var deviceCredentials = createDeviceCredentials(chain[0], device.getId()); - when(deviceCredentialsService.findDeviceCredentialsByDeviceId(any(), any())).thenReturn(deviceCredentials); - when(deviceCredentialsService.updateDeviceCredentials(any(), any())).thenReturn(deviceCredentials); + DeviceProfileData deviceProfileData = new DeviceProfileData(); + deviceProfileData.setProvisionConfiguration(provision); - service.validateOrCreateDeviceX509Certificate(certificateChain); - verify(deviceProfileService, times(1)).findDeviceProfileByCertificateHash(any()); - verify(deviceService, times(1)).findDeviceByTenantIdAndName(any(), any()); - verify(deviceCredentialsService, times(1)).findDeviceCredentialsByDeviceId(any(), any()); - verify(deviceCredentialsService, times(1)).updateDeviceCredentials(any(), any()); + DeviceProfile deviceProfile = new DeviceProfile(); + deviceProfile.setProfileData(deviceProfileData); + deviceProfile.setProvisionDeviceKey(EncryptionUtil.getSha3Hash(certificateValue)); + deviceProfile.setProvisionType(DeviceProfileProvisionType.X509_CERTIFICATE_CHAIN); + return deviceProfile; } private DeviceCredentials createDeviceCredentials(String certificateValue, DeviceId deviceId) { @@ -178,26 +179,16 @@ public class DefaultTransportApiServiceTest { return deviceCredentials; } - private DeviceProfile createDeviceProfile(String certificateValue) { - DeviceProfile deviceProfile = new DeviceProfile(); - DeviceProfileData deviceProfileData = new DeviceProfileData(); - X509CertificateChainProvisionConfiguration provision = new X509CertificateChainProvisionConfiguration(); - provision.setCertificateValue(certificateValue); - provision.setCertificateRegExPattern("([^@]+)"); - provision.setAllowCreateNewDevicesByX509Certificate(true); - deviceProfileData.setProvisionConfiguration(provision); - deviceProfile.setProfileData(deviceProfileData); - deviceProfile.setCertificateHash(EncryptionUtil.getSha3Hash(certificateValue)); - deviceProfile.setProvisionType(DeviceProfileProvisionType.X509_CERTIFICATE_CHAIN); - return deviceProfile; - } - private Device createDevice() { Device device = new Device(); device.setId(new DeviceId(UUID.randomUUID())); return device; } + private ProvisionResponse createProvisionResponse(DeviceCredentials deviceCredentials) { + return new ProvisionResponse(deviceCredentials, ProvisionResponseStatus.SUCCESS); + } + public static String certTrimNewLinesForChainInDeviceProfile(String input) { return input.replaceAll("\n", "") .replaceAll("\r", "") diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProfileService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProfileService.java index 90915c3ffe..e765cc5030 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProfileService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProfileService.java @@ -39,7 +39,7 @@ public interface DeviceProfileService extends EntityDaoService { PageData findDeviceProfileInfos(TenantId tenantId, PageLink pageLink, String transportType); - DeviceProfile findDeviceProfileByCertificateHash(String credentialsId); + DeviceProfile findDeviceProfileByProvisionDeviceKey(String provisionDeviceKey); DeviceProfile findOrCreateDeviceProfile(TenantId tenantId, String profileName); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfile.java b/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfile.java index 6d2c37e67d..7ff7d72023 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfile.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfile.java @@ -66,10 +66,6 @@ public class DeviceProfile extends SearchTextBased implements H private DeviceTransportType transportType; @ApiModelProperty(position = 15, value = "Provisioning strategy.") private DeviceProfileProvisionType provisionType; - @ApiModelProperty(position = 18, value = "CA certificate hash. ") - private String certificateHash; - - @ApiModelProperty(position = 7, value = "Reference to the rule chain. " + "If present, the specified rule chain will be used to process all messages related to device, including telemetry, attribute updates, etc. " + "Otherwise, the root rule chain will be used to process those messages.") @@ -125,7 +121,6 @@ public class DeviceProfile extends SearchTextBased implements H this.firmwareId = deviceProfile.getFirmwareId(); this.softwareId = deviceProfile.getSoftwareId(); this.defaultEdgeRuleChainId = deviceProfile.getDefaultEdgeRuleChainId(); - this.certificateHash = deviceProfile.getCertificateHash(); this.externalId = deviceProfile.getExternalId(); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/X509CertificateChainProvisionConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/X509CertificateChainProvisionConfiguration.java index 34fee34ab3..d5edafca69 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/X509CertificateChainProvisionConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/X509CertificateChainProvisionConfiguration.java @@ -24,16 +24,10 @@ import org.thingsboard.server.common.data.DeviceProfileProvisionType; @NoArgsConstructor public class X509CertificateChainProvisionConfiguration implements DeviceProfileProvisionConfiguration { - private String certificateValue; + private String provisionDeviceSecret; private String certificateRegExPattern; private boolean allowCreateNewDevicesByX509Certificate; - @Override - public String getProvisionDeviceSecret() { - // ignore device secret for this strategy - return null; - } - @Override public DeviceProfileProvisionType getType() { return DeviceProfileProvisionType.X509_CERTIFICATE_CHAIN; diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java index 4b6e15292a..f7a85425fe 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java @@ -164,12 +164,10 @@ public class MqttSslHandlerProvider { } }); latch.await(10, TimeUnit.SECONDS); - if (chain.length == 1) { - if (!clientDeviceCertValue.equals(credentialsBodyHolder[0])) { + if (!clientDeviceCertValue.equals(credentialsBodyHolder[0])) { + if (chain.length == 1) { throw new CertificateException("Invalid Device Certificate"); - } - } else { - if (!clientDeviceCertValue.equals(credentialsBodyHolder[0])) { + } else { throw new CertificateException("Invalid Chain of X509 Certificates"); } } @@ -177,6 +175,5 @@ public class MqttSslHandlerProvider { log.error(e.getMessage(), e); } } - } } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java index da04d50f50..089718ec05 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.transport.util; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.codec.binary.Base64; import org.bouncycastle.asn1.x500.RDN; import org.bouncycastle.asn1.x500.X500Name; import org.bouncycastle.asn1.x500.style.BCStyle; @@ -24,8 +25,11 @@ import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder; import org.springframework.util.Base64Utils; import org.thingsboard.server.common.msg.EncryptionUtil; +import java.io.ByteArrayInputStream; +import java.io.InputStream; import java.security.cert.Certificate; import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; /** @@ -53,6 +57,23 @@ public class SslUtil { return stringBuilder.toString(); } + public static X509Certificate readCertFile(String fileContent) { + X509Certificate certificate = null; + try { + if (fileContent != null && !fileContent.trim().isEmpty()) { + fileContent = fileContent.replace("-----BEGIN CERTIFICATE-----", "") + .replace("-----END CERTIFICATE-----", "") + .replaceAll("\\s", ""); + byte[] decoded = Base64.decodeBase64(fileContent); + CertificateFactory certFactory = CertificateFactory.getInstance("X.509"); + try (InputStream inStream = new ByteArrayInputStream(decoded)) { + certificate = (X509Certificate) certFactory.generateCertificate(inStream); + } + } + } catch (Exception ignored) {} + return certificate; + } + public static String parseCommonName(X509Certificate certificate) { X500Name x500name; try { diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileCacheKey.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileCacheKey.java index d15bde226b..715923f19b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileCacheKey.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileCacheKey.java @@ -29,14 +29,14 @@ public class DeviceProfileCacheKey implements Serializable { private final TenantId tenantId; private final String name; private final DeviceProfileId deviceProfileId; - private final String certificateHash; + private final String provisionDeviceKey; private final boolean defaultProfile; - private DeviceProfileCacheKey(TenantId tenantId, String name, DeviceProfileId deviceProfileId, String certificateHash, boolean defaultProfile) { + private DeviceProfileCacheKey(TenantId tenantId, String name, DeviceProfileId deviceProfileId, String provisionDeviceKey, boolean defaultProfile) { this.tenantId = tenantId; this.name = name; this.deviceProfileId = deviceProfileId; - this.certificateHash = certificateHash; + this.provisionDeviceKey = provisionDeviceKey; this.defaultProfile = defaultProfile; } @@ -48,8 +48,8 @@ public class DeviceProfileCacheKey implements Serializable { return new DeviceProfileCacheKey(null, null, id, null, false); } - public static DeviceProfileCacheKey fromCertificateHash(String certificateHash) { - return new DeviceProfileCacheKey(null, null, null, certificateHash, false); + public static DeviceProfileCacheKey fromProvisionDeviceKey(String provisionDeviceKey) { + return new DeviceProfileCacheKey(null, null, null, provisionDeviceKey, false); } public static DeviceProfileCacheKey defaultProfile(TenantId tenantId) { @@ -65,8 +65,8 @@ public class DeviceProfileCacheKey implements Serializable { return deviceProfileId.toString(); } else if (defaultProfile) { return tenantId.toString(); - } else if (certificateHash != null) { - return certificateHash; + } else if (provisionDeviceKey != null) { + return provisionDeviceKey; } return tenantId + "_" + name; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileDao.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileDao.java index 206f139b8c..387042c627 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileDao.java @@ -46,5 +46,4 @@ public interface DeviceProfileDao extends Dao, ExportableEntityDa DeviceProfile findByName(TenantId tenantId, String profileName); - DeviceProfile findByCertificateHash(String certificateHash); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileEvictEvent.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileEvictEvent.java index 55b5f994c2..6470bfc584 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileEvictEvent.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileEvictEvent.java @@ -27,6 +27,6 @@ public class DeviceProfileEvictEvent { private final String oldName; private final DeviceProfileId deviceProfileId; private final boolean defaultProfile; - private final String certificateHash; + private final String provisionDeviceKey; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java index 3b4be7aef4..d601bc4ef2 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java @@ -70,7 +70,7 @@ public class DeviceProfileServiceImpl extends AbstractCachedEntityService deviceProfileDao.findById(tenantId, deviceProfileId.getId()), true); } @@ -120,7 +120,7 @@ public class DeviceProfileServiceImpl extends AbstractCachedEntityService deviceProfileDao.findByName(tenantId, profileName), true); } @@ -128,7 +128,7 @@ public class DeviceProfileServiceImpl extends AbstractCachedEntityService deviceProfileDao.findByCertificateHash(certificateHash), true); + public DeviceProfile findDeviceProfileByProvisionDeviceKey(String provisionDeviceKey) { + log.trace("Executing findDeviceProfileIdByCredentialsId credentialId [{}]", provisionDeviceKey); + validateString(provisionDeviceKey, INCORRECT_PROVISION_DEVICE_KEY + provisionDeviceKey); + return cache.getAndPutInTransaction(DeviceProfileCacheKey.fromProvisionDeviceKey(provisionDeviceKey), + () -> deviceProfileDao.findByProvisionDeviceKey(provisionDeviceKey), true); } @Override @@ -290,7 +290,7 @@ public class DeviceProfileServiceImpl extends AbstractCachedEntityService impl @Column(name = ModelConstants.EXTERNAL_ID_PROPERTY) private UUID externalId; - @Column(name = ModelConstants.DEVICE_PROFILE_CERTIFICATE_HASH_PROPERTY) - private String certificateHash; - public DeviceProfileEntity() { super(); } @@ -129,7 +126,6 @@ public final class DeviceProfileEntity extends BaseSqlEntity impl this.image = deviceProfile.getImage(); this.transportType = deviceProfile.getTransportType(); this.provisionType = deviceProfile.getProvisionType(); - this.certificateHash = deviceProfile.getCertificateHash(); this.description = deviceProfile.getDescription(); this.isDefault = deviceProfile.isDefault(); this.profileData = JacksonUtil.convertValue(deviceProfile.getProfileData(), ObjectNode.class); @@ -192,7 +188,6 @@ public final class DeviceProfileEntity extends BaseSqlEntity impl deviceProfile.setDefaultDashboardId(new DashboardId(defaultDashboardId)); } deviceProfile.setProvisionDeviceKey(provisionDeviceKey); - deviceProfile.setCertificateHash(certificateHash); if (firmwareId != null) { deviceProfile.setFirmwareId(new OtaPackageId(firmwareId)); diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java index 8f4f89e2a8..dfd8d7b832 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java @@ -132,14 +132,14 @@ public class DeviceProfileDataValidator extends AbstractHasOtaPackageValidator Date: Fri, 7 Apr 2023 13:54:48 +0300 Subject: [PATCH 046/100] Refactoring: remove extra comments, remove spaces etc --- .../main/data/upgrade/3.4.4/schema_update.sql | 1 - .../device/DeviceProvisionServiceImpl.java | 2 +- application/src/main/resources/thingsboard.yml | 8 ++++---- .../dao/device/DeviceProfileCacheKey.java | 18 +++++++++--------- .../server/dao/device/DeviceProfileDao.java | 1 - .../validator/DeviceProfileDataValidator.java | 8 -------- 6 files changed, 14 insertions(+), 24 deletions(-) diff --git a/application/src/main/data/upgrade/3.4.4/schema_update.sql b/application/src/main/data/upgrade/3.4.4/schema_update.sql index 2781c9119d..2eb6a0d6cc 100644 --- a/application/src/main/data/upgrade/3.4.4/schema_update.sql +++ b/application/src/main/data/upgrade/3.4.4/schema_update.sql @@ -424,7 +424,6 @@ $$; -- ALARM FUNCTIONS END - -- TTL DROP PARTITIONS FUNCTIONS UPDATE START DROP PROCEDURE IF EXISTS drop_partitions_by_max_ttl(character varying, bigint, bigint); diff --git a/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java index dacddc8a1d..d4bfe9c625 100644 --- a/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java @@ -89,7 +89,7 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService { private final AuditLogService auditLogService; private final PartitionService partitionService; - public DeviceProvisionServiceImpl(TbQueueProducerProvider producerProvider, TbClusterService clusterService, DeviceDao deviceDao, DeviceProfileDao deviceProfileDao, DeviceProfileService deviceProfileService, DeviceService deviceService, DeviceCredentialsService deviceCredentialsService, AttributesService attributesService, DeviceStateService deviceStateService, AuditLogService auditLogService, PartitionService partitionService) { + public DeviceProvisionServiceImpl(TbQueueProducerProvider producerProvider, TbClusterService clusterService, DeviceProfileService deviceProfileService, DeviceService deviceService, DeviceCredentialsService deviceCredentialsService, AttributesService attributesService, AuditLogService auditLogService, PartitionService partitionService) { ruleEngineMsgProducer = producerProvider.getRuleEngineMsgProducer(); this.clusterService = clusterService; this.deviceProfileService = deviceProfileService; diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index ef42460108..f745e03ffc 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -741,7 +741,7 @@ transport: # MQTT SSL configuration ssl: # Enable/disable SSL support - enabled: "${MQTT_SSL_ENABLED:true}" + enabled: "${MQTT_SSL_ENABLED:false}" # MQTT SSL bind address bind_address: "${MQTT_SSL_BIND_ADDRESS:0.0.0.0}" # MQTT SSL bind port @@ -755,11 +755,11 @@ transport: # PEM server credentials pem: # Path to the server certificate file (holds server certificate or certificate chain, may include server private key) - cert_file: "${MQTT_SSL_PEM_CERT:/home/developer/x509/server.pem}" + cert_file: "${MQTT_SSL_PEM_CERT:mqttserver.pem}" # Path to the server certificate private key file. Optional by default. Required if the private key is not present in server certificate file; - key_file: "${MQTT_SSL_PEM_KEY:/home/developer/x509/server_key.pem}" + key_file: "${MQTT_SSL_PEM_KEY:mqttserver_key.pem}" # Server certificate private key password (optional) - key_password: "${MQTT_SSL_PEM_KEY_PASSWORD:}" + key_password: "${MQTT_SSL_PEM_KEY_PASSWORD:server_key_password}" # Keystore server credentials keystore: # Type of the key store (JKS or PKCS12) diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileCacheKey.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileCacheKey.java index 715923f19b..6e55b0b6ab 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileCacheKey.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileCacheKey.java @@ -29,31 +29,31 @@ public class DeviceProfileCacheKey implements Serializable { private final TenantId tenantId; private final String name; private final DeviceProfileId deviceProfileId; - private final String provisionDeviceKey; private final boolean defaultProfile; + private final String provisionDeviceKey; - private DeviceProfileCacheKey(TenantId tenantId, String name, DeviceProfileId deviceProfileId, String provisionDeviceKey, boolean defaultProfile) { + private DeviceProfileCacheKey(TenantId tenantId, String name, DeviceProfileId deviceProfileId, boolean defaultProfile, String provisionDeviceKey) { this.tenantId = tenantId; this.name = name; this.deviceProfileId = deviceProfileId; - this.provisionDeviceKey = provisionDeviceKey; this.defaultProfile = defaultProfile; + this.provisionDeviceKey = provisionDeviceKey; } public static DeviceProfileCacheKey fromName(TenantId tenantId, String name) { - return new DeviceProfileCacheKey(tenantId, name, null, null, false); + return new DeviceProfileCacheKey(tenantId, name, null, false, null); } public static DeviceProfileCacheKey fromId(DeviceProfileId id) { - return new DeviceProfileCacheKey(null, null, id, null, false); + return new DeviceProfileCacheKey(null, null, id, false, null); } - public static DeviceProfileCacheKey fromProvisionDeviceKey(String provisionDeviceKey) { - return new DeviceProfileCacheKey(null, null, null, provisionDeviceKey, false); + public static DeviceProfileCacheKey defaultProfile(TenantId tenantId) { + return new DeviceProfileCacheKey(tenantId, null, null, true, null); } - public static DeviceProfileCacheKey defaultProfile(TenantId tenantId) { - return new DeviceProfileCacheKey(tenantId, null, null, null, true); + public static DeviceProfileCacheKey fromProvisionDeviceKey(String provisionDeviceKey) { + return new DeviceProfileCacheKey(null, null, null, false, provisionDeviceKey); } /** diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileDao.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileDao.java index 387042c627..c221002413 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileDao.java @@ -45,5 +45,4 @@ public interface DeviceProfileDao extends Dao, ExportableEntityDa DeviceProfile findByProvisionDeviceKey(String provisionDeviceKey); DeviceProfile findByName(TenantId tenantId, String profileName); - } diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java index dfd8d7b832..073b685167 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java @@ -136,10 +136,6 @@ public class DeviceProfileDataValidator extends AbstractHasOtaPackageValidator Date: Fri, 7 Apr 2023 17:49:56 +0300 Subject: [PATCH 047/100] Add more detailed logs and move extract logic by regex to deviceProvisionService --- .../device/DeviceProvisionServiceImpl.java | 39 +++++++++++++-- .../transport/DefaultTransportApiService.java | 49 ++++++------------- 2 files changed, 48 insertions(+), 40 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java index d4bfe9c625..33b2af55fe 100644 --- a/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java @@ -37,7 +37,6 @@ import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.common.data.security.DeviceCredentialsType; -import org.thingsboard.server.common.msg.EncryptionUtil; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.common.msg.queue.ServiceType; @@ -46,8 +45,6 @@ import org.thingsboard.server.common.transport.util.SslUtil; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.audit.AuditLogService; import org.thingsboard.server.dao.device.DeviceCredentialsService; -import org.thingsboard.server.dao.device.DeviceDao; -import org.thingsboard.server.dao.device.DeviceProfileDao; import org.thingsboard.server.dao.device.DeviceProfileService; import org.thingsboard.server.dao.device.DeviceProvisionService; import org.thingsboard.server.dao.device.DeviceService; @@ -63,12 +60,13 @@ import org.thingsboard.server.queue.common.TbProtoQueueMsg; import org.thingsboard.server.queue.discovery.PartitionService; import org.thingsboard.server.queue.provider.TbQueueProducerProvider; import org.thingsboard.server.queue.util.TbCoreComponent; -import org.thingsboard.server.service.state.DeviceStateService; import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.concurrent.ExecutionException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; @Service @@ -102,6 +100,7 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService { @Override public ProvisionResponse provisionDevice(ProvisionRequest provisionRequest) { + fetchAndApplyDeviceNameForX509ProvisionRequestWithRegEx(provisionRequest); String provisionRequestKey = provisionRequest.getCredentials().getProvisionDeviceKey(); String provisionRequestSecret = provisionRequest.getCredentials().getProvisionDeviceSecret(); if (!StringUtils.isEmpty(provisionRequest.getDeviceName())) { @@ -160,7 +159,7 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService { } else if (x509Configuration.isAllowCreateNewDevicesByX509Certificate()) { return createDevice(provisionRequest, targetProfile); } else { - log.info("Device doesn't exist and cannot be created due incorrect configuration for X509CertificateChainProvisionConfiguration"); + log.warn("Device with name {} doesn't exist and cannot be created due incorrect configuration for X509CertificateChainProvisionConfiguration", provisionRequest.getDeviceName()); throw new ProvisionFailedException(ProvisionResponseStatus.NOT_FOUND.name()); } } @@ -273,4 +272,34 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService { ActionType actionType = success ? ActionType.PROVISION_SUCCESS : ActionType.PROVISION_FAILURE; auditLogService.logEntityAction(tenantId, customerId, new UserId(UserId.NULL_UUID), device.getName(), device.getId(), device, actionType, null, provisionRequest); } + + private void fetchAndApplyDeviceNameForX509ProvisionRequestWithRegEx(ProvisionRequest provisionRequest) { + DeviceProfile deviceProfile = deviceProfileService.findDeviceProfileByProvisionDeviceKey(provisionRequest.getCredentials().getProvisionDeviceKey()); + X509CertificateChainProvisionConfiguration configuration; + if (deviceProfile.getProfileData().getProvisionConfiguration() instanceof X509CertificateChainProvisionConfiguration) { + configuration = (X509CertificateChainProvisionConfiguration) deviceProfile.getProfileData().getProvisionConfiguration(); + String certificateValue = provisionRequest.getCredentialsData().getX509CertHash(); + String certificateRegEx = configuration.getCertificateRegExPattern(); + String deviceName = extractDeviceNameFromCertificateCNByRegEx(certificateValue, certificateRegEx); + if (deviceName == null) { + log.warn("Device name cannot be extracted using regex [{}] for certificate [{}]",certificateRegEx, certificateValue); + throw new ProvisionFailedException(ProvisionResponseStatus.FAILURE.name()); + } + provisionRequest.setDeviceName(deviceName); + } + } + + private String extractDeviceNameFromCertificateCNByRegEx(String x509Value, String regex) { + try { + String commonName = SslUtil.parseCommonName(SslUtil.readCertFile(x509Value)); + log.trace("Extract CN [{}] by regex pattern [{}]", commonName, regex); + Pattern pattern = Pattern.compile(regex); + Matcher matcher = pattern.matcher(commonName); + if (matcher.find()) { + return matcher.group(0); + } + return null; + } catch (Exception ignored) {} + return null; + } } diff --git a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java index dada1985f3..031564aecf 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java @@ -47,9 +47,7 @@ import org.thingsboard.server.common.data.device.data.CoapDeviceTransportConfigu import org.thingsboard.server.common.data.device.data.Lwm2mDeviceTransportConfiguration; import org.thingsboard.server.common.data.device.data.PowerMode; import org.thingsboard.server.common.data.device.data.PowerSavingConfiguration; -import org.thingsboard.server.common.data.device.profile.DeviceProfileProvisionConfiguration; import org.thingsboard.server.common.data.device.profile.ProvisionDeviceProfileCredentials; -import org.thingsboard.server.common.data.device.profile.X509CertificateChainProvisionConfiguration; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceProfileId; @@ -67,7 +65,6 @@ import org.thingsboard.server.common.msg.EncryptionUtil; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgDataType; import org.thingsboard.server.common.msg.TbMsgMetaData; -import org.thingsboard.server.common.transport.util.SslUtil; import org.thingsboard.server.dao.device.DeviceCredentialsService; import org.thingsboard.server.dao.device.DeviceProfileService; import org.thingsboard.server.dao.device.DeviceProvisionService; @@ -118,6 +115,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; +import static org.thingsboard.server.common.data.DeviceProfileProvisionType.X509_CERTIFICATE_CHAIN; import static org.thingsboard.server.service.transport.BasicCredentialsValidationResult.PASSWORD_MISMATCH; import static org.thingsboard.server.service.transport.BasicCredentialsValidationResult.VALID; @@ -247,13 +245,17 @@ public class DefaultTransportApiService implements TransportApiService { return getDeviceInfo(credentials); } DeviceProfile deviceProfile = deviceProfileService.findDeviceProfileByProvisionDeviceKey(certificateHash); - if (deviceProfile != null) { + if (deviceProfile != null && deviceProfile.getProvisionType() == X509_CERTIFICATE_CHAIN) { String updatedDeviceProvisionSecret = chain.get(0); ProvisionRequest provisionRequest = createProvisionRequest(deviceProfile, updatedDeviceProvisionSecret); - ProvisionResponse provisionResponse = deviceProvisionService.provisionDevice(provisionRequest); - if (provisionResponse.getResponseStatus().equals(ProvisionResponseStatus.SUCCESS)) { + ProvisionResponse provisionResponse = provisionDeviceRequestAndGetResponse(provisionRequest); + if (provisionResponse != null && provisionResponse.getResponseStatus().equals(ProvisionResponseStatus.SUCCESS)) { return getDeviceInfo(provisionResponse.getDeviceCredentials()); + } else { + return getEmptyTransportApiResponseFuture(); } + } else if (deviceProfile != null) { + log.warn("[{}] Device Profile provision configuration mismatched: expected {}, actual {}", deviceProfile.getName(), X509_CERTIFICATE_CHAIN, deviceProfile.getProvisionType()); } } return getEmptyTransportApiResponseFuture(); @@ -700,44 +702,21 @@ public class DefaultTransportApiService implements TransportApiService { } private ProvisionRequest createProvisionRequest(DeviceProfile deviceProfile, String certificateValue) { - String deviceName = getDeviceName(deviceProfile, certificateValue); - ProvisionDeviceProfileCredentials provisionDeviceProfileCredentials = new ProvisionDeviceProfileCredentials( deviceProfile.getProvisionDeviceKey(), deviceProfile.getProfileData().getProvisionConfiguration().getProvisionDeviceSecret() ); ProvisionDeviceCredentialsData provisionDeviceCredentialsData = new ProvisionDeviceCredentialsData(null, null, null, null, certificateValue); - return new ProvisionRequest(deviceName, DeviceCredentialsType.X509_CERTIFICATE, provisionDeviceCredentialsData, provisionDeviceProfileCredentials); + return new ProvisionRequest(null, DeviceCredentialsType.X509_CERTIFICATE, provisionDeviceCredentialsData, provisionDeviceProfileCredentials); } - - private String getDeviceName(DeviceProfile deviceProfile, String certificateValue) { - X509CertificateChainProvisionConfiguration configuration = new X509CertificateChainProvisionConfiguration(); - String deviceName = null; - if (deviceProfile.getProfileData().getProvisionConfiguration() instanceof X509CertificateChainProvisionConfiguration) { - configuration = (X509CertificateChainProvisionConfiguration) deviceProfile.getProfileData().getProvisionConfiguration(); - deviceName = extractDeviceNameFromCertificateCNByRegEx(certificateValue, configuration.getCertificateRegExPattern()); - if (deviceName == null) { - log.warn("Cannot extract device name from device's CN using regex [{}]", configuration.getCertificateRegExPattern()); - } - } else { - log.warn("Device Profile configuration: expected [{}], actual [{}]", configuration.getType(), deviceProfile.getProvisionType()); - } - return deviceName; - } - - private String extractDeviceNameFromCertificateCNByRegEx(String x509Value, String regex) { + private ProvisionResponse provisionDeviceRequestAndGetResponse(ProvisionRequest provisionRequest) { + ProvisionResponse provisionResponse = null; try { - String commonName = SslUtil.parseCommonName(SslUtil.readCertFile(x509Value)); - log.trace("Extract CN [{}] by regex pattern [{}]", commonName, regex); - Pattern pattern = Pattern.compile(regex); - Matcher matcher = pattern.matcher(commonName); - if (matcher.find()) { - return matcher.group(0); - } - } catch (Exception ignored) {} - return null; + provisionResponse = deviceProvisionService.provisionDevice(provisionRequest); + } catch (ProvisionFailedException ignored) {} + return provisionResponse; } private List convertX509CertificateChainToList(String certificateChain) { From 28db89368b22ed5acb2ddf448ebcdc38ae3b90ba Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Fri, 7 Apr 2023 17:54:53 +0300 Subject: [PATCH 048/100] UI: Implements and refactoring JSON type value in multiple input widget --- .../lib/multiple-input-widget.component.html | 6 +-- .../lib/multiple-input-widget.component.ts | 39 +++++++++++-------- ...ple-attributes-key-settings.component.html | 28 +++++++++++-- ...tiple-attributes-key-settings.component.ts | 23 ++++++++++- .../json-object-edit-dialog.component.html | 2 +- .../json-object-edit-dialog.component.ts | 36 +++++++++-------- .../assets/locale/locale.constant-en_US.json | 6 ++- 7 files changed, 96 insertions(+), 44 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.html index be894459b0..0a757fd34f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.html @@ -106,9 +106,7 @@ @@ -116,7 +114,7 @@ {{ getErrorMessageText(key.settings,'required') }} - {{ 'value.json-value-invalid' | translate }} + {{ getErrorMessageText(key.settings,'invalidJSON') | translate }}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.ts index 070336b3f0..534be7c676 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.ts @@ -26,11 +26,10 @@ import { DataKey, Datasource, DatasourceData, DatasourceType, WidgetConfig } fro import { IWidgetSubscription } from '@core/api/widget-api.models'; import { createLabelFromDatasource, - isBoolean, isDefined, + isBoolean, + isDefined, isDefinedAndNotNull, isEqual, - isNotEmptyStr, - isString, isUndefined } from '@core/utils'; import { EntityType } from '@shared/models/entity-type.models'; @@ -43,7 +42,10 @@ import { forkJoin, Observable, Subject } from 'rxjs'; import { EntityId } from '@shared/models/id/entity-id'; import { ResizeObserver } from '@juggle/resize-observer'; import { takeUntil } from 'rxjs/operators'; -import { JsonObjectEditDialogComponent, JsonObjectEditDialogData } from '@shared/components/dialog/json-object-edit-dialog.component'; +import { + JsonObjectEditDialogComponent, + JsonObjectEditDialogData +} from '@shared/components/dialog/json-object-edit-dialog.component'; import { MatDialog } from '@angular/material/dialog'; import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; @@ -51,8 +53,8 @@ type FieldAlignment = 'row' | 'column'; type MultipleInputWidgetDataKeyType = 'server' | 'shared' | 'timeseries'; export type MultipleInputWidgetDataKeyValueType = 'string' | 'double' | 'integer' | - 'JSON'| 'booleanCheckbox' | 'booleanSwitch' | - 'dateTime' | 'date' | 'time' | 'select'; + 'JSON' | 'booleanCheckbox' | 'booleanSwitch' | + 'dateTime' | 'date' | 'time' | 'select'; type MultipleInputWidgetDataKeyEditableType = 'editable' | 'disabled' | 'readonly'; type ConvertGetValueFunction = (value: any, ctx: WidgetContext) => any; @@ -93,6 +95,7 @@ interface MultipleInputWidgetDataKeySettings { invalidDateErrorMessage?: string; minValueErrorMessage?: string; maxValueErrorMessage?: string; + invalidJsonErrorMessage?: string; useCustomIcon: boolean; icon: string; customIcon: string ; @@ -106,9 +109,9 @@ interface MultipleInputWidgetDataKeySettings { useSetValueFunction?: boolean; setValueFunctionBody?: string; setValueFunction?: ConvertSetValueFunction; + dialogTitle?: string; saveButtonLabel?: string; - resetButtonLabel?: string; - dialogLabel?: string; + cancelButtonLabel?: string; } interface MultipleInputWidgetDataKey extends DataKey { @@ -434,9 +437,9 @@ export class MultipleInputWidgetComponent extends PageComponent implements OnIni break; case 'JSON': try { - value = JSON.parse(keyData[0][1]); + value = JSON.parse(keyValue); } catch (e) { - value = keyData[0][1] ? keyData[0][1] : null; + value = keyValue ? keyValue : null; } break; default: @@ -534,6 +537,10 @@ export class MultipleInputWidgetComponent extends PageComponent implements OnIni errorMessage = keySettings.invalidDateErrorMessage; defaultMessage = 'widgets.input-widgets.invalid-date'; break; + case 'invalidJSON': + errorMessage = keySettings.invalidJsonErrorMessage; + defaultMessage = 'widgets.input-widgets.json-invalid'; + break; default: return ''; } @@ -684,8 +691,8 @@ export class MultipleInputWidgetComponent extends PageComponent implements OnIni } }); if (tasks.length) { - forkJoin(tasks).subscribe( - () => { + forkJoin(tasks).subscribe({ + next: () => { this.multipleInputFormGroup.markAsPristine(); this.ctx.detectChanges(); this.isSavingInProgress = false; @@ -694,13 +701,13 @@ export class MultipleInputWidgetComponent extends PageComponent implements OnIni 1000, 'bottom', 'left', this.toastTargetId); } }, - () => { + error: () => { this.isSavingInProgress = false; if (this.settings.showResultMessage) { this.ctx.showErrorToast(this.translate.instant('widgets.input-widgets.update-failed'), 'bottom', 'left', this.toastTargetId); } - }); + }}); } else { this.multipleInputFormGroup.markAsPristine(); this.ctx.detectChanges(); @@ -743,9 +750,9 @@ export class MultipleInputWidgetComponent extends PageComponent implements OnIni panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], data: { jsonValue: formControl.value, - title: key.settings.dialogLabel, + title: key.settings.dialogTitle, saveLabel: key.settings.saveButtonLabel, - undoLabel: key.settings.saveButtonLabel + cancelLabel: key.settings.cancelButtonLabel } }).afterClosed().subscribe( (res) => { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.html index c3b86ac8af..3c62810000 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.html @@ -48,9 +48,6 @@ {{ 'widgets.input-widgets.datakey-value-type-integer' | translate }} - - {{ 'widgets.input-widgets.datakey-value-type-json' | translate }} - {{ 'widgets.input-widgets.datakey-value-type-boolean-checkbox' | translate }} @@ -69,6 +66,9 @@ {{ 'widgets.input-widgets.datakey-value-type-select' | translate }} + + {{ 'widgets.input-widgets.datakey-value-type-json' | translate }} +
@@ -182,6 +182,28 @@ widgets.input-widgets.invalid-date-error-message + + widgets.input-widgets.invalid-JSON-error-message + + + +
+ widgets.input-widgets.dialog-editor-settings + + widgets.input-widgets.title + + +
+ + widgets.input-widgets.save-button-label + + + + widgets.input-widgets.cancel-button-label + + +
widgets.input-widgets.icon-settings diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.ts index 47eee06cb3..7eff1bdc80 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.ts @@ -63,6 +63,11 @@ export class UpdateMultipleAttributesKeySettingsComponent extends WidgetSettings minValueErrorMessage: '', maxValueErrorMessage: '', invalidDateErrorMessage: '', + invalidJsonErrorMessage: '', + + dialogTitle: '', + saveButtonLabel: '', + cancelButtonLabel: '', useCustomIcon: false, icon: '', @@ -108,6 +113,13 @@ export class UpdateMultipleAttributesKeySettingsComponent extends WidgetSettings minValueErrorMessage: [settings.minValueErrorMessage, []], maxValueErrorMessage: [settings.maxValueErrorMessage, []], invalidDateErrorMessage: [settings.invalidDateErrorMessage, []], + invalidJsonErrorMessage: [settings.invalidJsonErrorMessage, []], + + // Dialog settings + + dialogTitle: [settings.dialogTitle, []], + saveButtonLabel: [settings.saveButtonLabel, []], + cancelButtonLabel: [settings.cancelButtonLabel, []], // Icon settings @@ -131,7 +143,8 @@ export class UpdateMultipleAttributesKeySettingsComponent extends WidgetSettings protected updateValidators(emitEvent: boolean) { const dataKeyHidden: boolean = this.updateMultipleAttributesKeySettingsForm.get('dataKeyHidden').value; - const dataKeyValueType: MultipleInputWidgetDataKeyValueType = this.updateMultipleAttributesKeySettingsForm.get('dataKeyValueType').value; + const dataKeyValueType: MultipleInputWidgetDataKeyValueType = + this.updateMultipleAttributesKeySettingsForm.get('dataKeyValueType').value; const required: boolean = this.updateMultipleAttributesKeySettingsForm.get('required').value; const isEditable: string = this.updateMultipleAttributesKeySettingsForm.get('isEditable').value; const useCustomIcon: boolean = this.updateMultipleAttributesKeySettingsForm.get('useCustomIcon').value; @@ -166,6 +179,11 @@ export class UpdateMultipleAttributesKeySettingsComponent extends WidgetSettings this.updateMultipleAttributesKeySettingsForm.get('maxValueErrorMessage').enable({emitEvent: false}); } else if (dataKeyValueType === 'dateTime' || dataKeyValueType === 'date' || dataKeyValueType === 'time') { this.updateMultipleAttributesKeySettingsForm.get('invalidDateErrorMessage').enable({emitEvent: false}); + } else if (dataKeyValueType === 'JSON') { + this.updateMultipleAttributesKeySettingsForm.get('invalidJsonErrorMessage').enable({emitEvent: false}); + this.updateMultipleAttributesKeySettingsForm.get('dialogTitle').enable({emitEvent: false}); + this.updateMultipleAttributesKeySettingsForm.get('saveButtonLabel').enable({emitEvent: false}); + this.updateMultipleAttributesKeySettingsForm.get('cancelButtonLabel').enable({emitEvent: false}); } if (required) { this.updateMultipleAttributesKeySettingsForm.get('requiredErrorMessage').enable({emitEvent: false}); @@ -244,7 +262,8 @@ export class UpdateMultipleAttributesKeySettingsComponent extends WidgetSettings displayErrorMessagesSection(): boolean { const dataKeyHidden: boolean = this.updateMultipleAttributesKeySettingsForm.get('dataKeyHidden').value; const required: boolean = this.updateMultipleAttributesKeySettingsForm.get('required').value; - const dataKeyValueType: string = this.updateMultipleAttributesKeySettingsForm.get('dataKeyValueType').value; + const dataKeyValueType: MultipleInputWidgetDataKeyValueType = + this.updateMultipleAttributesKeySettingsForm.get('dataKeyValueType').value; return !dataKeyHidden && (required || (['integer', 'double', 'dateTime', 'date', 'time', 'JSON'].includes(dataKeyValueType))); } } diff --git a/ui-ngx/src/app/shared/components/dialog/json-object-edit-dialog.component.html b/ui-ngx/src/app/shared/components/dialog/json-object-edit-dialog.component.html index 8fc3418808..141979d8ff 100644 --- a/ui-ngx/src/app/shared/components/dialog/json-object-edit-dialog.component.html +++ b/ui-ngx/src/app/shared/components/dialog/json-object-edit-dialog.component.html @@ -45,7 +45,7 @@ type="button" [disabled]="(isLoading$ | async)" (click)="cancel()" cdkFocusInitial> - {{ undoButtonLabel }} + {{ cancelButtonLabel }} + + + +
+
+ + +
+ diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-comment-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alarm/alarm-comment-dialog.component.ts new file mode 100644 index 0000000000..0aa568bdd0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-comment-dialog.component.ts @@ -0,0 +1,50 @@ +/// +/// Copyright © 2016-2023 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 } from '@angular/core'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Router } from '@angular/router'; +import { AlarmInfo } from '@shared/models/alarm.models'; + +export interface AlarmCommentDialogData { + alarmId?: string; + alarm?: AlarmInfo; +} + +@Component({ + selector: 'tb-alarm-comment-dialog', + templateUrl: './alarm-comment-dialog.component.html', + styleUrls: [] +}) +export class AlarmCommentDialogComponent extends DialogComponent { + + alarmId: string; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: AlarmCommentDialogData, + public dialogRef: MatDialogRef) { + super(store, router, dialogRef); + this.alarmId = this.data.alarmId; + } + + close(): void { + this.dialogRef.close(); + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-comment.component.html b/ui-ngx/src/app/modules/home/components/alarm/alarm-comment.component.html new file mode 100644 index 0000000000..ac2bb73d05 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-comment.component.html @@ -0,0 +1,181 @@ + + +
+
+
+ + alarm-activity.activity + +
+ + + +
+
+
+
+ + + +
+
+
+ + {{ displayDataElement.commentText }} + + + {{ displayDataElement.createdDateAgo }} + +
+ +
+
+ {{ getUserInitials(displayDataElement.displayName) }} +
+
+
+ {{ displayDataElement.displayName }} + + {{ displayDataElement.createdDateAgo }} + + + Edited + +
+ {{ displayDataElement.commentText }} +
+
+ + +
+
+ +
+
+ {{ getUserInitials(displayDataElement.displayName) }} +
+ + +
+ + +
+
+
+
+
+
+
+ + + +
+ +
+
+
+ {{ getUserInitials(userDisplayName) }} +
+ + + + +
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-comment.component.scss b/ui-ngx/src/app/modules/home/components/alarm/alarm-comment.component.scss new file mode 100644 index 0000000000..c7b0d65353 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-comment.component.scss @@ -0,0 +1,220 @@ +/** + * Copyright © 2016-2023 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. + */ +@use '@angular/material' as mat; +@import '../theme.scss'; + +$primary-color: rgba(mat.get-color-from-palette($tb-primary, 50), 0.4); +$border: 1px solid mat.get-color-from-palette($tb-primary); + +:host { + .tb-alarm-comments { + background-color: $primary-color; + max-width: 600px; + width: 100%; + + .comment-input { + position: sticky; + z-index: 1; + + .inner-wrap { + border: $border; + padding: 0 24px; + background-color: $primary-color; + } + + &.oldest-first { + bottom: -24px; + box-shadow: 0px -4px 12px rgba(0, 0, 0, 0.04); + + .inner-wrap { + border-radius: 0 0 8px 8px; + } + + &.activity-only { + bottom: -1px; + + .inner-wrap { + border: none + } + } + } + + &.newest-first { + top: 24px; + box-shadow: 0 8px 10px rgba(23, 33, 90, 0.08); + + .inner-wrap { + border-top: none; + } + + &.activity-only { + top: 48px; + box-shadow: 0 8px 10px rgba(23, 33, 90, 0.08); + + .inner-wrap { + border: none; + } + } + } + } + + .header { + z-index: 1; + position: sticky; + top: -24px; + background-color: white; + + &.activity-only { + top: 0; + } + + &-container { + padding: 0 24px; + background-color: $primary-color; + border: $border; + border-bottom: none; + border-radius: 8px 8px 0px 0px; + + &.activity-only { + border: none; + } + + &.asc { + border-bottom: $border; + box-shadow: 0px 4px 10px rgba(23, 33, 90, 0.08); + + &.activity-only { + border: none; + } + } + + .header-title { + color: rgba(0, 0, 0, 0.76); + letter-spacing: 0.25px; + font-weight: 500; + } + + .mat-icon { + color: rgba(0, 0, 0, 0.38); + } + } + } + + .comments-container { + border: $border; + border-top: none; + border-radius: 0px 0px 8px 8px; + + &.activity-only { + border: none; + } + + &.asc { + border-bottom: none; + border-radius: unset; + } + } + + .user-comment { + padding: 4px; + border-radius: 8px; + &:hover { + background-color: $primary-color; + } + } + + .user-avatar { + width: 28px; + min-width: 28px; + height: 28px; + min-height: 28px; + border-radius: 50%; + font-weight: 700; + color: white; + font-size: 13px; + } + + .user-name { + font-size: 16px; + color: rgba(0, 0, 0, 0.76); + font-weight: 500; + letter-spacing: 0.25px; + } + + .time { + font-size: 14px; + font-weight: 400; + color: rgba(0, 0, 0, 0.38); + letter-spacing: 0.2px; + border-radius: 16px; + padding: 0 3px; + &:hover { + background: rgba(0, 0, 0, 0.04); + } + } + + .system-comment { + margin-left: 38px; + @media #{$mat-xs} { + margin-left: 0; + } + } + + .system-text { + font-weight: 500; + font-size: 14px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.38); + } + + .text { + white-space: pre-line; + word-break: break-word; + font-size: 16px; + letter-spacing: 0.15px; + color: rgba(0, 0, 0, 0.54); + } + + .action-buttons { + visibility: hidden; + .mat-icon { + color: rgba(0, 0, 0, 0.38); + } + } + + .show-buttons { + visibility: visible; + } + + .green-button { + color: #00695C; + } + + .red-button { + color: #D12730; + } + + .mat-form-field { + font-size: 16px; + letter-spacing: 0.15px; + color: rgba(0, 0, 0, 0.76); + } + + textarea { + letter-spacing: 0.15px; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-comment.component.ts b/ui-ngx/src/app/modules/home/components/alarm/alarm-comment.component.ts new file mode 100644 index 0000000000..5ba3c39a81 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-comment.component.ts @@ -0,0 +1,325 @@ +/// +/// Copyright © 2016-2023 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Input, OnInit } from '@angular/core'; +import { select, Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { TranslateService } from '@ngx-translate/core'; +import { AlarmCommentService } from '@core/http/alarm-comment.service'; +import { AbstractControl, FormBuilder, FormGroup } from '@angular/forms'; +import { DialogService } from '@core/services/dialog.service'; +import { AuthUser, User } from '@shared/models/user.model'; +import { getCurrentAuthUser, selectUserDetails } from '@core/auth/auth.selectors'; +import { Direction, SortOrder } from '@shared/models/page/sort-order'; +import { MAX_SAFE_PAGE_SIZE, PageLink } from '@shared/models/page/page-link'; +import { DateAgoPipe } from '@shared/pipe/date-ago.pipe'; +import { map } from 'rxjs/operators'; +import { AlarmComment, AlarmCommentInfo, AlarmCommentType } from '@shared/models/alarm.models'; +import { UtilsService } from '@core/services/utils.service'; +import { EntityType } from '@shared/models/entity-type.models'; +import { DatePipe } from '@angular/common'; +import { ImportExportService } from '@home/components/import-export/import-export.service'; + +interface AlarmCommentsDisplayData { + commentId?: string, + displayName?: string, + createdTime: string, + createdDateAgo?: string, + edit?: boolean, + isEdited?: boolean, + editedTime?: string; + editedDateAgo?: string, + showActions?: boolean, + commentText?: string, + isSystemComment?: boolean, + avatarBgColor?: string +} + +@Component({ + selector: 'tb-alarm-comment', + templateUrl: './alarm-comment.component.html', + styleUrls: ['./alarm-comment.component.scss'] +}) +export class AlarmCommentComponent implements OnInit { + @Input() + alarmId: string; + + @Input() + alarmActivityOnly: boolean = false; + + authUser: AuthUser; + + alarmCommentFormGroup: FormGroup; + + alarmComments: Array; + + displayData: Array = new Array(); + + alarmCommentSortOrder: SortOrder = { + property: 'createdTime', + direction: Direction.DESC + }; + + editMode: boolean = false; + + userDisplayName$ = this.store.pipe( + select(selectUserDetails), + map((user) => this.getUserDisplayName(user)) + ); + + currentUserDisplayName: string; + currentUserAvatarColor: string; + + constructor(protected store: Store, + private translate: TranslateService, + private alarmCommentService: AlarmCommentService, + public fb: FormBuilder, + private dialogService: DialogService, + public dateAgoPipe: DateAgoPipe, + private utilsService: UtilsService, + private datePipe: DatePipe, + private importExportService: ImportExportService) { + + this.authUser = getCurrentAuthUser(store); + + this.alarmCommentFormGroup = this.fb.group( + { + alarmCommentEdit: [''], + alarmComment: [''] + } + ); + } + + ngOnInit() { + this.loadAlarmComments(); + this.currentUserAvatarColor = this.utilsService.stringToHslColor(this.currentUserDisplayName, + 60, 40); + } + + loadAlarmComments(): void { + this.alarmCommentService.getAlarmComments(this.alarmId, new PageLink(MAX_SAFE_PAGE_SIZE, 0, null, + this.alarmCommentSortOrder), {ignoreLoading: true}).subscribe( + (pagedData) => { + this.alarmComments = pagedData.data; + this.displayData.length = 0; + for (let alarmComment of pagedData.data) { + let displayDataElement = {} as AlarmCommentsDisplayData; + displayDataElement.createdTime = this.datePipe.transform(alarmComment.createdTime, 'yyyy-MM-dd HH:mm:ss'); + displayDataElement.createdDateAgo = this.dateAgoPipe.transform(alarmComment.createdTime); + displayDataElement.commentText = alarmComment.comment.text; + displayDataElement.isSystemComment = alarmComment.type === AlarmCommentType.SYSTEM; + if (alarmComment.type === AlarmCommentType.OTHER) { + displayDataElement.commentId = alarmComment.id.id; + displayDataElement.displayName = this.getUserDisplayName(alarmComment); + displayDataElement.edit = false; + displayDataElement.isEdited = alarmComment.comment.edited; + displayDataElement.editedTime = this.datePipe.transform(alarmComment.comment.editedOn, 'yyyy-MM-dd HH:mm:ss'); + displayDataElement.editedDateAgo = this.dateAgoPipe.transform(alarmComment.comment.editedOn) + '\n'; + displayDataElement.showActions = false; + displayDataElement.isSystemComment = false; + displayDataElement.avatarBgColor = this.utilsService.stringToHslColor(displayDataElement.displayName, + 40, 60); + } + this.displayData.push(displayDataElement); + } + } + ) + } + + changeSortDirection() { + let currentDirection = this.alarmCommentSortOrder.direction; + this.alarmCommentSortOrder.direction = currentDirection === Direction.DESC ? Direction.ASC : Direction.DESC; + this.loadAlarmComments(); + } + + exportAlarmActivity() { + let fileName = this.translate.instant('alarm.alarm') + '_' + this.translate.instant('alarm-activity.activity'); + this.importExportService.exportCsv(this.getDataForExport(), fileName.toLowerCase()); + } + + saveComment(): void { + const commentInputValue: string = this.getAlarmCommentFormControl().value; + if (commentInputValue) { + const comment: AlarmComment = { + alarmId: { + id: this.alarmId, + entityType: EntityType.ALARM + }, + type: AlarmCommentType.OTHER, + comment: { + text: commentInputValue + } + } + this.doSave(comment); + this.clearCommentInput(); + } + } + + saveEditedComment(commentId: string): void { + const commentEditInputValue: string = this.getAlarmCommentEditFormControl().value; + if (commentEditInputValue) { + const editedComment: AlarmComment = this.getAlarmCommentById(commentId); + editedComment.comment.text = commentEditInputValue; + this.doSave(editedComment); + this.clearCommentEditInput(); + this.editMode = false; + this.getAlarmCommentFormControl().enable({emitEvent: false}); + } + } + + private doSave(comment: AlarmComment): void { + this.alarmCommentService.saveAlarmComment(this.alarmId, comment, {ignoreLoading: true}).subscribe( + () => { + this.loadAlarmComments(); + } + ) + } + + editComment(commentId: string): void { + const commentDisplayData = this.getDataElementByCommentId(commentId); + commentDisplayData.edit = true; + this.editMode = true; + this.getAlarmCommentEditFormControl().patchValue(commentDisplayData.commentText); + this.getAlarmCommentFormControl().disable({emitEvent: false}); + } + + cancelEdit(commentId: string): void { + const commentDisplayData = this.getDataElementByCommentId(commentId); + commentDisplayData.edit = false; + this.editMode = false; + this.getAlarmCommentFormControl().enable({emitEvent: false}); + } + + deleteComment(commentId: string): void { + const alarmCommentInfo: AlarmComment = this.getAlarmCommentById(commentId); + const commentText: string = alarmCommentInfo.comment.text; + this.dialogService.confirm( + this.translate.instant('alarm-activity.delete-alarm-comment'), + commentText, + this.translate.instant('action.cancel'), + this.translate.instant('action.delete')).subscribe( + (result) => { + if (result) { + this.alarmCommentService.deleteAlarmComments(this.alarmId, commentId, {ignoreLoading: true}) + .subscribe(() => { + this.loadAlarmComments(); + } + ) + } + } + ) + } + + getSortDirectionIcon() { + return this.alarmCommentSortOrder.direction === Direction.DESC ? 'mdi:sort-descending' : 'mdi:sort-ascending' + } + + getSortDirectionTooltipText() { + let text = this.alarmCommentSortOrder.direction === Direction.DESC ? 'alarm-activity.newest-first' : + 'alarm-activity.oldest-first'; + return this.translate.instant(text); + } + + isDirectionAscending() { + return this.alarmCommentSortOrder.direction === Direction.ASC; + } + + onCommentMouseEnter(commentId: string, displayDataIndex: number): void { + if (!this.editMode) { + const alarmUserId = this.getAlarmCommentById(commentId).userId.id; + if (this.authUser.userId === alarmUserId) { + this.displayData[displayDataIndex].showActions = true; + } + } + } + + onCommentMouseLeave(displayDataIndex: number): void { + this.displayData[displayDataIndex].showActions = false; + } + + getUserInitials(userName: string): string { + let initials = ''; + const userNameSplit = userName.split(' '); + initials += userNameSplit[0].charAt(0).toUpperCase(); + if (userNameSplit.length > 1) { + initials += userNameSplit[userNameSplit.length - 1].charAt(0).toUpperCase(); + } + return initials; + } + + getCurrentUserBgColor(userDisplayName: string) { + return this.utilsService.stringToHslColor(userDisplayName, 40, 60); + } + + private getUserDisplayName(alarmCommentInfo: AlarmCommentInfo | User): string { + let name = ''; + if ((alarmCommentInfo.firstName && alarmCommentInfo.firstName.length > 0) || + (alarmCommentInfo.lastName && alarmCommentInfo.lastName.length > 0)) { + if (alarmCommentInfo.firstName) { + name += alarmCommentInfo.firstName; + } + if (alarmCommentInfo.lastName) { + if (name.length > 0) { + name += ' '; + } + name += alarmCommentInfo.lastName; + } + } else { + name = alarmCommentInfo.email; + } + return name; + } + + getAlarmCommentFormControl(): AbstractControl { + return this.alarmCommentFormGroup.get('alarmComment'); + } + + getAlarmCommentEditFormControl(): AbstractControl { + return this.alarmCommentFormGroup.get('alarmCommentEdit'); + } + + private clearCommentInput(): void { + this.getAlarmCommentFormControl().patchValue(''); + } + + private clearCommentEditInput(): void { + this.getAlarmCommentEditFormControl().patchValue(''); + } + + private getAlarmCommentById(id: string): AlarmComment { + return this.alarmComments.find(comment => comment.id.id === id); + } + + private getDataElementByCommentId(commentId: string): AlarmCommentsDisplayData { + return this.displayData.find(commentDisplayData => commentDisplayData.commentId === commentId); + } + + private getDataForExport() { + let dataToExport = []; + for (let row of this.displayData) { + let exportRow = { + [this.translate.instant('alarm-activity.author')]: row.isSystemComment ? + this.translate.instant('alarm-activity.system') : row.displayName, + [this.translate.instant('alarm-activity.created-date')]: row.createdTime, + [this.translate.instant('alarm-activity.edited-date')]: row.editedTime, + [this.translate.instant('alarm-activity.text')]: row.commentText + } + dataToExport.push(exportRow) + } + return dataToExport; + } + +} diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-details-dialog.component.html b/ui-ngx/src/app/modules/home/components/alarm/alarm-details-dialog.component.html index 78f72f55f4..85bdf96ea3 100644 --- a/ui-ngx/src/app/modules/home/components/alarm/alarm-details-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-details-dialog.component.html @@ -1,6 +1,6 @@ - + diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-table.component.scss b/ui-ngx/src/app/modules/home/components/alarm/alarm-table.component.scss index 351d962496..8de701f3e3 100644 --- a/ui-ngx/src/app/modules/home/components/alarm/alarm-table.component.scss +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-table.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2022 The Thingsboard Authors + * Copyright © 2016-2023 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. @@ -15,8 +15,43 @@ */ :host ::ng-deep { tb-entities-table { - .mat-drawer-container { - background-color: white; + &.tb-details-mode { + .mat-drawer-container { + background-color: white; + } + } + .assignee-cell { + display: flex; + justify-content: flex-start; + align-items: center; + .assigned-container { + max-width: 180px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + .user-avatar { + display: inline-flex; + justify-content: center; + align-items: center; + margin-right: 8px; + border-radius: 50%; + width: 28px; + height: 28px; + min-width: 28px; + min-height: 28px; + color: white; + font-size: 13px; + font-weight: 700; + } + } + .material-icons.unassigned-icon { + width: 28px; + height: 28px; + font-size: 28px; + margin-right: 8px; + color: rgba(0, 0, 0, 0.38); + overflow: visible; + } } } } diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-table.component.ts b/ui-ngx/src/app/modules/home/components/alarm/alarm-table.component.ts index d642bcef7a..655d1360d8 100644 --- a/ui-ngx/src/app/modules/home/components/alarm/alarm-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-table.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { Component, Input, OnInit, ViewChild } from '@angular/core'; +import { ChangeDetectorRef, Component, Input, OnInit, ViewChild, ViewContainerRef } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import { DatePipe } from '@angular/common'; import { MatDialog } from '@angular/material/dialog'; @@ -22,10 +22,14 @@ import { EntityId } from '@shared/models/id/entity-id'; import { EntitiesTableComponent } from '@home/components/entity/entities-table.component'; import { DialogService } from '@core/services/dialog.service'; import { AlarmTableConfig } from './alarm-table-config'; -import { AlarmSearchStatus } from '@shared/models/alarm.models'; +import { AlarmSearchStatus, AlarmsMode } from '@shared/models/alarm.models'; import { AlarmService } from '@app/core/http/alarm.service'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; +import { Overlay } from '@angular/cdk/overlay'; +import { UtilsService } from '@core/services/utils.service'; +import { ActivatedRoute } from '@angular/router'; +import { isDefinedAndNotNull } from '@core/utils'; @Component({ selector: 'tb-alarm-table', @@ -37,6 +41,8 @@ export class AlarmTableComponent implements OnInit { activeValue = false; dirtyValue = false; entityIdValue: EntityId; + alarmsMode = AlarmsMode.ENTITY; + detailsMode = true; @Input() set active(active: boolean) { @@ -71,20 +77,38 @@ export class AlarmTableComponent implements OnInit { private translate: TranslateService, private datePipe: DatePipe, private dialog: MatDialog, - private store: Store) { + private store: Store, + private overlay: Overlay, + private viewContainerRef: ViewContainerRef, + private cd: ChangeDetectorRef, + private utilsService: UtilsService, + private route: ActivatedRoute) { } ngOnInit() { this.dirtyValue = !this.activeValue; + const pageMode = !!this.route.snapshot.data.isPage; + if (pageMode) { + this.detailsMode = false; + } + if (isDefinedAndNotNull(this.route.snapshot.data.alarmsMode)) { + this.alarmsMode = this.route.snapshot.data.alarmsMode; + } this.alarmTableConfig = new AlarmTableConfig( this.alarmService, this.dialogService, this.translate, this.datePipe, this.dialog, + this.alarmsMode, this.entityIdValue, AlarmSearchStatus.ANY, - this.store + this.store, + this.viewContainerRef, + this.overlay, + this.cd, + this.utilsService, + pageMode ); } diff --git a/ui-ngx/src/app/modules/home/components/alias/aliases-entity-autocomplete.component.html b/ui-ngx/src/app/modules/home/components/alias/aliases-entity-autocomplete.component.html index 3fea8662a5..e537a27f66 100644 --- a/ui-ngx/src/app/modules/home/components/alias/aliases-entity-autocomplete.component.html +++ b/ui-ngx/src/app/modules/home/components/alias/aliases-entity-autocomplete.component.html @@ -1,6 +1,6 @@ - {{ alias }} + diff --git a/ui-ngx/src/app/modules/home/components/alias/aliases-entity-autocomplete.component.ts b/ui-ngx/src/app/modules/home/components/alias/aliases-entity-autocomplete.component.ts index 7c776385a5..3880c88c3d 100644 --- a/ui-ngx/src/app/modules/home/components/alias/aliases-entity-autocomplete.component.ts +++ b/ui-ngx/src/app/modules/home/components/alias/aliases-entity-autocomplete.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -15,7 +15,7 @@ /// import { AfterViewInit, Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core'; -import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { ControlValueAccessor, UntypedFormBuilder, UntypedFormGroup, NG_VALUE_ACCESSOR } from '@angular/forms'; import { Observable, of } from 'rxjs'; import { catchError, debounceTime, distinctUntilChanged, map, share, switchMap, tap } from 'rxjs/operators'; import { emptyPageData, PageData } from '@shared/models/page/page-data'; @@ -40,7 +40,7 @@ import { isDefinedAndNotNull } from '@core/utils'; }) export class AliasesEntityAutocompleteComponent implements ControlValueAccessor, OnInit, AfterViewInit { - selectEntityInfoFormGroup: FormGroup; + selectEntityInfoFormGroup: UntypedFormGroup; modelValue: EntityInfo | null; @@ -73,7 +73,7 @@ export class AliasesEntityAutocompleteComponent implements ControlValueAccessor, constructor(private store: Store, public translate: TranslateService, private entityService: EntityService, - private fb: FormBuilder) { + private fb: UntypedFormBuilder) { this.selectEntityInfoFormGroup = this.fb.group({ entityInfo: [null] }); diff --git a/ui-ngx/src/app/modules/home/components/alias/aliases-entity-select-panel.component.html b/ui-ngx/src/app/modules/home/components/alias/aliases-entity-select-panel.component.html index bea2e9a9bd..a0986154fc 100644 --- a/ui-ngx/src/app/modules/home/components/alias/aliases-entity-select-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/alias/aliases-entity-select-panel.component.html @@ -1,6 +1,6 @@ - +

{{ (isAdd ? 'alias.add' : 'alias.edit') | translate }}

diff --git a/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.scss b/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.scss index 5a0d6f16fc..45920f4f75 100644 --- a/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2022 The Thingsboard Authors + * Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.ts index cb3d24ba79..6cc8b0e59b 100644 --- a/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -20,9 +20,9 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { - FormBuilder, - FormControl, - FormGroup, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, FormGroupDirective, NgForm, ValidatorFn, @@ -59,7 +59,7 @@ export class EntityAliasDialogComponent extends DialogComponent, - private fb: FormBuilder, + private fb: UntypedFormBuilder, private utils: UtilsService, public translate: TranslateService, private entityService: EntityService) { @@ -103,7 +103,7 @@ export class EntityAliasDialogComponent extends DialogComponent { + return (c: UntypedFormControl) => { const newAlias = c.value.trim(); const found = this.entityAliases.find((entityAlias) => entityAlias.alias === newAlias); if (found) { @@ -122,7 +122,7 @@ export class EntityAliasDialogComponent extends DialogComponent - - - + {{ 'entity.entity-alias' | translate }} + - +

{{ title | translate }}

@@ -32,7 +32,7 @@
- alias.name + alias.name alias.entity-filter alias.resolve-multiple @@ -45,8 +45,7 @@ *ngFor="let entityAliasControl of entityAliasesFormArray().controls; let $index = index"> {{$index + 1}}.
- - + {{ 'entity.alias-required' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.scss b/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.scss index a4314e5efb..c89bd33373 100644 --- a/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2022 The Thingsboard Authors + * Copyright © 2016-2023 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. @@ -45,7 +45,7 @@ } :host ::ng-deep { - .mat-dialog-content { + .mat-mdc-dialog-content { padding-top: 0 !important; padding-bottom: 0 !important; } diff --git a/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.ts index 419649eb84..d15fc17df5 100644 --- a/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -21,10 +21,10 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { AbstractControl, - FormArray, - FormBuilder, - FormControl, - FormGroup, + UntypedFormArray, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, FormGroupDirective, NgForm, Validators @@ -67,7 +67,7 @@ export class EntityAliasesDialogComponent extends DialogComponent} = {}; - entityAliasesFormGroup: FormGroup; + entityAliasesFormGroup: UntypedFormGroup; submitted = false; @@ -76,7 +76,7 @@ export class EntityAliasesDialogComponent extends DialogComponent, - private fb: FormBuilder, + private fb: UntypedFormBuilder, private utils: UtilsService, private translate: TranslateService, private dialogs: DialogService, @@ -157,14 +157,14 @@ export class EntityAliasesDialogComponent extends DialogComponent { if (entityAlias) { if (isAdd) { - (this.entityAliasesFormGroup.get('entityAliases') as FormArray) + (this.entityAliasesFormGroup.get('entityAliases') as UntypedFormArray) .push(this.createEntityAliasFormControl(entityAlias.id, entityAlias)); } else { - const aliasFormControl = (this.entityAliasesFormGroup.get('entityAliases') as FormArray).at(index); + const aliasFormControl = (this.entityAliasesFormGroup.get('entityAliases') as UntypedFormArray).at(index); aliasFormControl.get('alias').patchValue(entityAlias.alias); aliasFormControl.get('filter').patchValue(entityAlias.filter); aliasFormControl.get('resolveMultiple').patchValue(entityAlias.filter.resolveMultiple); diff --git a/ui-ngx/src/app/modules/home/components/attribute/add-attribute-dialog.component.html b/ui-ngx/src/app/modules/home/components/attribute/add-attribute-dialog.component.html index b68bf2fa33..c48299986e 100644 --- a/ui-ngx/src/app/modules/home/components/attribute/add-attribute-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/attribute/add-attribute-dialog.component.html @@ -1,6 +1,6 @@
-
- +
+
{{telemetryTypeTranslationsMap.get(attributeScope) | translate}} - + attribute.attributes-scope - @@ -58,7 +58,7 @@
- +
- +
{{ (attributeScope === latestTelemetryTypes.LATEST_TELEMETRY ? @@ -104,7 +104,7 @@
- +
{{ 'widgets-bundle.current' | translate }} @@ -170,7 +170,7 @@ class="tb-value-cell" (click)="editAttribute($event, attribute)">
- {{attribute.value | tbJson}} + {{attribute.value | tbJson}} edit @@ -249,10 +249,10 @@ widgetBundleSet" fxFlex fxLayoutAlign="center center" style="display: flex;" - class="mat-headline">widgets-bundle.empty + class="mat-headline-5">widgets-bundle.empty widget.select-widgets-bundle + class="mat-headline-5">widget.select-widgets-bundle
diff --git a/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.scss b/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.scss index 10e1a87ce6..67bf37453a 100644 --- a/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.scss +++ b/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2022 The Thingsboard Authors + * Copyright © 2016-2023 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. @@ -24,6 +24,13 @@ width: 100%; height: 100%; background: #fff; + overflow: hidden; + + &.tb-outlined-border { + box-shadow: 0 0 0 0 rgb(0 0 0 / 20%), 0 0 0 0 rgb(0 0 0 / 14%), 0 0 0 0 rgb(0 0 0 / 12%); + border: solid 1px #e0e0e0; + border-radius: 4px; + } .mat-toolbar-tools{ min-height: auto; @@ -42,6 +49,9 @@ .table-container { overflow: auto; + .mat-mdc-table { + table-layout: fixed; + } } .tb-entity-table-info{ @@ -75,26 +85,6 @@ .mat-sort-header-sorted .mat-sort-header-arrow { opacity: 1 !important; } - mat-form-field.tb-attribute-scope { - font-size: 16px; - width: 200px; - - .mat-form-field-wrapper { - padding-bottom: 0; - } - - .mat-form-field-underline { - bottom: 0; - } - - @media #{$mat-xs} { - width: 100%; - - .mat-form-field-infix { - width: auto !important; - } - } - } mat-cell.tb-value-cell { cursor: pointer; mat-icon { diff --git a/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.ts b/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.ts index 9d6812e0f9..e9d67a3fdd 100644 --- a/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/modules/home/components/attribute/edit-attribute-value-panel.component.html b/ui-ngx/src/app/modules/home/components/attribute/edit-attribute-value-panel.component.html index 6004d37362..9cefd62825 100644 --- a/ui-ngx/src/app/modules/home/components/attribute/edit-attribute-value-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/attribute/edit-attribute-value-panel.component.html @@ -1,6 +1,6 @@ + [formGroup]="attributeFormGroup" (ngSubmit)="update()" style="padding: 5px;">

audit-log.audit-log-details

-
{{ headerTitle }}
{{ headerSubtitle }}
@@ -46,7 +52,7 @@
- + diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.scss b/ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.scss index 27b3d4f2f8..d4ef594449 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.scss +++ b/ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2022 The Thingsboard Authors + * Copyright © 2016-2023 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. @@ -79,7 +79,6 @@ .tb-header-button { .tb-btn-header { position: relative !important; - display: inline-block !important; animation: tbMoveFromTopFade .3s ease both; &.tb-hide { @@ -92,25 +91,25 @@ :host ::ng-deep { .tb-help { - .mat-icon-button.mat-primary { + .mat-mdc-icon-button.mat-primary { color: rgba(0, 0, 0, 0.52); } } - .mat-card-content { + .mat-mdc-card-content { position: relative; overflow: hidden; - > .mat-tab-group { - > .mat-tab-body-wrapper { + > .mat-mdc-tab-group { + > .mat-mdc-tab-body-wrapper { position: absolute; top: 49px; left: 0; right: 0; bottom: 0; } - > .mat-tab-header { - .mat-tab-label { + > .mat-mdc-tab-header { + .mat-mdc-tab { min-width: 40px; } } diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.ts b/ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.ts index d0b141256e..68dc59407e 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.ts +++ b/ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -29,7 +29,7 @@ import { AppState } from '@core/core.state'; import { EntityTableConfig } from '@home/models/entity/entities-table-config.models'; import { BaseData, HasId } from '@shared/models/base-data'; import { ActivatedRoute, Router } from '@angular/router'; -import { FormGroup } from '@angular/forms'; +import { UntypedFormGroup } from '@angular/forms'; import { AssetId } from '@shared/models/id/asset-id'; import { TranslateService } from '@ngx-translate/core'; import { deepClone } from '@core/utils'; @@ -140,10 +140,14 @@ export class EntityDetailsPageComponent extends EntityDetailsPanelComponent impl }); } - confirmForm(): FormGroup { + confirmForm(): UntypedFormGroup { return this.detailsForm; } + goBack(): void { + this.router.navigate(['../'], { relativeTo: this.route }); + } + private onUpdateEntity() { this.broadcast.broadcast('updateBreadcrumb'); this.isReadOnly = this.entitiesTableConfig.detailsReadonly(this.entity); @@ -164,7 +168,7 @@ export class EntityDetailsPageComponent extends EntityDetailsPanelComponent impl if (result) { this.entitiesTableConfig.deleteEntity(entity.id).subscribe( () => { - this.router.navigate(['../'], {relativeTo: this.route}); + this.goBack(); } ); } diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.html b/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.html index cba0b85ce9..d4977a3ec5 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.html @@ -1,6 +1,6 @@
- - + {{booleanOperationTranslations.get(booleanOperationEnum[operation]) | translate}} diff --git a/ui-ngx/src/app/modules/home/components/filter/boolean-filter-predicate.component.ts b/ui-ngx/src/app/modules/home/components/filter/boolean-filter-predicate.component.ts index 30807d698e..1f6a50a031 100644 --- a/ui-ngx/src/app/modules/home/components/filter/boolean-filter-predicate.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/boolean-filter-predicate.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -17,8 +17,8 @@ import { Component, forwardRef, Input, OnInit } from '@angular/core'; import { ControlValueAccessor, - FormBuilder, - FormGroup, + UntypedFormBuilder, + UntypedFormGroup, NG_VALIDATORS, NG_VALUE_ACCESSOR, ValidationErrors, @@ -60,7 +60,7 @@ export class BooleanFilterPredicateComponent implements ControlValueAccessor, Va valueTypeEnum = EntityKeyValueType; - booleanFilterPredicateFormGroup: FormGroup; + booleanFilterPredicateFormGroup: UntypedFormGroup; booleanOperations = Object.keys(BooleanOperation); booleanOperationEnum = BooleanOperation; @@ -68,7 +68,7 @@ export class BooleanFilterPredicateComponent implements ControlValueAccessor, Va private propagateChange = null; - constructor(private fb: FormBuilder) { + constructor(private fb: UntypedFormBuilder) { } ngOnInit(): void { diff --git a/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate-dialog.component.html b/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate-dialog.component.html index e9b48f0d26..0a4bd2d3cd 100644 --- a/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate-dialog.component.html @@ -1,6 +1,6 @@ - +

filter.complex-filter

diff --git a/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate-dialog.component.ts b/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate-dialog.component.ts index a91d9cddc5..6eca715187 100644 --- a/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate-dialog.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -19,7 +19,7 @@ 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 { FormBuilder, FormControl, FormGroup, FormGroupDirective, NgForm, Validators } from '@angular/forms'; +import { UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, FormGroupDirective, NgForm, Validators } from '@angular/forms'; import { Router } from '@angular/router'; import { DialogComponent } from '@app/shared/components/dialog.component'; import { @@ -40,7 +40,7 @@ export class ComplexFilterPredicateDialogComponent extends DialogComponent implements OnInit, ErrorStateMatcher { - complexFilterFormGroup: FormGroup; + complexFilterFormGroup: UntypedFormGroup; complexOperations = Object.keys(ComplexOperation); complexOperationEnum = ComplexOperation; @@ -55,7 +55,7 @@ export class ComplexFilterPredicateDialogComponent extends @Inject(MAT_DIALOG_DATA) public data: ComplexFilterPredicateDialogData, @SkipSelf() private errorStateMatcher: ErrorStateMatcher, public dialogRef: MatDialogRef, - private fb: FormBuilder) { + private fb: UntypedFormBuilder) { super(store, router, dialogRef); this.isAdd = this.data.isAdd; @@ -74,7 +74,7 @@ export class ComplexFilterPredicateDialogComponent extends ngOnInit(): void { } - isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean { + 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; diff --git a/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate.component.html b/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate.component.html index 13ca2d8ddd..1caf878b88 100644 --- a/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate.component.html +++ b/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate.component.html @@ -1,6 +1,6 @@ - - - + {{ 'filter.filter' | translate }} + FilterSelectComponent), @@ -54,7 +54,7 @@ import { Filter } from '@shared/models/query/query.models'; }) export class FilterSelectComponent implements ControlValueAccessor, OnInit, AfterViewInit, ErrorStateMatcher { - selectFilterFormGroup: FormGroup; + selectFilterFormGroup: UntypedFormGroup; modelValue: string | null; @@ -101,7 +101,7 @@ export class FilterSelectComponent implements ControlValueAccessor, OnInit, Afte @SkipSelf() private errorStateMatcher: ErrorStateMatcher, public translate: TranslateService, public truncate: TruncatePipe, - private fb: FormBuilder) { + private fb: UntypedFormBuilder) { this.selectFilterFormGroup = this.fb.group({ filter: [null] }); @@ -140,7 +140,7 @@ export class FilterSelectComponent implements ControlValueAccessor, OnInit, Afte ); } - isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean { + isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean { const originalErrorState = this.errorStateMatcher.isErrorState(control, form); const customErrorState = this.tbRequired && !this.modelValue; return originalErrorState || customErrorState; diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-text.component.html b/ui-ngx/src/app/modules/home/components/filter/filter-text.component.html index a4f26d10e9..54b69f1de8 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-text.component.html +++ b/ui-ngx/src/app/modules/home/components/filter/filter-text.component.html @@ -1,6 +1,6 @@ - +

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

@@ -46,7 +46,7 @@ [matAutocompleteDisabled]="!showAutocomplete"> @@ -60,7 +60,7 @@
- + filter.value-type.value-type diff --git a/ui-ngx/src/app/modules/home/components/filter/key-filter-dialog.component.scss b/ui-ngx/src/app/modules/home/components/filter/key-filter-dialog.component.scss index bfc62d5570..aeb088d38d 100644 --- a/ui-ngx/src/app/modules/home/components/filter/key-filter-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/components/filter/key-filter-dialog.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2022 The Thingsboard Authors + * Copyright © 2016-2023 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. @@ -14,11 +14,13 @@ * limitations under the License. */ :host ::ng-deep { - .entity-key { - mat-form-field { - .mat-form-field-wrapper { - .mat-form-field-infix { - width: auto; + .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/filter/key-filter-dialog.component.ts b/ui-ngx/src/app/modules/home/components/filter/key-filter-dialog.component.ts index b410d8a529..d8b1a21f6a 100644 --- a/ui-ngx/src/app/modules/home/components/filter/key-filter-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/key-filter-dialog.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -19,7 +19,7 @@ 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 { FormBuilder, FormControl, FormGroup, FormGroupDirective, NgForm, Validators } from '@angular/forms'; +import { UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, FormGroupDirective, NgForm, Validators } from '@angular/forms'; import { Router } from '@angular/router'; import { DialogComponent } from '@app/shared/components/dialog.component'; import { @@ -63,9 +63,9 @@ export class KeyFilterDialogComponent extends private dirty = false; private entityKeysName: Observable>; - private destroy$ = new Subject(); + private destroy$ = new Subject(); - keyFilterFormGroup: FormGroup; + keyFilterFormGroup: UntypedFormGroup; entityKeyTypes = this.data.telemetryKeysOnly ? @@ -96,7 +96,7 @@ export class KeyFilterDialogComponent extends private deviceProfileService: DeviceProfileService, private dialogs: DialogService, private translate: TranslateService, - private fb: FormBuilder) { + private fb: UntypedFormBuilder) { super(store, router, dialogRef); this.keyFilterFormGroup = this.fb.group( @@ -191,7 +191,7 @@ export class KeyFilterDialogComponent extends this.destroy$.complete(); } - isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean { + 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; diff --git a/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.html b/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.html index 62a6325e4c..c29ae4ca72 100644 --- a/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.html +++ b/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.html @@ -1,6 +1,6 @@
- - + {{numericOperationTranslations.get(numericOperationEnum[operation]) | translate}} diff --git a/ui-ngx/src/app/modules/home/components/filter/numeric-filter-predicate.component.ts b/ui-ngx/src/app/modules/home/components/filter/numeric-filter-predicate.component.ts index f83ac2c8cb..54b0ce76ea 100644 --- a/ui-ngx/src/app/modules/home/components/filter/numeric-filter-predicate.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/numeric-filter-predicate.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -17,8 +17,8 @@ import { Component, forwardRef, Input, OnInit } from '@angular/core'; import { ControlValueAccessor, - FormBuilder, - FormGroup, + UntypedFormBuilder, + UntypedFormGroup, NG_VALIDATORS, NG_VALUE_ACCESSOR, ValidationErrors, @@ -60,7 +60,7 @@ export class NumericFilterPredicateComponent implements ControlValueAccessor, Va @Input() valueType: EntityKeyValueType; - numericFilterPredicateFormGroup: FormGroup; + numericFilterPredicateFormGroup: UntypedFormGroup; valueTypeEnum = EntityKeyValueType; @@ -70,7 +70,7 @@ export class NumericFilterPredicateComponent implements ControlValueAccessor, Va private propagateChange = null; - constructor(private fb: FormBuilder) { + constructor(private fb: UntypedFormBuilder) { } ngOnInit(): void { diff --git a/ui-ngx/src/app/modules/home/components/filter/string-filter-predicate.component.html b/ui-ngx/src/app/modules/home/components/filter/string-filter-predicate.component.html index a121cd282e..26db51f8b0 100644 --- a/ui-ngx/src/app/modules/home/components/filter/string-filter-predicate.component.html +++ b/ui-ngx/src/app/modules/home/components/filter/string-filter-predicate.component.html @@ -1,6 +1,6 @@
- - + {{stringOperationTranslations.get(stringOperationEnum[operation]) | translate}} diff --git a/ui-ngx/src/app/modules/home/components/filter/string-filter-predicate.component.ts b/ui-ngx/src/app/modules/home/components/filter/string-filter-predicate.component.ts index af5979706d..db233d526f 100644 --- a/ui-ngx/src/app/modules/home/components/filter/string-filter-predicate.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/string-filter-predicate.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -17,8 +17,8 @@ import { Component, forwardRef, Input, OnInit } from '@angular/core'; import { ControlValueAccessor, - FormBuilder, - FormGroup, + UntypedFormBuilder, + UntypedFormGroup, NG_VALIDATORS, NG_VALUE_ACCESSOR, ValidationErrors, @@ -60,7 +60,7 @@ export class StringFilterPredicateComponent implements ControlValueAccessor, Val valueTypeEnum = EntityKeyValueType; - stringFilterPredicateFormGroup: FormGroup; + stringFilterPredicateFormGroup: UntypedFormGroup; stringOperations = Object.keys(StringOperation); stringOperationEnum = StringOperation; @@ -68,7 +68,7 @@ export class StringFilterPredicateComponent implements ControlValueAccessor, Val private propagateChange = null; - constructor(private fb: FormBuilder) { + constructor(private fb: UntypedFormBuilder) { } ngOnInit(): void { diff --git a/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.html b/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.html index 960bcd5f9a..e5f1449eb7 100644 --- a/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.html @@ -1,6 +1,6 @@ + diff --git a/ui-ngx/src/app/modules/home/components/notification/notification-bell.component.ts b/ui-ngx/src/app/modules/home/components/notification/notification-bell.component.ts new file mode 100644 index 0000000000..6c35641348 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/notification/notification-bell.component.ts @@ -0,0 +1,111 @@ +/// +/// Copyright © 2016-2023 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, + NgZone, + OnDestroy, + Renderer2, + ViewContainerRef +} from '@angular/core'; +import { NotificationWebsocketService } from '@core/ws/notification-websocket.service'; +import { BehaviorSubject, ReplaySubject, Subscription } from 'rxjs'; +import { distinctUntilChanged, share, tap } from 'rxjs/operators'; +import { MatButton } from '@angular/material/button'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { ShowNotificationPopoverComponent } from '@home/components/notification/show-notification-popover.component'; +import { NotificationSubscriber } from '@shared/models/websocket/notification-ws.models'; +import { select, Store } from '@ngrx/store'; +import { selectIsAuthenticated } from '@core/auth/auth.selectors'; +import { AppState } from '@core/core.state'; + +@Component({ + selector: 'tb-notification-bell', + templateUrl: './notification-bell.component.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class NotificationBellComponent implements OnDestroy { + + private notificationSubscriber: NotificationSubscriber; + private notificationCountSubscriber: Subscription; + private countSubject = new BehaviorSubject(0); + + count$ = this.countSubject.asObservable().pipe( + distinctUntilChanged(), + tap(() => setTimeout(() => this.cd.markForCheck())), + share({ + connector: () => new ReplaySubject(1) + }) + ); + + constructor( + private notificationWsService: NotificationWebsocketService, + private zone: NgZone, + private cd: ChangeDetectorRef, + private popoverService: TbPopoverService, + private renderer: Renderer2, + private viewContainerRef: ViewContainerRef, + private store: Store,) { + this.store.pipe(select(selectIsAuthenticated)).subscribe((value) => { + if (value) { + this.initSubscription(); + } + }); + } + + ngOnDestroy() { + this.unsubscribeSubscription(); + } + + showNotification($event: Event, createVersionButton: MatButton) { + if ($event) { + $event.stopPropagation(); + } + this.unsubscribeSubscription(); + const trigger = createVersionButton._elementRef.nativeElement; + if (this.popoverService.hasPopover(trigger)) { + this.popoverService.hidePopover(trigger); + } else { + const showNotificationPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, ShowNotificationPopoverComponent, 'bottom', true, null, + { + onClose: () => { + showNotificationPopover.hide(); + this.initSubscription(); + }, + counter: this.countSubject + }, + {maxHeight: '90vh', height: '100%', padding: '10px'}, + {width: '400px', minWidth: '100%', maxWidth: '100%'}, + {height: '100%', flexDirection: 'column', boxSizing: 'border-box', display: 'flex', margin: '0 -16px'}, false); + showNotificationPopover.tbComponentRef.instance.popoverComponent = showNotificationPopover; + } + } + + private initSubscription() { + this.notificationSubscriber = NotificationSubscriber.createNotificationCountSubscription(this.notificationWsService, this.zone); + this.notificationCountSubscriber = this.notificationSubscriber.notificationCount$.subscribe(value => this.countSubject.next(value)); + + this.notificationSubscriber.subscribe(); + } + + private unsubscribeSubscription() { + this.notificationCountSubscriber.unsubscribe(); + this.notificationSubscriber.unsubscribe(); + } +} diff --git a/ui-ngx/src/app/modules/home/components/notification/send-notification-button.component.html b/ui-ngx/src/app/modules/home/components/notification/send-notification-button.component.html new file mode 100644 index 0000000000..60f069c28e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/notification/send-notification-button.component.html @@ -0,0 +1,26 @@ + +
+ + +
diff --git a/ui-ngx/src/app/modules/home/components/notification/send-notification-button.component.ts b/ui-ngx/src/app/modules/home/components/notification/send-notification-button.component.ts new file mode 100644 index 0000000000..2842e220f9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/notification/send-notification-button.component.ts @@ -0,0 +1,73 @@ +/// +/// Copyright © 2016-2023 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 { + RequestNotificationDialogData, + SentNotificationDialogComponent +} from '@home/pages/notification/sent/sent-notification-dialog.componet'; +import { NotificationTemplate } from '@shared/models/notification.models'; +import { MatDialog } from '@angular/material/dialog'; +import { ActiveComponentService } from '@core/services/active-component.service'; +import { EntitiesTableComponent } from '@home/components/entity/entities-table.component'; +import { EntityType } from '@shared/models/entity-type.models'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { Authority } from '@shared/models/authority.enum'; + +@Component({ + selector: 'tb-send-notification-button', + templateUrl: './send-notification-button.component.html', +}) +export class SendNotificationButtonComponent { + + authUser = getCurrentAuthUser(this.store); + + constructor(private dialog: MatDialog, + private store: Store, + private activeComponentService: ActiveComponentService) { + } + + sendNotification($event: Event) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(SentNotificationDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + isAdd: true + } + }).afterClosed().subscribe((res) => { + if (res) { + const comp = this.activeComponentService.getCurrentActiveComponent(); + if (comp instanceof EntitiesTableComponent) { + const entitiesTableComponent = comp as EntitiesTableComponent; + if (entitiesTableComponent.entitiesTableConfig.entityType === EntityType.NOTIFICATION_REQUEST) { + entitiesTableComponent.entitiesTableConfig.updateData(); + } + } + } + }); + } + + public show(): boolean { + return this.authUser.authority !== Authority.CUSTOMER_USER; + } + +} diff --git a/ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.html b/ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.html new file mode 100644 index 0000000000..096cecd91a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.html @@ -0,0 +1,47 @@ + +
+
notification.notification
+ +
+ +
+
+
+ + + +
+
+
+ +
+ +
+ + empty notification + notification.no-notifications-yet + diff --git a/ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.ts b/ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.ts new file mode 100644 index 0000000000..5e94289055 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.ts @@ -0,0 +1,101 @@ +/// +/// Copyright © 2016-2023 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, Input, NgZone, OnDestroy, OnInit } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Notification, NotificationRequest } from '@shared/models/notification.models'; +import { NotificationWebsocketService } from '@core/ws/notification-websocket.service'; +import { BehaviorSubject, Observable, ReplaySubject, Subscription } from 'rxjs'; +import { share, tap } from 'rxjs/operators'; +import { Router } from '@angular/router'; +import { NotificationSubscriber } from '@shared/models/websocket/notification-ws.models'; + +@Component({ + selector: 'tb-show-notification-popover', + templateUrl: './show-notification-popover.component.html', + styleUrls: [] +}) +export class ShowNotificationPopoverComponent extends PageComponent implements OnDestroy, OnInit { + + @Input() + onClose: () => void; + + @Input() + counter: BehaviorSubject; + + @Input() + popoverComponent: TbPopoverComponent; + + private notificationSubscriber: NotificationSubscriber; + private notificationCountSubscriber: Subscription; + + notifications$: Observable; + + constructor(protected store: Store, + private notificationWsService: NotificationWebsocketService, + private zone: NgZone, + private cd: ChangeDetectorRef, + private router: Router) { + super(store); + } + + ngOnInit() { + this.notificationSubscriber = NotificationSubscriber.createNotificationsSubscription(this.notificationWsService, this.zone, 6); + this.notifications$ = this.notificationSubscriber.notifications$.pipe( + share({ + connector: () => new ReplaySubject(1) + }), + tap(() => setTimeout(() => this.cd.markForCheck())) + ); + this.notificationCountSubscriber = this.notificationSubscriber.notificationCount$.subscribe(value => this.counter.next(value)); + this.notificationSubscriber.subscribe(); + } + + ngOnDestroy() { + super.ngOnDestroy(); + this.notificationCountSubscriber.unsubscribe(); + this.notificationSubscriber.unsubscribe(); + this.onClose(); + } + + markAsRead(id: string) { + const cmd = NotificationSubscriber.createMarkAsReadCommand(this.notificationWsService, [id]); + cmd.subscribe(); + } + + markAsAllRead($event: Event) { + if ($event) { + $event.stopPropagation(); + } + const cmd = NotificationSubscriber.createMarkAllAsReadCommand(this.notificationWsService); + cmd.subscribe(); + } + + viewAll($event: Event) { + if ($event) { + $event.stopPropagation(); + } + this.onClose(); + this.router.navigateByUrl(this.router.parseUrl('/notification/inbox')).then(() => {}); + } + + trackById(index: number, item: NotificationRequest): string { + return item.id.id; + } +} diff --git a/ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.html b/ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.html index f15a969b19..779bc94f8f 100644 --- a/ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.html @@ -1,6 +1,6 @@ -
- -

device-profile.add

- -
- -
- - -
-
- - - - {{ 'device-profile.device-profile-details' | translate }} -
- - device-profile.name - - - {{ 'device-profile.name-required' | translate }} - - - {{ 'device-profile.name-max-length' | translate }} - - - - - -
{{'device-profile.mobile-dashboard-hint' | translate}}
-
- - - - device-profile.type - - - {{deviceProfileTypeTranslations.get(type) | translate}} - - - - {{ 'device-profile.type-required' | translate }} - - - - - - device-profile.description - - -
- -
- -
- {{ 'device-profile.transport-configuration' | translate }} + +

device-profile.add

+ +
+ +
+ + +
+
+ + + + {{ 'device-profile.device-profile-details' | translate }} +
- device-profile.transport-type - - - {{deviceTransportTypeTranslations.get(type) | translate}} + device-profile.name + + + {{ 'device-profile.name-required' | translate }} + + + {{ 'device-profile.name-max-length' | translate }} + + + + + + {{'device-profile.mobile-dashboard-hint' | translate}} + + + + + {{'device-profile.default-edge-rule-chain-hint' | translate}} + + + device-profile.type + + + {{deviceProfileTypeTranslations.get(type) | translate}} - - {{deviceTransportTypeHints.get(transportConfigFormGroup.get('transportType').value) | translate}} - - - {{ 'device-profile.transport-type-required' | translate }} + + {{ 'device-profile.type-required' | translate }} - - - - - -
- {{'device-profile.alarm-rules-with-count' | translate: - {count: alarmRulesFormGroup.get('alarms').value ? - alarmRulesFormGroup.get('alarms').value.length : 0} }} - - -
-
- -
- {{ 'device-profile.device-provisioning' | translate }} - - -
-
- -
-
+ + + + device-profile.description + + +
+ + + +
+ {{ 'device-profile.transport-configuration' | translate }} + + device-profile.transport-type + + + {{deviceTransportTypeTranslations.get(type) | translate}} + + + + {{deviceTransportTypeHints.get(transportConfigFormGroup.get('transportType').value) | translate}} + + + {{ 'device-profile.transport-type-required' | translate }} + + + + +
+
+ +
+ {{'device-profile.alarm-rules-with-count' | translate: + {count: alarmRulesFormGroup.get('alarms').value ? + alarmRulesFormGroup.get('alarms').value.length : 0} }} + + +
+
+ +
+ {{ 'device-profile.device-provisioning' | translate }} + + +
+
+ +
+
+
@@ -137,8 +147,8 @@ [disabled]="(isLoading$ | async)" (click)="nextStep()">{{ 'action.next-with-label' | translate:{label: (getFormLabel(this.selectedIndex+1) | translate)} }}
- -
+ +
diff --git a/ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.scss b/ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.scss index db53d82cb5..ca54ae2053 100644 --- a/ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2022 The Thingsboard Authors + * Copyright © 2016-2023 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. @@ -15,16 +15,25 @@ */ @import "../../../../../scss/constants"; -:host-context(.tb-fullscreen-dialog .mat-dialog-container) { +:host { + height: 100%; + display: grid; + + .dialog-actions-row { + padding: 8px; + } +} + +:host-context(.tb-fullscreen-dialog .mat-mdc-dialog-container) { @media #{$mat-lt-sm} { - .mat-dialog-content { + .mat-mdc-dialog-content { max-height: 75vh; } } } :host ::ng-deep { - .mat-dialog-content { + .mat-mdc-dialog-content { display: flex; flex-direction: column; height: 100%; @@ -32,31 +41,21 @@ .mat-stepper-horizontal { display: flex; - flex-direction: column; height: 100%; overflow: hidden; - @media #{$mat-lt-sm} { - .mat-step-label { - white-space: normal; - overflow: visible; - .mat-step-text-label { - overflow: visible; - } - } + + .mat-horizontal-stepper-wrapper { + flex: 1 1 100%; } + .mat-horizontal-content-container { - height: 530px; + height: 680px; max-height: 100%; width: 100%;; overflow-y: auto; + scrollbar-gutter: stable; @media #{$mat-gt-sm} { - min-width: 800px; - } - } - .mat-horizontal-stepper-content[aria-expanded=true] { - height: 100%; - form { - height: 100%; + min-width: 500px; } } } diff --git a/ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.ts b/ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.ts index 188fad26dc..3426593aed 100644 --- a/ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -27,7 +27,7 @@ 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 { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { DialogComponent } from '@shared/components/dialog.component'; import { Router } from '@angular/router'; import { @@ -44,12 +44,17 @@ import { } from '@shared/models/device.models'; import { DeviceProfileService } from '@core/http/device-profile.service'; import { EntityType } from '@shared/models/entity-type.models'; -import { MatHorizontalStepper } from '@angular/material/stepper'; +import { MatStepper, StepperOrientation } from '@angular/material/stepper'; import { RuleChainId } from '@shared/models/id/rule-chain-id'; import { StepperSelectionEvent } from '@angular/cdk/stepper'; import { deepTrim } from '@core/utils'; import { ServiceType } from '@shared/models/queue.models'; import { DashboardId } from '@shared/models/id/dashboard-id'; +import { RuleChainType } from '@shared/models/rule-chain.models'; +import { Observable } from 'rxjs'; +import { BreakpointObserver } from '@angular/cdk/layout'; +import { MediaBreakpoints } from '@shared/models/constants'; +import { map } from 'rxjs/operators'; export interface AddDeviceProfileDialogData { deviceProfileName: string; @@ -65,8 +70,9 @@ export interface AddDeviceProfileDialogData { export class AddDeviceProfileDialogComponent extends DialogComponent implements AfterViewInit { - @ViewChild('addDeviceProfileStepper', {static: true}) addDeviceProfileStepper: MatHorizontalStepper; - + @ViewChild('addDeviceProfileStepper', {static: true}) addDeviceProfileStepper: MatStepper; + stepperOrientation: Observable; + stepperLabelPosition: Observable<'bottom' | 'end'>; selectedIndex = 0; showNext = true; @@ -83,26 +89,35 @@ export class AddDeviceProfileDialogComponent extends deviceTransportTypeTranslations = deviceTransportTypeTranslationMap; - deviceProfileDetailsFormGroup: FormGroup; + deviceProfileDetailsFormGroup: UntypedFormGroup; - transportConfigFormGroup: FormGroup; + transportConfigFormGroup: UntypedFormGroup; - alarmRulesFormGroup: FormGroup; + alarmRulesFormGroup: UntypedFormGroup; - provisionConfigFormGroup: FormGroup; + provisionConfigFormGroup: UntypedFormGroup; serviceType = ServiceType.TB_RULE_ENGINE; + edgeRuleChainType = RuleChainType.EDGE; + constructor(protected store: Store, protected router: Router, @Inject(MAT_DIALOG_DATA) public data: AddDeviceProfileDialogData, public dialogRef: MatDialogRef, private componentFactoryResolver: ComponentFactoryResolver, private injector: Injector, + private breakpointObserver: BreakpointObserver, @SkipSelf() private errorStateMatcher: ErrorStateMatcher, private deviceProfileService: DeviceProfileService, - private fb: FormBuilder) { + private fb: UntypedFormBuilder) { super(store, router, dialogRef); + this.stepperOrientation = this.breakpointObserver.observe(MediaBreakpoints['gt-sm']) + .pipe(map(({matches}) => matches ? 'horizontal' : 'vertical')); + + this.stepperLabelPosition = this.breakpointObserver.observe(MediaBreakpoints['gt-md']) + .pipe(map(({matches}) => matches ? 'end' : 'bottom')); + this.deviceProfileDetailsFormGroup = this.fb.group( { name: [data.deviceProfileName, [Validators.required, Validators.maxLength(255)]], @@ -111,6 +126,7 @@ export class AddDeviceProfileDialogComponent extends defaultRuleChainId: [null, []], defaultDashboardId: [null, []], defaultQueueName: [null, []], + defaultEdgeRuleChainId: [null, []], description: ['', []] } ); @@ -165,7 +181,7 @@ export class AddDeviceProfileDialogComponent extends } } - selectedForm(): FormGroup { + selectedForm(): UntypedFormGroup { switch (this.selectedIndex) { case 0: return this.deviceProfileDetailsFormGroup; @@ -205,6 +221,9 @@ export class AddDeviceProfileDialogComponent extends if (this.deviceProfileDetailsFormGroup.get('defaultDashboardId').value) { deviceProfile.defaultDashboardId = new DashboardId(this.deviceProfileDetailsFormGroup.get('defaultDashboardId').value); } + if (this.deviceProfileDetailsFormGroup.get('defaultEdgeRuleChainId').value) { + deviceProfile.defaultEdgeRuleChainId = new RuleChainId(this.deviceProfileDetailsFormGroup.get('defaultEdgeRuleChainId').value); + } this.deviceProfileService.saveDeviceProfile(deepTrim(deviceProfile)).subscribe( (savedDeviceProfile) => { this.dialogRef.close(savedDeviceProfile); diff --git a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-duration-predicate-value.component.html b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-duration-predicate-value.component.html index 552e0e4c47..d4d8644aad 100644 --- a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-duration-predicate-value.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-duration-predicate-value.component.html @@ -1,6 +1,6 @@
- - +
- - + {{'filter.no-dynamic-value' | translate}} @@ -53,8 +51,7 @@
- - +
diff --git a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-duration-predicate-value.component.ts b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-duration-predicate-value.component.ts index 0b24911ee6..e14dcc6ea7 100644 --- a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-duration-predicate-value.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-duration-predicate-value.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -15,7 +15,7 @@ /// import { Component, forwardRef, Input, OnInit } from '@angular/core'; -import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import { ControlValueAccessor, UntypedFormBuilder, UntypedFormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; import { DynamicValueSourceType, dynamicValueSourceTypeTranslationMap, @@ -28,7 +28,7 @@ import { AlarmConditionType } from '@shared/models/device.models'; @Component({ selector: 'tb-alarm-duration-predicate-value', templateUrl: './alarm-duration-predicate-value.component.html', - styleUrls: ['./alarm-duration-predicate-value.component.scss'], + styleUrls: [], providers: [ { provide: NG_VALUE_ACCESSOR, @@ -68,7 +68,7 @@ export class AlarmDurationPredicateValueComponent implements ControlValueAccesso dynamicValueSourceTypeTranslations = dynamicValueSourceTypeTranslationMap; - alarmDurationPredicateValueFormGroup: FormGroup; + alarmDurationPredicateValueFormGroup: UntypedFormGroup; dynamicMode = false; @@ -76,7 +76,7 @@ export class AlarmDurationPredicateValueComponent implements ControlValueAccesso private propagateChange = null; - constructor(private fb: FormBuilder) { + constructor(private fb: UntypedFormBuilder) { } ngOnInit(): void { diff --git a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-dynamic-value.component.html b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-dynamic-value.component.html index 6a64ba4800..be17201671 100644 --- a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-dynamic-value.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-dynamic-value.component.html @@ -1,6 +1,6 @@ -
+

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

@@ -56,8 +56,7 @@ >
- - + diff --git a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-rule-condition-dialog.component.scss b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-rule-condition-dialog.component.scss index fc2edaee5f..f0ac3daae1 100644 --- a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-rule-condition-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-rule-condition-dialog.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2022 The Thingsboard Authors + * Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-rule-condition-dialog.component.ts b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-rule-condition-dialog.component.ts index 1df5a6bd4f..dcbceabcd5 100644 --- a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-rule-condition-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-rule-condition-dialog.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -19,7 +19,7 @@ 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 { FormBuilder, FormControl, FormGroup, FormGroupDirective, NgForm, Validators } from '@angular/forms'; +import { UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, FormGroupDirective, NgForm, Validators } from '@angular/forms'; import { Router } from '@angular/router'; import { DialogComponent } from '@app/shared/components/dialog.component'; import { TranslateService } from '@ngx-translate/core'; @@ -52,7 +52,7 @@ export class AlarmRuleConditionDialogComponent extends DialogComponent, - private fb: FormBuilder, + private fb: UntypedFormBuilder, public translate: TranslateService) { super(store, router, dialogRef); @@ -87,7 +87,7 @@ export class AlarmRuleConditionDialogComponent extends DialogComponent { }; constructor(private dialog: MatDialog, - private fb: FormBuilder, + private fb: UntypedFormBuilder, private translate: TranslateService) { } @@ -110,7 +110,7 @@ export class AlarmRuleConditionComponent implements ControlValueAccessor, OnInit return this.modelValue && this.modelValue.condition.length; } - public validate(c: FormControl) { + public validate(c: UntypedFormControl) { return this.conditionSet() ? null : { alarmRuleCondition: { valid: false, diff --git a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-rule.component.html b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-rule.component.html index 69982686b1..5e7ca9b096 100644 --- a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-rule.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-rule.component.html @@ -1,6 +1,6 @@ - +

{{ (readonly ? 'device-profile.schedule' : 'device-profile.edit-schedule') | translate }}

@@ -28,13 +28,9 @@
-
-
- - -
-
+ +
-
+
device-profile.snmp.please-add-communication-config
diff --git a/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-communication-config.component.scss b/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-communication-config.component.scss index 6dcf3642ad..9a93f17b23 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-communication-config.component.scss +++ b/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-communication-config.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2022 The Thingsboard Authors + * Copyright © 2016-2023 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. @@ -31,8 +31,8 @@ } :host ::ng-deep { - .mat-form-field.spec { - .mat-form-field-infix { + .mat-mdc-form-field.spec { + .mat-mdc-form-field-infix { width: 160px; } } diff --git a/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-communication-config.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-communication-config.component.ts index 6fe20f5e2f..7e0f8dd3b5 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-communication-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-communication-config.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -18,16 +18,16 @@ import { Component, forwardRef, Input, OnDestroy, OnInit } from '@angular/core'; import { AbstractControl, ControlValueAccessor, - FormArray, - FormBuilder, - FormGroup, + UntypedFormArray, + UntypedFormBuilder, + UntypedFormGroup, NG_VALIDATORS, NG_VALUE_ACCESSOR, Validator, Validators } from '@angular/forms'; import { SnmpCommunicationConfig, SnmpSpecType, SnmpSpecTypeTranslationMap } from '@shared/models/device.models'; -import { Subject, Subscription } from 'rxjs'; +import { Subject } from 'rxjs'; import { isUndefinedOrNull } from '@core/utils'; import { takeUntil } from 'rxjs/operators'; @@ -52,34 +52,33 @@ export class SnmpDeviceProfileCommunicationConfigComponent implements OnInit, On snmpSpecTypes = Object.values(SnmpSpecType); snmpSpecTypeTranslationMap = SnmpSpecTypeTranslationMap; - deviceProfileCommunicationConfig: FormGroup; + deviceProfileCommunicationConfig: UntypedFormGroup; @Input() disabled: boolean; private usedSpecType: SnmpSpecType[] = []; - private valueChange$: Subscription = null; - private destroy$ = new Subject(); + private destroy$ = new Subject(); private propagateChange = (v: any) => { }; - constructor(private fb: FormBuilder) { } + constructor(private fb: UntypedFormBuilder) { } ngOnInit(): void { this.deviceProfileCommunicationConfig = this.fb.group({ communicationConfig: this.fb.array([]) }); + this.deviceProfileCommunicationConfig.valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(() => this.updateModel()); } ngOnDestroy() { - if (this.valueChange$) { - this.valueChange$.unsubscribe(); - } this.destroy$.next(); this.destroy$.complete(); } - communicationConfigFormArray(): FormArray { - return this.deviceProfileCommunicationConfig.get('communicationConfig') as FormArray; + get communicationConfigFormArray(): UntypedFormArray { + return this.deviceProfileCommunicationConfig.get('communicationConfig') as UntypedFormArray; } registerOnChange(fn: any): void { @@ -99,27 +98,27 @@ export class SnmpDeviceProfileCommunicationConfigComponent implements OnInit, On } writeValue(communicationConfig: SnmpCommunicationConfig[]) { - if (this.valueChange$) { - this.valueChange$.unsubscribe(); - } - const communicationConfigControl: Array = []; - if (communicationConfig) { - communicationConfig.forEach((config) => { - communicationConfigControl.push(this.createdFormGroup(config)); - }); - } - this.deviceProfileCommunicationConfig.setControl('communicationConfig', this.fb.array(communicationConfigControl)); - if (!communicationConfig || !communicationConfig.length) { - this.addCommunicationConfig(); - } - if (this.disabled) { - this.deviceProfileCommunicationConfig.disable({emitEvent: false}); + if (communicationConfig?.length === this.communicationConfigFormArray.length) { + this.communicationConfigFormArray.patchValue(communicationConfig, {emitEvent: false}); } else { - this.deviceProfileCommunicationConfig.enable({emitEvent: false}); + const communicationConfigControl: Array = []; + if (communicationConfig) { + communicationConfig.forEach((config) => { + communicationConfigControl.push(this.createdFormGroup(config)); + }); + } + this.deviceProfileCommunicationConfig.setControl( + 'communicationConfig', this.fb.array(communicationConfigControl), {emitEvent: false} + ); + if (!communicationConfig || !communicationConfig.length) { + this.addCommunicationConfig(); + } + if (this.disabled) { + this.deviceProfileCommunicationConfig.disable({emitEvent: false}); + } else { + this.deviceProfileCommunicationConfig.enable({emitEvent: false}); + } } - this.valueChange$ = this.deviceProfileCommunicationConfig.valueChanges.subscribe(() => { - this.updateModel(); - }); this.updateUsedSpecType(); if (!this.disabled && !this.deviceProfileCommunicationConfig.valid) { this.updateModel(); @@ -133,16 +132,16 @@ export class SnmpDeviceProfileCommunicationConfigComponent implements OnInit, On } public removeCommunicationConfig(index: number) { - this.communicationConfigFormArray().removeAt(index); + this.communicationConfigFormArray.removeAt(index); } get isAddEnabled(): boolean { - return this.communicationConfigFormArray().length !== Object.keys(SnmpSpecType).length; + return this.communicationConfigFormArray.length !== Object.keys(SnmpSpecType).length; } public addCommunicationConfig() { - this.communicationConfigFormArray().push(this.createdFormGroup()); + this.communicationConfigFormArray.push(this.createdFormGroup()); this.deviceProfileCommunicationConfig.updateValueAndValidity(); if (!this.deviceProfileCommunicationConfig.valid) { this.updateModel(); @@ -175,7 +174,7 @@ export class SnmpDeviceProfileCommunicationConfigComponent implements OnInit, On }); } - private createdFormGroup(value?: SnmpCommunicationConfig): FormGroup { + private createdFormGroup(value?: SnmpCommunicationConfig): UntypedFormGroup { if (isUndefinedOrNull(value)) { value = { spec: this.getFirstUnusedSeverity(), diff --git a/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-mapping.component.html b/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-mapping.component.html index e202370d21..94fe06137f 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-mapping.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-mapping.component.html @@ -1,6 +1,6 @@ - tenant-profile.tenant-profile +
-
+
relation.any-relation
diff --git a/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.scss b/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.scss index 0b4ae03f02..b18f226f4f 100644 --- a/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.scss +++ b/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2022 The Thingsboard Authors + * Copyright © 2016-2023 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. @@ -20,11 +20,9 @@ .container{ width: 100%; - overflow: auto; } .header { - width: max-content; padding: 0 5px 5px; .cell { @@ -38,8 +36,9 @@ } .body { - width: max-content; padding: 0 5px 20px; + max-height: 300px; + overflow: auto; .row { padding-top: 5px; @@ -56,20 +55,3 @@ } } } - -:host ::ng-deep { - .tb-relation-filters { - .cell { - .mat-form-field { - .mat-form-field-infix { - border-top: none; - } - &.mat-form-field-appearance-standard { - .mat-form-field-flex { - padding-top: 0; - } - } - } - } - } -} diff --git a/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.ts b/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.ts index 911aa4fe30..aa027c4846 100644 --- a/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.ts +++ b/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -14,13 +14,13 @@ /// limitations under the License. /// -import { Component, forwardRef, Input, OnInit } from '@angular/core'; +import { Component, forwardRef, Input, OnDestroy, OnInit } from '@angular/core'; import { AbstractControl, ControlValueAccessor, - FormArray, - FormBuilder, - FormGroup, + UntypedFormArray, + UntypedFormBuilder, + UntypedFormGroup, NG_VALUE_ACCESSOR } from '@angular/forms'; import { AliasEntityType, EntityType } from '@shared/models/entity-type.models'; @@ -28,7 +28,8 @@ import { RelationEntityTypeFilter } from '@shared/models/relation.models'; import { PageComponent } from '@shared/components/page.component'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; -import { Subscription } from 'rxjs'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; @Component({ selector: 'tb-relation-filters', @@ -42,31 +43,41 @@ import { Subscription } from 'rxjs'; } ] }) -export class RelationFiltersComponent extends PageComponent implements ControlValueAccessor, OnInit { +export class RelationFiltersComponent extends PageComponent implements ControlValueAccessor, OnInit, OnDestroy { @Input() disabled: boolean; @Input() allowedEntityTypes: Array; - relationFiltersFormGroup: FormGroup; + relationFiltersFormGroup: UntypedFormGroup; + private destroy$ = new Subject(); private propagateChange = null; - private valueChangeSubscription: Subscription = null; - constructor(protected store: Store, - private fb: FormBuilder) { + private fb: UntypedFormBuilder) { super(store); } ngOnInit(): void { - this.relationFiltersFormGroup = this.fb.group({}); - this.relationFiltersFormGroup.addControl('relationFilters', - this.fb.array([])); + this.relationFiltersFormGroup = this.fb.group({ + relationFilters: this.fb.array([]) + }); + + this.relationFiltersFormGroup.valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(() => { + this.updateModel(); + }); } - relationFiltersFormArray(): FormArray { - return this.relationFiltersFormGroup.get('relationFilters') as FormArray; + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + get relationFiltersFormArray(): UntypedFormArray { + return this.relationFiltersFormGroup.get('relationFilters') as UntypedFormArray; } registerOnChange(fn: any): void { @@ -81,32 +92,29 @@ export class RelationFiltersComponent extends PageComponent implements ControlVa } writeValue(filters: Array): void { - if (this.valueChangeSubscription) { - this.valueChangeSubscription.unsubscribe(); - } - const relationFiltersControls: Array = []; - if (filters && filters.length) { - filters.forEach((filter) => { - relationFiltersControls.push(this.createRelationFilterFormGroup(filter)); - }); + if (filters?.length === this.relationFiltersFormArray.length) { + this.relationFiltersFormArray.patchValue(filters, {emitEvent: false}); + } else { + const relationFiltersControls: Array = []; + if (filters && filters.length) { + filters.forEach((filter) => { + relationFiltersControls.push(this.createRelationFilterFormGroup(filter)); + }); + } + this.relationFiltersFormGroup.setControl('relationFilters', this.fb.array(relationFiltersControls), {emitEvent: false}); } - this.relationFiltersFormGroup.setControl('relationFilters', this.fb.array(relationFiltersControls)); - this.valueChangeSubscription = this.relationFiltersFormGroup.valueChanges.subscribe(() => { - this.updateModel(); - }); } public removeFilter(index: number) { - (this.relationFiltersFormGroup.get('relationFilters') as FormArray).removeAt(index); + (this.relationFiltersFormGroup.get('relationFilters') as UntypedFormArray).removeAt(index); } public addFilter() { - const relationFiltersFormArray = this.relationFiltersFormGroup.get('relationFilters') as FormArray; const filter: RelationEntityTypeFilter = { relationType: null, entityTypes: [] }; - relationFiltersFormArray.push(this.createRelationFilterFormGroup(filter)); + this.relationFiltersFormArray.push(this.createRelationFilterFormGroup(filter)); } private createRelationFilterFormGroup(filter: RelationEntityTypeFilter): AbstractControl { diff --git a/ui-ngx/src/app/modules/home/components/relation/relation-table.component.html b/ui-ngx/src/app/modules/home/components/relation/relation-table.component.html index 5c2c529815..c96b22a1cc 100644 --- a/ui-ngx/src/app/modules/home/components/relation/relation-table.component.html +++ b/ui-ngx/src/app/modules/home/components/relation/relation-table.component.html @@ -1,6 +1,6 @@
-
- +
+
@@ -52,7 +52,7 @@
- +
- +
{{ 'relation.selected-relations' | translate:{count: dataSource.selection.selected.length} }} diff --git a/ui-ngx/src/app/modules/home/components/relation/relation-table.component.scss b/ui-ngx/src/app/modules/home/components/relation/relation-table.component.scss index 453c4ab897..e39d275075 100644 --- a/ui-ngx/src/app/modules/home/components/relation/relation-table.component.scss +++ b/ui-ngx/src/app/modules/home/components/relation/relation-table.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2022 The Thingsboard Authors + * Copyright © 2016-2023 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. @@ -24,6 +24,13 @@ width: 100%; height: 100%; background: #fff; + overflow: hidden; + + &.tb-outlined-border { + box-shadow: 0 0 0 0 rgb(0 0 0 / 20%), 0 0 0 0 rgb(0 0 0 / 14%), 0 0 0 0 rgb(0 0 0 / 12%); + border: solid 1px #e0e0e0; + border-radius: 4px; + } .mat-toolbar-tools{ min-height: auto; @@ -67,29 +74,3 @@ } } } - -:host ::ng-deep { - .mat-sort-header-sorted .mat-sort-header-arrow { - opacity: 1 !important; - } - mat-form-field.tb-relation-direction { - font-size: 16px; - width: 200px; - - .mat-form-field-wrapper { - padding-bottom: 0; - } - - .mat-form-field-underline { - bottom: 0; - } - - @media #{$mat-xs} { - width: 100%; - - .mat-form-field-infix { - width: auto !important; - } - } - } -} diff --git a/ui-ngx/src/app/modules/home/components/relation/relation-table.component.ts b/ui-ngx/src/app/modules/home/components/relation/relation-table.component.ts index 6b2649ec9a..04a3f87486 100644 --- a/ui-ngx/src/app/modules/home/components/relation/relation-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/relation/relation-table.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/modules/home/components/router-tabs.component.html b/ui-ngx/src/app/modules/home/components/router-tabs.component.html new file mode 100644 index 0000000000..afcc4d9fa9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/router-tabs.component.html @@ -0,0 +1,37 @@ + + diff --git a/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-table-header.component.scss b/ui-ngx/src/app/modules/home/components/router-tabs.component.scss similarity index 52% rename from ui-ngx/src/app/modules/home/pages/entity-view/entity-view-table-header.component.scss rename to ui-ngx/src/app/modules/home/components/router-tabs.component.scss index 813272928c..8fe2c2b305 100644 --- a/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-table-header.component.scss +++ b/ui-ngx/src/app/modules/home/components/router-tabs.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2022 The Thingsboard Authors + * Copyright © 2016-2023 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. @@ -13,37 +13,34 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -@import '../../../../../scss/constants'; - :host { - flex: 1; display: flex; - justify-content: flex-start; - min-width: 150px; -} - -:host ::ng-deep { - tb-entity-subtype-select { - width: 100%; - - mat-form-field { - font-size: 16px; - - .mat-form-field-wrapper { - padding-bottom: 0; - } + width: 100%; + height: 100%; - .mat-form-field-underline { - bottom: 0; + .mat-mdc-tab-nav-bar.tb-router-tabs { + a.mat-mdc-tab-link { + display: inline-flex; + align-items: center; + overflow: hidden; + line-height: 40px; + min-width: 200px; + border-bottom: none; + mat-icon { + margin-right: 8px; + margin-left: 0; } - - @media #{$mat-xs} { - width: 100%; - - .mat-form-field-infix { - width: auto !important; - } + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } } } + + .tb-router-tabs-content { + overflow: auto; + position: relative; + height: 100%; + } } diff --git a/ui-ngx/src/app/modules/home/components/router-tabs.component.ts b/ui-ngx/src/app/modules/home/components/router-tabs.component.ts new file mode 100644 index 0000000000..65c0074227 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/router-tabs.component.ts @@ -0,0 +1,125 @@ +/// +/// Copyright © 2016-2023 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, ComponentRef, OnInit, Type, ViewChild } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; +import { MenuService } from '@core/services/menu.service'; +import { distinctUntilChanged, filter, map, mergeMap, take } from 'rxjs/operators'; +import { merge } from 'rxjs'; +import { MenuSection } from '@core/services/menu.models'; +import { ActiveComponentService } from '@core/services/active-component.service'; +import { TbAnchorComponent } from '@shared/components/tb-anchor.component'; + +@Component({ + selector: 'tb-router-tabs', + templateUrl: './router-tabs.component.html', + styleUrls: ['./router-tabs.component.scss'] +}) +export class RouterTabsComponent extends PageComponent implements OnInit { + + @ViewChild('tabsHeaderComponent', {static: true}) tabsHeaderComponentAnchor: TbAnchorComponent; + + tabsHeaderComponentRef: ComponentRef; + + hideCurrentTabs = false; + + tabs$ = merge(this.menuService.menuSections(), + this.router.events.pipe( + filter((event) => event instanceof NavigationEnd ), + distinctUntilChanged()) + ).pipe( + mergeMap(() => this.menuService.menuSections().pipe(take(1))), + map((sections) => this.buildTabs(this.activatedRoute, sections)) + ); + + constructor(protected store: Store, + private activatedRoute: ActivatedRoute, + public router: Router, + private menuService: MenuService, + private activeComponentService: ActiveComponentService) { + super(store); + } + + ngOnInit() { + this.activatedRoute.data.subscribe( + (data) => this.buildTabsHeaderComponent(data) + ); + } + + activeComponentChanged(activeComponent: any) { + this.activeComponentService.setCurrentActiveComponent(activeComponent); + let snapshot = this.router.routerState.snapshot.root; + this.hideCurrentTabs = false; + let found = false; + while (snapshot.children.length) { + if (snapshot.component && snapshot.component === RouterTabsComponent) { + if (this.activatedRoute.snapshot === snapshot) { + found = true; + } else if (found) { + this.hideCurrentTabs = true; + break; + } + } + snapshot = snapshot.children[0]; + } + } + + private buildTabs(activatedRoute: ActivatedRoute, sections: MenuSection[]): Array { + const sectionPath = '/' + activatedRoute.pathFromRoot.map(r => r.snapshot.url) + .filter(f => !!f[0]).map(f => f.map(f1 => f1.path).join('/')).join('/'); + const found = this.findRootSection(sections, sectionPath); + if (found) { + const rootPath = sectionPath.substring(0, sectionPath.length - found.path.length); + const isRoot = rootPath === ''; + const tabs: Array = found ? found.pages.filter(page => !page.disabled && (!page.rootOnly || isRoot)) : []; + return tabs.map((tab) => ({...tab, path: rootPath + tab.path})); + } else { + return []; + } + } + + private findRootSection(sections: MenuSection[], sectionPath: string): MenuSection { + for (const section of sections) { + if (sectionPath.endsWith(section.path)) { + return section; + } + if (section.pages?.length) { + const found = this.findRootSection(section.pages, sectionPath); + if (found) { + return found; + } + } + } + return null; + } + + private buildTabsHeaderComponent(snapshotData: any) { + const viewContainerRef = this.tabsHeaderComponentAnchor.viewContainerRef; + if (this.tabsHeaderComponentRef) { + this.tabsHeaderComponentRef.destroy(); + this.tabsHeaderComponentRef = null; + viewContainerRef.clear(); + } + const tabsHeaderComponentType: Type = snapshotData.routerTabsHeaderComponent; + if (tabsHeaderComponentType) { + this.tabsHeaderComponentRef = viewContainerRef.createComponent(tabsHeaderComponentType, {index: 0}); + } + } + +} diff --git a/ui-ngx/src/app/modules/home/components/rule-chain/rule-chain-autocomplete.component.html b/ui-ngx/src/app/modules/home/components/rule-chain/rule-chain-autocomplete.component.html index fd99e1bc54..e282d62f49 100644 --- a/ui-ngx/src/app/modules/home/components/rule-chain/rule-chain-autocomplete.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-chain/rule-chain-autocomplete.component.html @@ -1,6 +1,6 @@ - {{ labelText | translate }} + @@ -54,4 +55,7 @@ {{ 'rulechain.rulechain-required' | translate }} + + + diff --git a/ui-ngx/src/app/modules/home/components/rule-chain/rule-chain-autocomplete.component.ts b/ui-ngx/src/app/modules/home/components/rule-chain/rule-chain-autocomplete.component.ts index 3db424012f..f371f4201f 100644 --- a/ui-ngx/src/app/modules/home/components/rule-chain/rule-chain-autocomplete.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-chain/rule-chain-autocomplete.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -15,7 +15,7 @@ /// import { Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core'; -import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { ControlValueAccessor, UntypedFormBuilder, UntypedFormGroup, NG_VALUE_ACCESSOR } from '@angular/forms'; import { Observable, of } from 'rxjs'; import { catchError, debounceTime, distinctUntilChanged, map, share, switchMap, tap } from 'rxjs/operators'; import { Store } from '@ngrx/store'; @@ -43,18 +43,19 @@ import { RuleChainType } from '@app/shared/models/rule-chain.models'; }) export class RuleChainAutocompleteComponent implements ControlValueAccessor, OnInit { - selectRuleChainFormGroup: FormGroup; - - ruleChainLabel = 'rulechain.rulechain'; + selectRuleChainFormGroup: UntypedFormGroup; modelValue: string | null; @Input() - labelText: string; + labelText: string = 'rulechain.rulechain'; @Input() requiredText: string; + @Input() + ruleChainType: RuleChainType = RuleChainType.CORE; + private requiredValue: boolean; get required(): boolean { return this.requiredValue; @@ -83,7 +84,7 @@ export class RuleChainAutocompleteComponent implements ControlValueAccessor, OnI public truncate: TruncatePipe, private entityService: EntityService, private ruleChainService: RuleChainService, - private fb: FormBuilder) { + private fb: UntypedFormBuilder) { this.selectRuleChainFormGroup = this.fb.group({ ruleChainId: [null] }); @@ -191,9 +192,8 @@ export class RuleChainAutocompleteComponent implements ControlValueAccessor, OnI fetchRuleChain(searchText?: string): Observable>> { this.searchText = searchText; - // @voba: at the moment device profiles are not supported by edge, so 'core' hardcoded return this.entityService.getEntitiesByNameFilter(EntityType.RULE_CHAIN, searchText, - 50, RuleChainType.CORE, {ignoreLoading: true}).pipe( + 50, this.ruleChainType, {ignoreLoading: true}).pipe( catchError(() => of([])) ); } diff --git a/ui-ngx/src/app/modules/home/components/shared-home-components.module.ts b/ui-ngx/src/app/modules/home/components/shared-home-components.module.ts index 4052958e86..865e7cf224 100644 --- a/ui-ngx/src/app/modules/home/components/shared-home-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/shared-home-components.module.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -19,6 +19,9 @@ import { CommonModule } from '@angular/common'; import { SharedModule } from '@app/shared/shared.module'; import { AlarmDetailsDialogComponent } from '@home/components/alarm/alarm-details-dialog.component'; import { SHARED_HOME_COMPONENTS_MODULE_TOKEN } from '@home/components/tokens'; +import { AlarmCommentComponent } from '@home/components/alarm/alarm-comment.component'; +import { AlarmCommentDialogComponent } from '@home/components/alarm/alarm-comment-dialog.component'; +import { AlarmAssigneeComponent } from '@home/components/alarm/alarm-assignee.component'; @NgModule({ providers: [ @@ -26,14 +29,20 @@ import { SHARED_HOME_COMPONENTS_MODULE_TOKEN } from '@home/components/tokens'; ], declarations: [ - AlarmDetailsDialogComponent + AlarmDetailsDialogComponent, + AlarmCommentComponent, + AlarmCommentDialogComponent, + AlarmAssigneeComponent ], imports: [ CommonModule, SharedModule ], exports: [ - AlarmDetailsDialogComponent + AlarmDetailsDialogComponent, + AlarmCommentComponent, + AlarmCommentDialogComponent, + AlarmAssigneeComponent ] }) export class SharedHomeComponentsModule { } diff --git a/ui-ngx/src/app/modules/home/components/sms/aws-sns-provider-configuration.component.html b/ui-ngx/src/app/modules/home/components/sms/aws-sns-provider-configuration.component.html index e72cd7f077..8b29a5fbdc 100644 --- a/ui-ngx/src/app/modules/home/components/sms/aws-sns-provider-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/sms/aws-sns-provider-configuration.component.html @@ -1,6 +1,6 @@
- + admin.smpp-provider.smpp-version @@ -32,7 +32,7 @@ {{'admin.smpp-provider.smpp-host-required' | translate}} - + admin.smpp-provider.smpp-port diff --git a/ui-ngx/src/app/modules/home/components/sms/smpp-sms-provider-configuration.component.ts b/ui-ngx/src/app/modules/home/components/sms/smpp-sms-provider-configuration.component.ts index 605af2cb51..f4e305ef1c 100644 --- a/ui-ngx/src/app/modules/home/components/sms/smpp-sms-provider-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/sms/smpp-sms-provider-configuration.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -15,7 +15,7 @@ /// import { Component, forwardRef, Input, OnInit } from '@angular/core'; -import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import { ControlValueAccessor, UntypedFormBuilder, UntypedFormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; import { AwsSnsSmsProviderConfiguration, BindTypes, @@ -46,7 +46,7 @@ import { coerceBooleanProperty } from '@angular/cdk/coercion'; }) export class SmppSmsProviderConfigurationComponent implements ControlValueAccessor, OnInit{ - constructor(private fb: FormBuilder) { + constructor(private fb: UntypedFormBuilder) { } private requiredValue: boolean; @@ -61,7 +61,7 @@ export class SmppSmsProviderConfigurationComponent implements ControlValueAcces @Input() disabled: boolean; - smppSmsProviderConfigurationFormGroup: FormGroup; + smppSmsProviderConfigurationFormGroup: UntypedFormGroup; smppVersions = smppVersions; diff --git a/ui-ngx/src/app/modules/home/components/sms/sms-provider-configuration.component.html b/ui-ngx/src/app/modules/home/components/sms/sms-provider-configuration.component.html index f4d9d0d1c9..77b22386a1 100644 --- a/ui-ngx/src/app/modules/home/components/sms/sms-provider-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/sms/sms-provider-configuration.component.html @@ -1,6 +1,6 @@ - + admin.number-from diff --git a/ui-ngx/src/app/modules/home/components/sms/twilio-sms-provider-configuration.component.ts b/ui-ngx/src/app/modules/home/components/sms/twilio-sms-provider-configuration.component.ts index 556828c138..4627089cd3 100644 --- a/ui-ngx/src/app/modules/home/components/sms/twilio-sms-provider-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/sms/twilio-sms-provider-configuration.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -15,7 +15,7 @@ /// import { Component, forwardRef, Input, OnInit } from '@angular/core'; -import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import { ControlValueAccessor, UntypedFormBuilder, UntypedFormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; import { Store } from '@ngrx/store'; import { AppState } from '@app/core/core.state'; import { coerceBooleanProperty } from '@angular/cdk/coercion'; @@ -39,7 +39,7 @@ import { }) export class TwilioSmsProviderConfigurationComponent implements ControlValueAccessor, OnInit { - twilioSmsProviderConfigurationFormGroup: FormGroup; + twilioSmsProviderConfigurationFormGroup: UntypedFormGroup; phoneNumberPatternTwilio = phoneNumberPatternTwilio; @@ -60,7 +60,7 @@ export class TwilioSmsProviderConfigurationComponent implements ControlValueAcce private propagateChange = (v: any) => { }; constructor(private store: Store, - private fb: FormBuilder) { + private fb: UntypedFormBuilder) { } registerOnChange(fn: any): void { diff --git a/ui-ngx/src/app/modules/home/components/tokens.ts b/ui-ngx/src/app/modules/home/components/tokens.ts index 62b580432f..5ddd9fe661 100644 --- a/ui-ngx/src/app/modules/home/components/tokens.ts +++ b/ui-ngx/src/app/modules/home/components/tokens.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/modules/home/components/vc/auto-commit-settings.component.html b/ui-ngx/src/app/modules/home/components/vc/auto-commit-settings.component.html index 8336eb728d..b619e9d846 100644 --- a/ui-ngx/src/app/modules/home/components/vc/auto-commit-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/vc/auto-commit-settings.component.html @@ -1,6 +1,6 @@
- - -
- admin.auto-commit-settings - -
-
-
+ + + + admin.auto-commit-settings + + +
+
diff --git a/ui-ngx/src/app/modules/home/components/vc/auto-commit-settings.component.scss b/ui-ngx/src/app/modules/home/components/vc/auto-commit-settings.component.scss index b8bb1ee760..36617d9ab9 100644 --- a/ui-ngx/src/app/modules/home/components/vc/auto-commit-settings.component.scss +++ b/ui-ngx/src/app/modules/home/components/vc/auto-commit-settings.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2022 The Thingsboard Authors + * Copyright © 2016-2023 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. @@ -14,7 +14,7 @@ * limitations under the License. */ :host { - mat-card.auto-commit-settings { + .mat-mdc-card.auto-commit-settings { margin: 8px; .mat-divider { position: relative; diff --git a/ui-ngx/src/app/modules/home/components/vc/auto-commit-settings.component.ts b/ui-ngx/src/app/modules/home/components/vc/auto-commit-settings.component.ts index 980d89db4f..d94cec9457 100644 --- a/ui-ngx/src/app/modules/home/components/vc/auto-commit-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/vc/auto-commit-settings.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -16,7 +16,7 @@ import { Component, OnInit } from '@angular/core'; import { PageComponent } from '@shared/components/page.component'; -import { AbstractControl, FormArray, FormBuilder, FormGroup, FormGroupDirective, Validators } from '@angular/forms'; +import { AbstractControl, UntypedFormArray, UntypedFormBuilder, UntypedFormGroup, FormGroupDirective, Validators } from '@angular/forms'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { AdminService } from '@core/http/admin.service'; @@ -36,7 +36,7 @@ import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; }) export class AutoCommitSettingsComponent extends PageComponent implements OnInit { - autoCommitSettingsForm: FormGroup; + autoCommitSettingsForm: UntypedFormGroup; settings: AutoCommitSettings = null; entityTypes = EntityType; @@ -48,7 +48,7 @@ export class AutoCommitSettingsComponent extends PageComponent implements OnInit private dialogService: DialogService, private sanitizer: DomSanitizer, private translate: TranslateService, - public fb: FormBuilder) { + public fb: UntypedFormBuilder) { super(store); } @@ -76,8 +76,8 @@ export class AutoCommitSettingsComponent extends PageComponent implements OnInit this.isReadOnly = this.adminService.getRepositorySettingsInfo().pipe(map(settings => settings.readOnly)); } - entityTypesFormGroupArray(): FormGroup[] { - return (this.autoCommitSettingsForm.get('entityTypes') as FormArray).controls as FormGroup[]; + entityTypesFormGroupArray(): UntypedFormGroup[] { + return (this.autoCommitSettingsForm.get('entityTypes') as UntypedFormArray).controls as UntypedFormGroup[]; } entityTypesFormGroupExpanded(entityTypeControl: AbstractControl): boolean { @@ -89,17 +89,17 @@ export class AutoCommitSettingsComponent extends PageComponent implements OnInit } public removeEntityType(index: number) { - (this.autoCommitSettingsForm.get('entityTypes') as FormArray).removeAt(index); + (this.autoCommitSettingsForm.get('entityTypes') as UntypedFormArray).removeAt(index); this.autoCommitSettingsForm.markAsDirty(); } public addEnabled(): boolean { - const entityTypesArray = this.autoCommitSettingsForm.get('entityTypes') as FormArray; + const entityTypesArray = this.autoCommitSettingsForm.get('entityTypes') as UntypedFormArray; return entityTypesArray.length < exportableEntityTypes.length; } public addEntityType() { - const entityTypesArray = this.autoCommitSettingsForm.get('entityTypes') as FormArray; + const entityTypesArray = this.autoCommitSettingsForm.get('entityTypes') as UntypedFormArray; const config: AutoVersionCreateConfig = { branch: null, saveAttributes: true, @@ -119,7 +119,7 @@ export class AutoCommitSettingsComponent extends PageComponent implements OnInit } public removeAll() { - const entityTypesArray = this.autoCommitSettingsForm.get('entityTypes') as FormArray; + const entityTypesArray = this.autoCommitSettingsForm.get('entityTypes') as UntypedFormArray; entityTypesArray.clear(); this.autoCommitSettingsForm.updateValueAndValidity(); this.autoCommitSettingsForm.markAsDirty(); @@ -187,7 +187,7 @@ export class AutoCommitSettingsComponent extends PageComponent implements OnInit }); } - private prepareEntityTypesFormArray(settings: AutoCommitSettings | null): FormArray { + private prepareEntityTypesFormArray(settings: AutoCommitSettings | null): UntypedFormArray { const entityTypesControls: Array = []; if (settings) { for (const entityType of Object.keys(settings)) { diff --git a/ui-ngx/src/app/modules/home/components/vc/complex-version-create.component.html b/ui-ngx/src/app/modules/home/components/vc/complex-version-create.component.html index 317d6e9cb6..d19ed2b51d 100644 --- a/ui-ngx/src/app/modules/home/components/vc/complex-version-create.component.html +++ b/ui-ngx/src/app/modules/home/components/vc/complex-version-create.component.html @@ -1,6 +1,6 @@ -

{{ 'version-control.create-entities-version' | translate }}

@@ -70,7 +69,7 @@
-
+
- +
diff --git a/ui-ngx/src/app/modules/home/components/vc/repository-settings.component.scss b/ui-ngx/src/app/modules/home/components/vc/repository-settings.component.scss index 158f7ba5f4..80df7d9d79 100644 --- a/ui-ngx/src/app/modules/home/components/vc/repository-settings.component.scss +++ b/ui-ngx/src/app/modules/home/components/vc/repository-settings.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2022 The Thingsboard Authors + * Copyright © 2016-2023 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. @@ -14,12 +14,9 @@ * limitations under the License. */ :host { - mat-card.repository-settings { + .mat-mdc-card.repository-settings { margin: 8px; } - mat-checkbox { - padding-bottom: 16px; - } .fields-group { padding: 0 16px 8px; margin-bottom: 10px; @@ -37,8 +34,3 @@ } } } -:host ::ng-deep { - .mat-checkbox-layout { - white-space: normal !important; - } -} diff --git a/ui-ngx/src/app/modules/home/components/vc/repository-settings.component.ts b/ui-ngx/src/app/modules/home/components/vc/repository-settings.component.ts index 1270acf150..b42bca156d 100644 --- a/ui-ngx/src/app/modules/home/components/vc/repository-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/vc/repository-settings.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -16,7 +16,7 @@ import { ChangeDetectorRef, Component, Input, OnInit } from '@angular/core'; import { PageComponent } from '@shared/components/page.component'; -import { FormBuilder, FormGroup, FormGroupDirective, Validators } from '@angular/forms'; +import { UntypedFormBuilder, UntypedFormGroup, FormGroupDirective, Validators } from '@angular/forms'; import { select, Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { AdminService } from '@core/http/admin.service'; @@ -48,7 +48,7 @@ export class RepositorySettingsComponent extends PageComponent implements OnInit @Input() popoverComponent: TbPopoverComponent; - repositorySettingsForm: FormGroup; + repositorySettingsForm: UntypedFormGroup; settings: RepositorySettings = null; repositoryAuthMethod = RepositoryAuthMethod; @@ -66,7 +66,7 @@ export class RepositorySettingsComponent extends PageComponent implements OnInit private dialogService: DialogService, private translate: TranslateService, private cd: ChangeDetectorRef, - public fb: FormBuilder) { + public fb: UntypedFormBuilder) { super(store); } diff --git a/ui-ngx/src/app/modules/home/components/vc/version-control.component.html b/ui-ngx/src/app/modules/home/components/vc/version-control.component.html index eaf80fd664..7b14dde065 100644 --- a/ui-ngx/src/app/modules/home/components/vc/version-control.component.html +++ b/ui-ngx/src/app/modules/home/components/vc/version-control.component.html @@ -1,6 +1,6 @@
- +
diff --git a/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-editor.component.scss b/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-editor.component.scss index 611394ee12..4eebb52161 100644 --- a/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-editor.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-editor.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2022 The Thingsboard Authors + * Copyright © 2016-2023 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. @@ -33,10 +33,10 @@ box-sizing: border-box; } - mat-tab-group { - .mat-tab-body-wrapper { + .mat-mdc-tab-group { + .mat-mdc-tab-body-wrapper { height: 100%; - mat-tab-body { + .mat-mdc-tab-body { height: 100%; & > div { height: 100%; @@ -81,20 +81,20 @@ &.tb-fullscreen-editor { position: relative; right: 0; - .mat-button { + /* .mat-mdc-button { .mat-icon { margin-right: 5px; } - } + } */ } - .mat-button { + /* .mat-mdc-button { min-width: 36px; padding: 0; .mat-icon { margin-right: 0; } - } + } */ } .tb-custom-action-editor { diff --git a/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-editor.component.ts b/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-editor.component.ts index 7c36347f2a..5c2111c76e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-editor.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-editor.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -14,7 +14,7 @@ /// limitations under the License. /// -// tslint:disable-next-line:no-reference +// eslint-disable-next-line @typescript-eslint/triple-slash-reference /// import { diff --git a/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-resources-tabs.component.html b/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-resources-tabs.component.html index 63dc06c1d4..c3d0141d20 100644 --- a/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-resources-tabs.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-resources-tabs.component.html @@ -1,6 +1,6 @@
-
- +
+
widget-config.actions @@ -36,7 +36,7 @@
- +
+ close
- - close -
- - - + + +
+ - - - notifications - - - - - - - - - timeline - - + + notifications + + + timeline + @@ -130,31 +137,22 @@ {{'entity.create-new-key' | translate }} - - notifications - - - - - - - - - timeline - + notifications + + + timeline
@@ -167,3 +165,19 @@
{{ maxDataKeysText() }}
+ + + f() + + + + + + + + {{ key.aggregationType }}({{ key.name }}) + + + {{key.name}} + + diff --git a/ui-ngx/src/app/modules/home/components/widget/data-keys.component.models.ts b/ui-ngx/src/app/modules/home/components/widget/data-keys.component.models.ts index a04221fd3b..9407145376 100644 --- a/ui-ngx/src/app/modules/home/components/widget/data-keys.component.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/data-keys.component.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/modules/home/components/widget/data-keys.component.scss b/ui-ngx/src/app/modules/home/components/widget/data-keys.component.scss index 334f533dd4..a0b909af1a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/data-keys.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/data-keys.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2022 The Thingsboard Authors + * Copyright © 2016-2023 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. @@ -14,62 +14,107 @@ * limitations under the License. */ -.mat-chip.mat-standard-chip.tb-datakey-chip { - overflow: hidden; +.tb-datakeys-container { + display: flex; + flex-wrap: wrap; + width: 100%; - .tb-attribute-chip { - max-width: 100%; - color: rgb(66, 66, 66); - font-weight: normal; - font-size: 16px; + input.tb-dragging { + display: none; + } - .tb-chip-drag-handle { - cursor: move; + .mat-mdc-chip.mat-mdc-standard-chip.tb-datakey-chip { + overflow: hidden; + line-height: 20px; + height: 32px; - mat-icon { - pointer-events: none; + .mat-mdc-chip-action { + overflow: hidden; + .mat-mdc-chip-action-label { + overflow: hidden; } } - - .tb-chip-labels { - display: flex; - flex-direction: row; - align-items: flex-end; - min-width: 0; - .tb-chip-label { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + .tb-attribute-chip { + max-width: 100%; + color: rgb(66, 66, 66); + font-weight: normal; + font-size: 16px; + .tb-chip-drag-handle { + cursor: move; + mat-icon { + pointer-events: none; + margin-right: 4px; + margin-left: 4px; + vertical-align: bottom; + } } - - .tb-chip-separator { - white-space: pre; + .tb-chip-labels { + display: flex; + flex-direction: row; + align-items: center; + min-width: 0; + .tb-chip-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + .mat-icon.tb-datakey-icon { + margin-right: 4px; + margin-left: 4px; + } + .tb-agg-func { + font-style: italic; + color: #0c959c; + } + } + .tb-chip-separator { + white-space: pre; + } + } + .mat-mdc-chip-remove.mat-icon { + width: 24px; + min-width: 24px; + height: 24px; + font-size: 24px; + margin-right: 4px; + color: inherit; + opacity: inherit; + padding-left: 0; } } - .mat-chip-remove.mat-icon { - width: 24px; - height: 24px; - font-size: 24px; - margin-left: 0; - color: inherit; - opacity: inherit; + &.tb-datakey-chip-dnd-placeholder { + min-width: 120px; + border: 2px dashed rgba(0, 0, 0, 0.2); + } + &.tb-chip-dragging { + display: none; + } + .tb-dragging-chip-image-fill { + background-color: rgba(0,0,0,0.3); + border-radius: var(--mdc-chip-container-shape-radius, 16px 16px 16px 16px); + display: none; + pointer-events: none; + } + .tb-dragging-chip-image { + background-color: var(--mdc-chip-elevated-container-color, transparent); + border-radius: var(--mdc-chip-container-shape-radius, 16px 16px 16px 16px); + overflow: hidden; + height: 32px; + line-height: 20px; + .tb-dragging-chip-image-fill { + display: block; + } } } } -:host ::ng-deep { - .mat-form-field-flex { - padding-top: 0; - .mat-form-field-infix { - border-top: 0; - } +.mat-icon.tb-datakey-icon { + vertical-align: middle; + & > svg { + vertical-align: initial; } - - .tb-chip-label { - .tb-agg-func { - font-style: italic; - color: #0c959c; - } + &.new-key { + margin-left: 8px; + margin-right: 8px; } } diff --git a/ui-ngx/src/app/modules/home/components/widget/data-keys.component.ts b/ui-ngx/src/app/modules/home/components/widget/data-keys.component.ts index 940e4c4c9b..757f838fe4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/data-keys.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/data-keys.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -16,25 +16,27 @@ import { COMMA, ENTER, SEMICOLON } from '@angular/cdk/keycodes'; import { - AfterViewInit, + ChangeDetectorRef, Component, ElementRef, forwardRef, Input, OnChanges, OnInit, + Renderer2, SimpleChanges, SkipSelf, - ViewChild + ViewChild, + ViewEncapsulation } from '@angular/core'; import { ControlValueAccessor, - FormBuilder, - FormControl, - FormGroup, FormGroupDirective, NG_VALUE_ACCESSOR, NgForm, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, Validators } from '@angular/forms'; import { Observable, of } from 'rxjs'; @@ -43,7 +45,7 @@ import { Store } from '@ngrx/store'; import { AppState } from '@app/core/core.state'; import { TranslateService } from '@ngx-translate/core'; import { MatAutocomplete } from '@angular/material/autocomplete'; -import { MatChipInputEvent, MatChipList } from '@angular/material/chips'; +import { MatChipGrid, MatChipInputEvent, MatChipRow } from '@angular/material/chips'; import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { DataKey, DatasourceType, JsonSettingsSchema, Widget, widgetType } from '@shared/models/widget.models'; @@ -59,11 +61,11 @@ import { DataKeyConfigDialogComponent, DataKeyConfigDialogData } from '@home/components/widget/data-key-config-dialog.component'; -import { deepClone } from '@core/utils'; -import { MatChipDropEvent } from '@app/shared/components/mat-chip-draggable.directive'; +import { deepClone, guid, isUndefined } from '@core/utils'; import { Dashboard } from '@shared/models/dashboard.models'; -import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { AggregationType } from '@shared/models/time/time.models'; +import { DndDropEvent } from 'ngx-drag-drop/lib/dnd-dropzone.directive'; +import { moveItemInArray } from '@angular/cdk/drag-drop'; @Component({ selector: 'tb-data-keys', @@ -79,15 +81,16 @@ import { AggregationType } from '@shared/models/time/time.models'; provide: ErrorStateMatcher, useExisting: DataKeysComponent } - ] + ], + encapsulation: ViewEncapsulation.None }) -export class DataKeysComponent implements ControlValueAccessor, OnInit, AfterViewInit, OnChanges, ErrorStateMatcher { +export class DataKeysComponent implements ControlValueAccessor, OnInit, OnChanges, ErrorStateMatcher { datasourceTypes = DatasourceType; widgetTypes = widgetType; dataKeyTypes = DataKeyType; - keysListFormGroup: FormGroup; + keysListFormGroup: UntypedFormGroup; modelValue: Array | null; @@ -145,7 +148,7 @@ export class DataKeysComponent implements ControlValueAccessor, OnInit, AfterVie @ViewChild('keyInput') keyInput: ElementRef; @ViewChild('keyAutocomplete') matAutocomplete: MatAutocomplete; - @ViewChild('chipList') chipList: MatChipList; + @ViewChild('chipList') chipList: MatChipGrid; keys: Array = []; filteredKeys: Observable>; @@ -161,6 +164,11 @@ export class DataKeysComponent implements ControlValueAccessor, OnInit, AfterVie requiredText: string; searchText = ''; + + dndId = guid(); + + dragIndex: number; + private latestSearchTextResult: Array = null; private fetchObservable$: Observable> = null; @@ -174,8 +182,9 @@ export class DataKeysComponent implements ControlValueAccessor, OnInit, AfterVie private utils: UtilsService, private dialogs: DialogService, private dialog: MatDialog, - private fb: FormBuilder, - private sanitizer: DomSanitizer, + private fb: UntypedFormBuilder, + private cd: ChangeDetectorRef, + private renderer: Renderer2, public truncate: TruncatePipe) { } @@ -250,7 +259,7 @@ export class DataKeysComponent implements ControlValueAccessor, OnInit, AfterVie this.secondaryPlaceholder = '+' + this.translate.instant('datakey.latest-key-function'); } else if (this.widgetType === widgetType.alarm) { this.placeholder = this.translate.instant('datakey.alarm-key-functions'); - this.secondaryPlaceholder = '+' + this.translate.instant('alarm-key-function'); + this.secondaryPlaceholder = '+' + this.translate.instant('datakey.alarm-key-function'); } else { this.placeholder = this.translate.instant('datakey.timeseries-key-functions'); this.secondaryPlaceholder = '+' + this.translate.instant('datakey.timeseries-key-function'); @@ -314,14 +323,12 @@ export class DataKeysComponent implements ControlValueAccessor, OnInit, AfterVie } } - isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean { + isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean { const originalErrorState = this.errorStateMatcher.isErrorState(control, form); const customErrorState = this.required && !this.modelValue; return originalErrorState || customErrorState; } - ngAfterViewInit(): void {} - setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; if (this.disabled) { @@ -395,12 +402,24 @@ export class DataKeysComponent implements ControlValueAccessor, OnInit, AfterVie } } - onChipDrop(event: MatChipDropEvent) { - const from = event.from; - const to = event.to; - this.keys.splice(to, 0, this.keys.splice(from, 1)[0]); + chipDragStart(index: number, chipRow: MatChipRow, placeholderChipRow: MatChipRow) { + this.renderer.setStyle(placeholderChipRow._elementRef.nativeElement, 'width', chipRow._elementRef.nativeElement.offsetWidth + 'px'); + this.dragIndex = index; + } + + chipDragEnd() { + this.dragIndex = -1; + } + + onChipDrop(event: DndDropEvent) { + let index = event.index; + if (isUndefined(index)) { + index = this.keys.length; + } + moveItemInArray(this.keys, this.dragIndex, index); this.keysListFormGroup.get('keys').setValue(this.keys); - this.modelValue.splice(to, 0, this.modelValue.splice(from, 1)[0]); + moveItemInArray(this.modelValue, this.dragIndex, index); + this.dragIndex = -1; this.propagateChange(this.modelValue); } @@ -450,34 +469,13 @@ export class DataKeysComponent implements ControlValueAccessor, OnInit, AfterVie return key ? key.name : undefined; } - displayDataKeyNameFn(key: DataKey): SafeHtml { - let keyName = key.name; - if (this.widgetType === widgetType.latest && key.type === DataKeyType.timeseries - && key.aggregationType && key.aggregationType !== AggregationType.NONE) { - let aggFuncName: string; - switch (key.aggregationType) { - case AggregationType.MIN: - aggFuncName = 'MIN'; - break; - case AggregationType.MAX: - aggFuncName = 'MAX'; - break; - case AggregationType.AVG: - aggFuncName = 'AVG'; - break; - case AggregationType.SUM: - aggFuncName = 'SUM'; - break; - case AggregationType.COUNT: - aggFuncName = 'COUNT'; - break; - } - keyName = `${aggFuncName}(${keyName})`; - } - if (this.datasourceType !== DatasourceType.function && key.postFuncBody) { - keyName = `f(${keyName})`; - } - return this.sanitizer.bypassSecurityTrustHtml(`${keyName}`); + dataKeyHasAggregation(key: DataKey): boolean { + return this.widgetType === widgetType.latest && key.type === DataKeyType.timeseries + && key.aggregationType && key.aggregationType !== AggregationType.NONE; + } + + dataKeyHasPostprocessing(key: DataKey): boolean { + return this.datasourceType !== DatasourceType.function && !!key.postFuncBody; } private fetchKeys(searchText?: string): Observable> { diff --git a/ui-ngx/src/app/modules/home/components/widget/dialog/custom-dialog-container.component.ts b/ui-ngx/src/app/modules/home/components/widget/dialog/custom-dialog-container.component.ts index be717873da..d9ab65813f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/dialog/custom-dialog-container.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/dialog/custom-dialog-container.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/modules/home/components/widget/dialog/custom-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/dialog/custom-dialog.component.ts index ce5b3badb8..092d91b0f8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/dialog/custom-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/dialog/custom-dialog.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -21,7 +21,7 @@ import { AppState } from '@core/core.state'; import { Router } from '@angular/router'; import { PageComponent } from '@shared/components/page.component'; import { CustomDialogContainerComponent } from './custom-dialog-container.component'; -import { FormBuilder, Validators } from '@angular/forms'; +import { UntypedFormBuilder, Validators } from '@angular/forms'; import { TbInject } from '@shared/decorators/tb-inject'; export const CUSTOM_DIALOG_DATA = new InjectionToken('ConfigDialogData'); @@ -32,7 +32,7 @@ export interface CustomDialogData { } @Directive() -// tslint:disable-next-line:directive-class-suffix +// eslint-disable-next-line @angular-eslint/directive-class-suffix export class CustomDialogComponent extends PageComponent { [key: string]: any; @@ -40,7 +40,7 @@ export class CustomDialogComponent extends PageComponent { constructor(@TbInject(Store) protected store: Store, @TbInject(Router) protected router: Router, @TbInject(MatDialogRef) public dialogRef: MatDialogRef, - @TbInject(FormBuilder) public fb: FormBuilder, + @TbInject(UntypedFormBuilder) public fb: UntypedFormBuilder, @TbInject(CUSTOM_DIALOG_DATA) public data: CustomDialogData) { super(store); // @ts-ignore diff --git a/ui-ngx/src/app/modules/home/components/widget/dialog/custom-dialog.service.ts b/ui-ngx/src/app/modules/home/components/widget/dialog/custom-dialog.service.ts index 4de6abdd31..73b67017ff 100644 --- a/ui-ngx/src/app/modules/home/components/widget/dialog/custom-dialog.service.ts +++ b/ui-ngx/src/app/modules/home/components/widget/dialog/custom-dialog.service.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/modules/home/components/widget/dialog/embed-dashboard-dialog-token.ts b/ui-ngx/src/app/modules/home/components/widget/dialog/embed-dashboard-dialog-token.ts index 5d0ee4733f..14b9af98ad 100644 --- a/ui-ngx/src/app/modules/home/components/widget/dialog/embed-dashboard-dialog-token.ts +++ b/ui-ngx/src/app/modules/home/components/widget/dialog/embed-dashboard-dialog-token.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/modules/home/components/widget/dialog/embed-dashboard-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/dialog/embed-dashboard-dialog.component.html index 4936cb5ba6..36837414ed 100644 --- a/ui-ngx/src/app/modules/home/components/widget/dialog/embed-dashboard-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/dialog/embed-dashboard-dialog.component.html @@ -1,6 +1,6 @@
- +
-
- +
{{ translate.get('alarm.selected-alarms', @@ -45,13 +45,13 @@ - + + - + - +
-
-
-
@@ -59,18 +60,18 @@
- + - +
- - - - -
-
- - - check - - - - {{ 'device.wizard.device-details' | translate}} -
- - device.name - - - {{ 'device.name-required' | translate }} - - - {{ 'device.name-max-length' | translate }} - - - - device.label - - - {{ 'device.label-max-length' | translate }} - - -
- - - device.wizard.existing-device-profile - - - device.wizard.new-device-profile - - -
- - - - device-profile.new-device-profile-name - - - {{ 'device-profile.new-device-profile-name-required' | translate }} - - -
-
- - -
-
- - -
+ +

device.add-device-text

+ +
+ +
+ + +
+
+ + + check + + + + {{ 'device.wizard.device-details' | translate}} +
+ + device.name + + + {{ 'device.name-required' | translate }} + + + {{ 'device.name-max-length' | translate }} + + + + device.label + + + {{ 'device.label-max-length' | translate }} + + +
+ + + device.wizard.existing-device-profile + + + device.wizard.new-device-profile + + +
+ + + + device-profile.new-device-profile-name + + + {{ 'device-profile.new-device-profile-name-required' | translate }} + +
-
- - {{ 'device.is-gateway' | translate }} - - - {{ 'device.overwrite-activity-time' | translate }} - +
+ +
- - device.description - - -
- -
- -
- {{ 'device-profile.transport-configuration' | translate }} - device-profile.transport-type - - - {{deviceTransportTypeTranslations.get(type) | translate}} - - - - {{deviceTransportTypeHints.get(transportConfigFormGroup.get('transportType').value) | translate}} - - - {{ 'device-profile.transport-type-required' | translate }} - - - - -
-
- -
- {{'device-profile.alarm-rules-with-count' | translate: - {count: alarmRulesFormGroup.get('alarms').value ? - alarmRulesFormGroup.get('alarms').value.length : 0} }} - - -
-
- -
- {{ 'device-profile.device-provisioning' | translate }} - - -
-
- - {{ 'device.credentials' | translate }} -
- {{ 'device.wizard.add-credentials' | translate }} - - -
-
- - {{ 'customer.customer' | translate }} -
- - -
-
-
-
-
+
+ + +
+
+
+ + {{ 'device.is-gateway' | translate }} + + + {{ 'device.overwrite-activity-time' | translate }} + +
+ + device.description + + +
+ +
+ +
+ {{ 'device-profile.transport-configuration' | translate }} + device-profile.transport-type + + + {{deviceTransportTypeTranslations.get(type) | translate}} + + + + {{deviceTransportTypeHints.get(transportConfigFormGroup.get('transportType').value) | translate}} + + + {{ 'device-profile.transport-type-required' | translate }} + + + + +
+
+ +
+ {{'device-profile.alarm-rules-with-count' | translate: + {count: alarmRulesFormGroup.get('alarms').value ? + alarmRulesFormGroup.get('alarms').value.length : 0} }} + + +
+
+ +
+ {{ 'device-profile.device-provisioning' | translate }} + + +
+
+ + {{ 'device.credentials' | translate }} +
+ {{ 'device.wizard.add-credentials' | translate }} + + +
+
+ + {{ 'customer.customer' | translate }} +
+ + +
+
+
+
+
+
@@ -191,8 +191,8 @@ [disabled]="(isLoading$ | async)" (click)="nextStep()">{{ 'action.next-with-label' | translate:{label: (getFormLabel(this.selectedIndex+1) | translate)} }}
- -
+ +
diff --git a/ui-ngx/src/app/modules/home/components/wizard/device-wizard-dialog.component.scss b/ui-ngx/src/app/modules/home/components/wizard/device-wizard-dialog.component.scss index ffeaad5acd..0fe18467fd 100644 --- a/ui-ngx/src/app/modules/home/components/wizard/device-wizard-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/components/wizard/device-wizard-dialog.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2022 The Thingsboard Authors + * Copyright © 2016-2023 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. @@ -15,9 +15,18 @@ */ @import "../../../../../scss/constants"; -:host-context(.tb-fullscreen-dialog .mat-dialog-container) { +:host { + height: 100%; + display: grid; + + .dialog-actions-row { + padding: 8px; + } +} + +:host-context(.tb-fullscreen-dialog .mat-mdc-dialog-container) { @media #{$mat-lt-sm} { - .mat-dialog-content { + .mat-mdc-dialog-content { max-height: 75vh; } } @@ -28,7 +37,7 @@ } :host ::ng-deep { - .mat-dialog-content { + .mat-mdc-dialog-content { display: flex; flex-direction: column; height: 100%; @@ -36,37 +45,21 @@ .mat-stepper-horizontal { display: flex; - flex-direction: column; height: 100%; overflow: hidden; - @media #{$mat-lt-sm} { - .mat-step-label { - white-space: normal; - overflow: visible; - .mat-step-text-label { - overflow: visible; - } - } + + .mat-horizontal-stepper-wrapper { + flex: 1 1 100%; } + .mat-horizontal-content-container { - height: 530px; + height: 680px; max-height: 100%; width: 100%;; overflow-y: auto; + scrollbar-gutter: stable; @media #{$mat-gt-sm} { - min-width: 800px; - } - } - .mat-horizontal-stepper-content[aria-expanded=true] { - height: 100%; - form { - height: 100%; - } - } - - tb-device-profile-autocomplete, tb-queue-autocomplete{ - .mat-form-field-wrapper{ - width: 180px !important; + min-width: 500px; } } } diff --git a/ui-ngx/src/app/modules/home/components/wizard/device-wizard-dialog.component.ts b/ui-ngx/src/app/modules/home/components/wizard/device-wizard-dialog.component.ts index 694deabd96..77916d379c 100644 --- a/ui-ngx/src/app/modules/home/components/wizard/device-wizard-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/wizard/device-wizard-dialog.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -18,7 +18,7 @@ import { Component, Inject, OnDestroy, SkipSelf, ViewChild } 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, FormControl, FormGroup, FormGroupDirective, NgForm, Validators } from '@angular/forms'; +import { UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, FormGroupDirective, NgForm, Validators } from '@angular/forms'; import { DialogComponent } from '@shared/components/dialog.component'; import { Router } from '@angular/router'; import { @@ -33,7 +33,7 @@ import { deviceTransportTypeHintMap, deviceTransportTypeTranslationMap } from '@shared/models/device.models'; -import { MatHorizontalStepper } from '@angular/material/stepper'; +import { MatStepper } from '@angular/material/stepper'; import { AddEntityDialogData } from '@home/models/entity/entity-component.models'; import { BaseData, HasId } from '@shared/models/base-data'; import { EntityType } from '@shared/models/entity-type.models'; @@ -59,7 +59,7 @@ import { deepTrim } from '@core/utils'; export class DeviceWizardDialogComponent extends DialogComponent implements OnDestroy, ErrorStateMatcher { - @ViewChild('addDeviceWizardStepper', {static: true}) addDeviceWizardStepper: MatHorizontalStepper; + @ViewChild('addDeviceWizardStepper', {static: true}) addDeviceWizardStepper: MatStepper; selectedIndex = 0; @@ -75,19 +75,19 @@ export class DeviceWizardDialogComponent extends deviceTransportTypeHints = deviceTransportTypeHintMap; - deviceWizardFormGroup: FormGroup; + deviceWizardFormGroup: UntypedFormGroup; - transportConfigFormGroup: FormGroup; + transportConfigFormGroup: UntypedFormGroup; - alarmRulesFormGroup: FormGroup; + alarmRulesFormGroup: UntypedFormGroup; - provisionConfigFormGroup: FormGroup; + provisionConfigFormGroup: UntypedFormGroup; - credentialsFormGroup: FormGroup; + credentialsFormGroup: UntypedFormGroup; - customerFormGroup: FormGroup; + customerFormGroup: UntypedFormGroup; - labelPosition: MatHorizontalStepper['labelPosition'] = 'end'; + labelPosition: MatStepper['labelPosition'] = 'end'; serviceType = ServiceType.TB_RULE_ENGINE; @@ -102,7 +102,7 @@ export class DeviceWizardDialogComponent extends private deviceProfileService: DeviceProfileService, private deviceService: DeviceService, private breakpointObserver: BreakpointObserver, - private fb: FormBuilder) { + private fb: UntypedFormBuilder) { super(store, router, dialogRef); this.deviceWizardFormGroup = this.fb.group({ name: ['', [Validators.required, Validators.maxLength(255)]], @@ -205,7 +205,7 @@ export class DeviceWizardDialogComponent extends this.subscriptions.forEach(s => s.unsubscribe()); } - isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean { + isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean { const originalErrorState = this.errorStateMatcher.isErrorState(control, form); const customErrorState = !!(control && control.invalid); return originalErrorState || customErrorState; diff --git a/ui-ngx/src/app/modules/home/dialogs/add-entities-to-customer-dialog.component.html b/ui-ngx/src/app/modules/home/dialogs/add-entities-to-customer-dialog.component.html index bc8aadedae..2588c38ba3 100644 --- a/ui-ngx/src/app/modules/home/dialogs/add-entities-to-customer-dialog.component.html +++ b/ui-ngx/src/app/modules/home/dialogs/add-entities-to-customer-dialog.component.html @@ -1,6 +1,6 @@ - - {{section.icon}} - + + {{section.icon}} + {{section.name | translate}} diff --git a/ui-ngx/src/app/modules/home/menu/menu-link.component.scss b/ui-ngx/src/app/modules/home/menu/menu-link.component.scss index da8df4b469..66df772d2d 100644 --- a/ui-ngx/src/app/modules/home/menu/menu-link.component.scss +++ b/ui-ngx/src/app/modules/home/menu/menu-link.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2022 The Thingsboard Authors + * Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/modules/home/menu/menu-link.component.ts b/ui-ngx/src/app/modules/home/menu/menu-link.component.ts index cae14d32b6..f2d43248bd 100644 --- a/ui-ngx/src/app/modules/home/menu/menu-link.component.ts +++ b/ui-ngx/src/app/modules/home/menu/menu-link.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/modules/home/menu/menu-toggle.component.html b/ui-ngx/src/app/modules/home/menu/menu-toggle.component.html index 0635205581..de4f16b163 100644 --- a/ui-ngx/src/app/modules/home/menu/menu-toggle.component.html +++ b/ui-ngx/src/app/modules/home/menu/menu-toggle.component.html @@ -1,6 +1,6 @@ - - {{section.icon}} - + + {{section.icon}} + {{section.name | translate}} + [ngClass]="{'tb-toggled' : section.opened}">
  • diff --git a/ui-ngx/src/app/modules/home/menu/menu-toggle.component.scss b/ui-ngx/src/app/modules/home/menu/menu-toggle.component.scss index a076fa77d6..93d0276639 100644 --- a/ui-ngx/src/app/modules/home/menu/menu-toggle.component.scss +++ b/ui-ngx/src/app/modules/home/menu/menu-toggle.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2022 The Thingsboard Authors + * Copyright © 2016-2023 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. @@ -18,6 +18,7 @@ .tb-toggle-icon { display: inline-block; width: 15px; + min-width: 15px; margin: auto 0 auto auto; background-size: 100% auto; transition: transform .3s ease-in-out; @@ -32,11 +33,11 @@ z-index: 1; overflow: hidden; - transition: .75s cubic-bezier(.35, 0, .25, 1); + transition: .45s cubic-bezier(.35, 0, .25, 1); transition-property: height; - a.mat-button { + a.mat-mdc-button { padding: 0 16px 0 32px; font-weight: 500; text-transform: none !important; diff --git a/ui-ngx/src/app/modules/home/menu/menu-toggle.component.ts b/ui-ngx/src/app/modules/home/menu/menu-toggle.component.ts index 76a818c16b..70d711469a 100644 --- a/ui-ngx/src/app/modules/home/menu/menu-toggle.component.ts +++ b/ui-ngx/src/app/modules/home/menu/menu-toggle.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -17,6 +17,9 @@ import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; import { MenuSection } from '@core/services/menu.models'; import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { ActionPreferencesUpdateOpenedMenuSection } from '@core/auth/auth.actions'; @Component({ selector: 'tb-menu-toggle', @@ -28,24 +31,27 @@ export class MenuToggleComponent implements OnInit { @Input() section: MenuSection; - constructor(private router: Router) { + constructor(private router: Router, + private store: Store) { } ngOnInit() { } - sectionActive(): boolean { - return this.router.isActive(this.section.path, false); - } - sectionHeight(): string { - if (this.router.isActive(this.section.path, false)) { - return this.section.height; + if (this.section.opened) { + return this.section.pages.length * 40 + 'px'; } else { return '0px'; } } + toggleSection(event: MouseEvent) { + event.stopPropagation(); + this.section.opened = !this.section.opened; + this.store.dispatch(new ActionPreferencesUpdateOpenedMenuSection({path: this.section.path, opened: this.section.opened})); + } + trackBySectionPages(index: number, section: MenuSection){ return section.id; } diff --git a/ui-ngx/src/app/modules/home/menu/side-menu.component.html b/ui-ngx/src/app/modules/home/menu/side-menu.component.html index 8a9f07f3c9..b41b05ca76 100644 --- a/ui-ngx/src/app/modules/home/menu/side-menu.component.html +++ b/ui-ngx/src/app/modules/home/menu/side-menu.component.html @@ -1,6 +1,6 @@
    - - -
    - admin.general-settings -
    -
    + + + +
    + admin.general-settings +
    +
    +
    - +
    @@ -35,10 +37,10 @@ {{ 'admin.base-url-required' | translate }} - + {{ 'admin.prohibit-different-url' | translate }} -
    admin.prohibit-different-url-hint
    +
    admin.prohibit-different-url-hint
    - -
    -
    - -
    -
    -
    + +
    + + + +
    + +
    +
    + + +
    + + +
    +
    +
    +
    + + + + + admin.slack-settings + + +
    +
    + + +
    + +
    +
    +
    + + admin.slack-api-token + + +
    +
    + +
    +
    +
    +
    +
    diff --git a/ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.scss b/ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.scss index da8df4b469..66df772d2d 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.scss +++ b/ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2022 The Thingsboard Authors + * Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.ts b/ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.ts index bfd1a38e14..e56f02c4e4 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { Component, OnInit } from '@angular/core'; +import { Component } from '@angular/core'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { PageComponent } from '@shared/components/page.component'; @@ -25,43 +25,62 @@ import { AdminService } from '@core/http/admin.service'; import { HasConfirmForm } from '@core/guards/confirm-on-exit.guard'; import { MatDialog } from '@angular/material/dialog'; import { SendTestSmsDialogComponent, SendTestSmsDialogData } from '@home/pages/admin/send-test-sms-dialog.component'; +import { NotificationSettings } from '@shared/models/notification.models'; +import { deepTrim, isEmptyStr } from '@core/utils'; +import { NotificationService } from '@core/http/notification.service'; +import { Authority } from '@shared/models/authority.enum'; +import { AuthUser } from '@shared/models/user.model'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; @Component({ selector: 'tb-sms-provider', templateUrl: './sms-provider.component.html', styleUrls: ['./sms-provider.component.scss', './settings-card.scss'] }) -export class SmsProviderComponent extends PageComponent implements OnInit, HasConfirmForm { +export class SmsProviderComponent extends PageComponent implements HasConfirmForm { smsProvider: FormGroup; - adminSettings: AdminSettings; + private adminSettings: AdminSettings; + + slackSettingsForm: FormGroup; + private notificationSettings: NotificationSettings; + + private readonly authUser: AuthUser; constructor(protected store: Store, private router: Router, private adminService: AdminService, + private notificationService: NotificationService, private dialog: MatDialog, public fb: FormBuilder) { super(store); - } - - ngOnInit() { + this.authUser = getCurrentAuthUser(this.store); this.buildSmsProviderForm(); - this.adminService.getAdminSettings('sms', {ignoreErrors: true}).subscribe( - (adminSettings) => { - this.adminSettings = adminSettings; - this.smsProvider.reset({configuration: this.adminSettings.jsonValue}); - }, - () => { - this.adminSettings = { - key: 'sms', - jsonValue: null - }; - this.smsProvider.reset({configuration: this.adminSettings.jsonValue}); + this.buildGeneralServerSettingsForm(); + this.notificationService.getNotificationSettings().subscribe( + (settings) => { + this.notificationSettings = settings; + this.slackSettingsForm.reset(this.notificationSettings); } ); + if (this.isSysAdmin()) { + this.adminService.getAdminSettings('sms', {ignoreErrors: true}).subscribe({ + next: adminSettings => { + this.adminSettings = adminSettings; + this.smsProvider.reset({configuration: this.adminSettings.jsonValue}); + }, + error: () => { + this.adminSettings = { + key: 'sms', + jsonValue: null + }; + this.smsProvider.reset({configuration: this.adminSettings.jsonValue}); + } + }); + } } - buildSmsProviderForm() { + private buildSmsProviderForm() { this.smsProvider = this.fb.group({ configuration: [null, [Validators.required]] }); @@ -89,7 +108,42 @@ export class SmsProviderComponent extends PageComponent implements OnInit, HasCo } confirmForm(): FormGroup { - return this.smsProvider; + return this.smsProvider.dirty ? this.smsProvider : this.slackSettingsForm; + } + + private buildGeneralServerSettingsForm() { + this.slackSettingsForm = this.fb.group({ + deliveryMethodsConfigs: this.fb.group({ + SLACK: this.fb.group({ + botToken: [''] + }) + }) + }); + this.registerDisableOnLoadFormControl(this.slackSettingsForm.get('deliveryMethodsConfigs')); + } + + saveNotification(): void { + this.notificationSettings = deepTrim({ + ...this.notificationSettings, + ...this.slackSettingsForm.value + }); + // eslint-disable-next-line guard-for-in + for (const method in this.notificationSettings.deliveryMethodsConfigs) { + const keys = Object.keys(this.notificationSettings.deliveryMethodsConfigs[method]); + if (keys.some(item => isEmptyStr(this.notificationSettings.deliveryMethodsConfigs[method][item]))) { + delete this.notificationSettings.deliveryMethodsConfigs[method]; + } else { + this.notificationSettings.deliveryMethodsConfigs[method].method = method; + } + } + this.notificationService.saveNotificationSettings(this.notificationSettings).subscribe(setting => { + this.notificationSettings = setting; + this.slackSettingsForm.reset(this.notificationSettings); + }); + } + + isSysAdmin(): boolean { + return this.authUser.authority === Authority.SYS_ADMIN; } } diff --git a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html index d658de9589..69cacf3604 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html @@ -1,6 +1,6 @@
    - - -
    - admin.2fa.2fa - -
    -
    -
    + + + + admin.2fa.2fa + + +
    +
    @@ -38,7 +38,7 @@ {{ twoFactorAuthProvidersData.get(provider.value.providerType).name | translate }} @@ -153,7 +153,7 @@ - {{ 'admin.2fa.verification-code-check-rate-limit' | translate }} diff --git a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.scss b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.scss index 254cc51c22..fea3045098 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.scss +++ b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2022 The Thingsboard Authors + * Copyright © 2016-2023 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. @@ -13,11 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - @import "../../../../../scss/constants"; :host { - mat-card.settings-card { + .mat-mdc-card.settings-card { @media #{$mat-md} { width: 90%; } @@ -58,7 +57,7 @@ height: 48px; } - .mat-slide-toggle { + .mat-mdc-slide-toggle { margin-right: 8px; } } @@ -72,6 +71,13 @@ } :host ::ng-deep { + + .mat-mdc-form-field { + .mat-mdc-form-field-infix { + width: 100%; + } + } + .mat-expansion-panel { .mat-expansion-panel-content { font-size: 16px; diff --git a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts index 9e79d2d725..6bd630ae88 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -19,7 +19,7 @@ import { PageComponent } from '@shared/components/page.component'; import { HasConfirmForm } from '@core/guards/confirm-on-exit.guard'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; -import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { UntypedFormArray, UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service'; import { TwoFactorAuthProviderConfigForm, @@ -43,7 +43,7 @@ export class TwoFactorAuthSettingsComponent extends PageComponent implements OnI private readonly destroy$ = new Subject(); private readonly posIntValidation = [Validators.required, Validators.min(1), Validators.pattern(/^\d*$/)]; - twoFaFormGroup: FormGroup; + twoFaFormGroup: UntypedFormGroup; twoFactorAuthProviderType = TwoFactorAuthProviderType; twoFactorAuthProvidersData = twoFactorAuthProvidersData; @@ -51,7 +51,7 @@ export class TwoFactorAuthSettingsComponent extends PageComponent implements OnI constructor(protected store: Store, private twoFaService: TwoFactorAuthenticationService, - private fb: FormBuilder) { + private fb: UntypedFormBuilder) { super(store); } @@ -68,7 +68,7 @@ export class TwoFactorAuthSettingsComponent extends PageComponent implements OnI this.destroy$.complete(); } - confirmForm(): FormGroup { + confirmForm(): UntypedFormGroup { return this.twoFaFormGroup; } @@ -99,9 +99,9 @@ export class TwoFactorAuthSettingsComponent extends PageComponent implements OnI $event.stopPropagation(); } if (currentState) { - this.getByIndexPanel(index).close(); - } else { this.getByIndexPanel(index).open(); + } else { + this.getByIndexPanel(index).close(); } } @@ -109,8 +109,8 @@ export class TwoFactorAuthSettingsComponent extends PageComponent implements OnI return item; } - get providersForm(): FormArray { - return this.twoFaFormGroup.get('providers') as FormArray; + get providersForm(): UntypedFormArray { + return this.twoFaFormGroup.get('providers') as UntypedFormArray; } private build2faSettingsForm(): void { diff --git a/ui-ngx/src/app/modules/home/pages/alarm/alarm-routing.module.ts b/ui-ngx/src/app/modules/home/pages/alarm/alarm-routing.module.ts new file mode 100644 index 0000000000..30a6cbb8fd --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/alarm/alarm-routing.module.ts @@ -0,0 +1,58 @@ +/// +/// Copyright © 2016-2023 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 { Injectable, NgModule } from '@angular/core'; +import { Resolve, RouterModule, Routes } from '@angular/router'; +import { Authority } from '@shared/models/authority.enum'; +import { Observable } from 'rxjs'; +import { OAuth2Service } from '@core/http/oauth2.service'; +import { AlarmTableComponent } from '@home/components/alarm/alarm-table.component'; +import { AlarmsMode } from '@shared/models/alarm.models'; + +@Injectable() +export class OAuth2LoginProcessingUrlResolver implements Resolve { + + constructor(private oauth2Service: OAuth2Service) { + } + + resolve(): Observable { + return this.oauth2Service.getLoginProcessingUrl(); + } +} + +const routes: Routes = [ + { + path: 'alarms', + component: AlarmTableComponent, + data: { + auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], + title: 'alarm.alarms', + breadcrumb: { + label: 'alarm.alarms', + icon: 'notifications' + }, + isPage: true, + alarmsMode: AlarmsMode.ALL + } + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], + providers: [] +}) +export class AlarmRoutingModule { } diff --git a/ui-ngx/src/app/modules/home/pages/alarm/alarm.module.ts b/ui-ngx/src/app/modules/home/pages/alarm/alarm.module.ts new file mode 100644 index 0000000000..cd6f257833 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/alarm/alarm.module.ts @@ -0,0 +1,34 @@ +/// +/// Copyright © 2016-2023 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 { HomeDialogsModule } from '../../dialogs/home-dialogs.module'; +import { HomeComponentsModule } from '@modules/home/components/home-components.module'; +import { AlarmRoutingModule } from '@home/pages/alarm/alarm-routing.module'; + +@NgModule({ + declarations: [], + imports: [ + CommonModule, + SharedModule, + HomeComponentsModule, + HomeDialogsModule, + AlarmRoutingModule + ] +}) +export class AlarmModule { } diff --git a/ui-ngx/src/app/modules/home/pages/api-usage/api-usage-routing.module.ts b/ui-ngx/src/app/modules/home/pages/api-usage/api-usage-routing.module.ts index f1db71d466..a38fbf9958 100644 --- a/ui-ngx/src/app/modules/home/pages/api-usage/api-usage-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/api-usage/api-usage-routing.module.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/modules/home/pages/api-usage/api-usage.component.html b/ui-ngx/src/app/modules/home/pages/api-usage/api-usage.component.html index 6563b6d941..7c9cc1bba1 100644 --- a/ui-ngx/src/app/modules/home/pages/api-usage/api-usage.component.html +++ b/ui-ngx/src/app/modules/home/pages/api-usage/api-usage.component.html @@ -1,6 +1,6 @@ { 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 452dce8bcd..22ab3d855e 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 @@ -1,6 +1,6 @@ { diff --git a/ui-ngx/src/app/modules/home/pages/device/device-tabs.component.html b/ui-ngx/src/app/modules/home/pages/device/device-tabs.component.html index a2862382e5..7ce78ec6ac 100644 --- a/ui-ngx/src/app/modules/home/pages/device/device-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/device/device-tabs.component.html @@ -1,6 +1,6 @@ +
    + +

    info_outline + {{ 'edge.install-connect-instructions' | translate }}

    + + +
    + + +
    +
    + +
    +
    + +
    +
    diff --git a/ui-ngx/src/app/modules/home/pages/edge/edge-instructions-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/edge/edge-instructions-dialog.component.ts new file mode 100644 index 0000000000..a4dfe4345b --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/edge/edge-instructions-dialog.component.ts @@ -0,0 +1,46 @@ +/// +/// Copyright © 2016-2023 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 } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from "@angular/material/dialog"; +import { DialogComponent } from "@shared/components/dialog.component"; +import { Store } from "@ngrx/store"; +import { AppState } from "@core/core.state"; +import { Router } from "@angular/router"; + +export interface EdgeInstructionsData { + instructions: string; +} + +@Component({ + selector: 'tb-edge-instructions', + templateUrl: './edge-instructions-dialog.component.html' +}) +export class EdgeInstructionsDialogComponent extends DialogComponent { + + instructions: string = this.data.instructions; + + constructor(protected store: Store, + protected router: Router, + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: EdgeInstructionsData) { + super(store, router, dialogRef); + } + + cancel(): void { + this.dialogRef.close(null); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/edge/edge-routing.module.ts b/ui-ngx/src/app/modules/home/pages/edge/edge-routing.module.ts index 297669002a..5dcdee3169 100644 --- a/ui-ngx/src/app/modules/home/pages/edge/edge-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/edge/edge-routing.module.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -44,49 +44,28 @@ import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages const routes: Routes = [ { - path: 'edgeInstances', + path: 'edgeManagement', data: { breadcrumb: { - label: 'edge.edge-instances', - icon: 'router' + label: 'edge.management', + icon: 'settings_input_antenna' } }, children: [ { path: '', - component: EntitiesTableComponent, + children: [], data: { auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], - title: 'edge.edge-instances', - edgesType: 'tenant' - }, - resolve: { - entitiesTableConfig: EdgesTableConfigResolver + redirectTo: '/edgeManagement/instances' } }, { - path: ':entityId', - component: EntityDetailsPageComponent, - canDeactivate: [ConfirmOnExitGuard], + path: 'instances', data: { breadcrumb: { - labelFunction: entityDetailsPageBreadcrumbLabelFunction, + label: 'edge.instances', icon: 'router' - } as BreadCrumbConfig, - auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], - title: 'edge.edge-instances', - edgesType: 'tenant' - }, - resolve: { - entitiesTableConfig: EdgesTableConfigResolver - } - }, - { - path: ':edgeId/assets', - data: { - breadcrumb: { - label: 'edge.assets', - icon: 'domain' } }, children: [ @@ -95,11 +74,11 @@ const routes: Routes = [ component: EntitiesTableComponent, data: { auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], - title: 'edge.assets', - assetsType: 'edge' + title: 'edge.edge-instances', + edgesType: 'tenant' }, resolve: { - entitiesTableConfig: AssetsTableConfigResolver + entitiesTableConfig: EdgesTableConfigResolver } }, { @@ -109,197 +88,219 @@ const routes: Routes = [ data: { breadcrumb: { labelFunction: entityDetailsPageBreadcrumbLabelFunction, - icon: 'domain' + icon: 'router' } as BreadCrumbConfig, auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], - title: 'edge.assets', - assetsType: 'edge' - }, - resolve: { - entitiesTableConfig: AssetsTableConfigResolver - } - } - ] - }, - { - path: ':edgeId/devices', - data: { - breadcrumb: { - label: 'edge.devices', - icon: 'devices_other' - } - }, - children: [ - { - path: '', - component: EntitiesTableComponent, - data: { - auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], - title: 'edge.devices', - devicesType: 'edge' + title: 'edge.edge-instances', + edgesType: 'tenant' }, resolve: { - entitiesTableConfig: DevicesTableConfigResolver + entitiesTableConfig: EdgesTableConfigResolver } }, { - path: ':entityId', - component: EntityDetailsPageComponent, - canDeactivate: [ConfirmOnExitGuard], + path: ':edgeId/assets', data: { breadcrumb: { - labelFunction: entityDetailsPageBreadcrumbLabelFunction, - icon: 'devices_other' - } as BreadCrumbConfig, - auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], - title: 'edge.devices', - devicesType: 'edge' - }, - resolve: { - entitiesTableConfig: DevicesTableConfigResolver - } - } - ] - }, - { - path: ':edgeId/entityViews', - data: { - breadcrumb: { - label: 'edge.entity-views', - icon: 'view_quilt' - }, - }, - children: [ - { - path: '', - component: EntitiesTableComponent, - data: { - auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], - title: 'edge.entity-views', - entityViewsType: 'edge' + label: 'edge.assets', + icon: 'domain' + } }, - resolve: { - entitiesTableConfig: EntityViewsTableConfigResolver - } + children: [ + { + path: '', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], + title: 'edge.assets', + assetsType: 'edge' + }, + resolve: { + entitiesTableConfig: AssetsTableConfigResolver + } + }, + { + path: ':entityId', + component: EntityDetailsPageComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + breadcrumb: { + labelFunction: entityDetailsPageBreadcrumbLabelFunction, + icon: 'domain' + } as BreadCrumbConfig, + auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], + title: 'edge.assets', + assetsType: 'edge' + }, + resolve: { + entitiesTableConfig: AssetsTableConfigResolver + } + } + ] }, { - path: ':entityId', - component: EntityDetailsPageComponent, - canDeactivate: [ConfirmOnExitGuard], + path: ':edgeId/devices', data: { breadcrumb: { - labelFunction: entityDetailsPageBreadcrumbLabelFunction, + label: 'edge.devices', icon: 'devices_other' - } as BreadCrumbConfig, - auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], - title: 'edge.entity-views', - entityViewsType: 'edge' + } }, - resolve: { - entitiesTableConfig: EntityViewsTableConfigResolver - } - } - ] - }, - { - path: ':edgeId/dashboards', - data: { - breadcrumb: { - label: 'edge.dashboards', - icon: 'dashboard' - } - }, - children: [ + children: [ + { + path: '', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], + title: 'edge.devices', + devicesType: 'edge' + }, + resolve: { + entitiesTableConfig: DevicesTableConfigResolver + } + }, + { + path: ':entityId', + component: EntityDetailsPageComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + breadcrumb: { + labelFunction: entityDetailsPageBreadcrumbLabelFunction, + icon: 'devices_other' + } as BreadCrumbConfig, + auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], + title: 'edge.devices', + devicesType: 'edge' + }, + resolve: { + entitiesTableConfig: DevicesTableConfigResolver + } + } + ] + }, { - path: '', - component: EntitiesTableComponent, + path: ':edgeId/entityViews', data: { - auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], - dashboardsType: 'edge' - }, - resolve: { - entitiesTableConfig: DashboardsTableConfigResolver + breadcrumb: { + label: 'edge.entity-views', + icon: 'view_quilt' + }, }, + children: [ + { + path: '', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], + title: 'edge.entity-views', + entityViewsType: 'edge' + }, + resolve: { + entitiesTableConfig: EntityViewsTableConfigResolver + } + }, + { + path: ':entityId', + component: EntityDetailsPageComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + breadcrumb: { + labelFunction: entityDetailsPageBreadcrumbLabelFunction, + icon: 'devices_other' + } as BreadCrumbConfig, + auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], + title: 'edge.entity-views', + entityViewsType: 'edge' + }, + resolve: { + entitiesTableConfig: EntityViewsTableConfigResolver + } + } + ] }, { - path: ':dashboardId', - component: DashboardPageComponent, + path: ':edgeId/dashboards', data: { breadcrumb: { - labelFunction: dashboardBreadcumbLabelFunction, + label: 'edge.dashboards', icon: 'dashboard' - } as BreadCrumbConfig, - auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], - title: 'edge.dashboard', - widgetEditMode: false - }, - resolve: { - dashboard: DashboardResolver - } - } - ] - }, - { - path: ':edgeId/ruleChains', - data: { - breadcrumb: { - label: 'edge.edge-rulechains', - icon: 'settings_ethernet' - } - }, - children: [ - { - path: '', - component: EntitiesTableComponent, - data: { - auth: [Authority.TENANT_ADMIN], - title: 'edge.rulechains', - ruleChainsType: 'edge' + } }, - resolve: { - entitiesTableConfig: RuleChainsTableConfigResolver - } + children: [ + { + path: '', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], + dashboardsType: 'edge' + }, + resolve: { + entitiesTableConfig: DashboardsTableConfigResolver + }, + }, + { + path: ':dashboardId', + component: DashboardPageComponent, + data: { + breadcrumb: { + labelFunction: dashboardBreadcumbLabelFunction, + icon: 'dashboard' + } as BreadCrumbConfig, + auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], + title: 'edge.dashboard', + widgetEditMode: false + }, + resolve: { + dashboard: DashboardResolver + } + } + ] }, { - path: ':ruleChainId', - component: RuleChainPageComponent, - canDeactivate: [ConfirmOnExitGuard], + path: ':edgeId/ruleChains', data: { breadcrumb: { - labelFunction: ruleChainBreadcumbLabelFunction, + label: 'edge.edge-rulechains', icon: 'settings_ethernet' - } as BreadCrumbConfig, - auth: [Authority.TENANT_ADMIN], - title: 'rulechain.edge-rulechain', - import: false, - ruleChainType: RuleChainType.EDGE + } }, - resolve: { - ruleChain: RuleChainResolver, - ruleChainMetaData: RuleChainMetaDataResolver, - ruleNodeComponents: RuleNodeComponentsResolver, - tooltipster: TooltipsterResolver - } + children: [ + { + path: '', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'edge.rulechains', + ruleChainsType: 'edge' + }, + resolve: { + entitiesTableConfig: RuleChainsTableConfigResolver + } + }, + { + path: ':ruleChainId', + component: RuleChainPageComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + breadcrumb: { + labelFunction: ruleChainBreadcumbLabelFunction, + icon: 'settings_ethernet' + } as BreadCrumbConfig, + auth: [Authority.TENANT_ADMIN], + title: 'rulechain.edge-rulechain', + import: false, + ruleChainType: RuleChainType.EDGE + }, + resolve: { + ruleChain: RuleChainResolver, + ruleChainMetaData: RuleChainMetaDataResolver, + ruleNodeComponents: RuleNodeComponentsResolver, + tooltipster: TooltipsterResolver + } + } + ] } ] - } - ] - }, - { - path: 'edgeManagement', - data: { - breadcrumb: { - label: 'edge.management', - icon: 'settings_input_antenna' - } - }, - children: [ - { - path: '', - data: { - auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], - redirectTo: '/edgeManagement/ruleChains' - } }, { path: 'ruleChains', @@ -366,7 +367,63 @@ const routes: Routes = [ ] } ] - }]; + }, + { + path: 'edgeInstances', + pathMatch: 'full', + redirectTo: '/edgeManagement/instances' + }, + { + path: 'edgeInstances/:entityId', + pathMatch: 'full', + redirectTo: '/edgeManagement/instances/:entityId' + }, + { + path: 'edgeInstances/:edgeId/assets', + pathMatch: 'full', + redirectTo: '/edgeManagement/instances/:edgeId/assets' + }, + { + path: 'edgeInstances/:edgeId/assets/:entityId', + redirectTo: '/edgeManagement/instances/:edgeId/assets/:entityId' + }, + { + path: 'edgeInstances/:edgeId/devices', + pathMatch: 'full', + redirectTo: '/edgeManagement/instances/:edgeId/devices' + }, + { + path: 'edgeInstances/:edgeId/devices/:entityId', + redirectTo: '/edgeManagement/instances/:edgeId/devices/:entityId' + }, + { + path: 'edgeInstances/:edgeId/entityViews', + pathMatch: 'full', + redirectTo: '/edgeManagement/instances/:edgeId/entityViews' + }, + { + path: 'edgeInstances/:edgeId/entityViews/:entityId', + redirectTo: '/edgeManagement/instances/:edgeId/entityViews/:entityId' + }, + { + path: 'edgeInstances/:edgeId/dashboards', + pathMatch: 'full', + redirectTo: '/edgeManagement/instances/:edgeId/dashboards' + }, + { + path: 'edgeInstances/:edgeId/dashboards/:dashboardId', + redirectTo: '/edgeManagement/instances/:edgeId/dashboards/:dashboardId' + }, + { + path: 'edgeInstances/:edgeId/ruleChains', + pathMatch: 'full', + redirectTo: '/edgeManagement/instances/:edgeId/ruleChains' + }, + { + path: 'edgeInstances/:edgeId/ruleChains/:ruleChainId', + redirectTo: '/edgeManagement/instances/:edgeId/ruleChains/:ruleChainId' + } +]; @NgModule({ imports: [RouterModule.forChild(routes)], diff --git a/ui-ngx/src/app/modules/home/pages/edge/edge-table-header.component.html b/ui-ngx/src/app/modules/home/pages/edge/edge-table-header.component.html index 2b2d8a1e4c..8a7338a8ff 100644 --- a/ui-ngx/src/app/modules/home/pages/edge/edge-table-header.component.html +++ b/ui-ngx/src/app/modules/home/pages/edge/edge-table-header.component.html @@ -1,6 +1,6 @@ { diff --git a/ui-ngx/src/app/modules/home/pages/edge/edge-tabs.component.html b/ui-ngx/src/app/modules/home/pages/edge/edge-tabs.component.html index 99a8cd7ebb..caaf368f3d 100644 --- a/ui-ngx/src/app/modules/home/pages/edge/edge-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/edge/edge-tabs.component.html @@ -1,6 +1,6 @@ { diff --git a/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-tabs.component.html b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-tabs.component.html index 79307ddcb2..87ee70f1a1 100644 --- a/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-tabs.component.html @@ -1,6 +1,6 @@ + diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-select.component.scss b/ui-ngx/src/app/modules/home/pages/notification/inbox/inbox-notification-dialog.component.scss similarity index 81% rename from ui-ngx/src/app/modules/home/components/filter/filter-select.component.scss rename to ui-ngx/src/app/modules/home/pages/notification/inbox/inbox-notification-dialog.component.scss index 6132c393a9..222482999d 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-select.component.scss +++ b/ui-ngx/src/app/modules/home/pages/notification/inbox/inbox-notification-dialog.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2022 The Thingsboard Authors + * Copyright © 2016-2023 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. @@ -13,12 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -:host { - -} - -:host ::ng-deep { - .mat-form-field-infix { - border-top: none; - } +:host{ + display: block; + max-width: 400px; + min-width: 350px; + padding: 2px; } diff --git a/ui-ngx/src/app/modules/home/pages/notification/inbox/inbox-notification-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/notification/inbox/inbox-notification-dialog.component.ts new file mode 100644 index 0000000000..275caa6647 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/inbox/inbox-notification-dialog.component.ts @@ -0,0 +1,50 @@ +/// +/// Copyright © 2016-2023 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 } 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 { Notification } from '@shared/models/notification.models'; + +export interface InboxNotificationDialogData { + notification: Notification; +} + +@Component({ + selector: 'tb-inbox-notification-dialog', + templateUrl: './inbox-notification-dialog.component.html', + styleUrls: ['inbox-notification-dialog.component.scss'] +}) +export class InboxNotificationDialogComponent extends DialogComponent { + + notification: Notification; + + constructor(protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: InboxNotificationDialogData) { + super(store, router, dialogRef); + + this.notification = data.notification; + } + + markAsRead(id: string) { + this.dialogRef.close(id); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/notification/inbox/inbox-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/notification/inbox/inbox-table-config.resolver.ts new file mode 100644 index 0000000000..f8088c28cb --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/inbox/inbox-table-config.resolver.ts @@ -0,0 +1,165 @@ +/// +/// Copyright © 2016-2023 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, + DateEntityTableColumn, + EntityTableColumn, + EntityTableConfig +} from '@home/models/entity/entities-table-config.models'; +import { EntityType, EntityTypeResource, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { DatePipe } from '@angular/common'; +import { Direction } from '@shared/models/page/sort-order'; +import { + Notification, + NotificationStatus, + NotificationTemplateTypeTranslateMap +} from '@shared/models/notification.models'; +import { NotificationService } from '@core/http/notification.service'; +import { InboxTableHeaderComponent } from '@home/pages/notification/inbox/inbox-table-header.component'; +import { TranslateService } from '@ngx-translate/core'; +import { take } from 'rxjs/operators'; +import { MatDialog } from '@angular/material/dialog'; +import { + InboxNotificationDialogComponent, + InboxNotificationDialogData +} from '@home/pages/notification/inbox/inbox-notification-dialog.component'; +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve } from '@angular/router'; + +@Injectable() +export class InboxTableConfigResolver implements Resolve> { + + private readonly config: EntityTableConfig = new EntityTableConfig(); + + constructor(private notificationService: NotificationService, + private translate: TranslateService, + private dialog: MatDialog, + private datePipe: DatePipe) { + + this.config.entityType = EntityType.NOTIFICATION; + this.config.detailsPanelEnabled = false; + this.config.addEnabled = false; + this.config.rowPointer = true; + this.config.entityTranslations = entityTypeTranslations.get(EntityType.NOTIFICATION); + this.config.entityResources = {} as EntityTypeResource; + + this.config.deleteEntityTitle = () => this.translate.instant('notification.delete-notification-title'); + this.config.deleteEntityContent = () => this.translate.instant('notification.delete-notification-text'); + this.config.deleteEntitiesTitle = count => this.translate.instant('notification.delete-notifications-title', {count}); + this.config.deleteEntitiesContent = () => this.translate.instant('notification.delete-notifications-text'); + + this.config.deleteEntity = id => this.notificationService.deleteNotification(id.id); + this.config.entitiesFetchFunction = pageLink => + this.notificationService.getNotifications(pageLink, this.config.componentsData.unreadOnly); + + this.config.defaultSortOrder = {property: 'createdTime', direction: Direction.DESC}; + + this.config.handleRowClick = ($event, notification) => { + this.showNotification($event, notification); + return true; + }; + + this.config.componentsData = { + unreadOnly: true + }; + + this.config.cellActionDescriptors = this.configureCellActions(); + + this.config.headerComponent = InboxTableHeaderComponent; + + this.config.headerActionDescriptors = [{ + name: this.translate.instant('notification.mark-all-as-read'), + icon: 'done_all', + isEnabled: () => true, + onAction: $event => this.markAllRead($event) + }]; + + this.config.columns.push( + new DateEntityTableColumn('createdTime', 'common.created-time', this.datePipe, '170px'), + new EntityTableColumn('type', 'notification.type', '10%', (notification) => + this.translate.instant(NotificationTemplateTypeTranslateMap.get(notification.type).name)), + new EntityTableColumn('subject', 'notification.subject', '30%'), + new EntityTableColumn('text', 'notification.message', '60%') + ); + + } + + resolve(route: ActivatedRouteSnapshot): EntityTableConfig { + return this.config; + } + + private configureCellActions(): Array> { + return [{ + name: this.translate.instant('notification.mark-as-read'), + icon: 'check_circle_outline', + isEnabled: (notification) => notification.status !== NotificationStatus.READ, + onAction: ($event, entity) => this.markAsRead($event, entity) + }]; + } + + private markAllRead($event: Event) { + if ($event) { + $event.stopPropagation(); + } + this.notificationService.markAllNotificationsAsRead().subscribe(() => { + if (this.config.componentsData.unreadOnly) { + this.config.getTable().resetSortAndFilter(true); + } else { + this.config.updateData(); + } + }); + } + + private markAsRead($event, entity){ + if ($event) { + $event.stopPropagation(); + } + this.notificationService.markNotificationAsRead(entity.id.id).subscribe(() => { + if (this.config.componentsData.unreadOnly) { + this.config.getTable().dataSource.pageData$.pipe(take(1)).subscribe( + (value) => { + if (value.data.length === 1 && this.config.getTable().pageLink.page) { + this.config.getTable().pageLink.page--; + } + this.config.updateData(); + } + ); + } else { + entity.status = NotificationStatus.READ; + this.config.getTable().detectChanges(); + } + }); + } + + private showNotification($event: Event, notification: Notification) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(InboxNotificationDialogComponent, { + disableClose: false, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + notification + } + }).afterClosed().subscribe(res => { + if (res) { + this.markAsRead(null, notification); + } + }); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/notification/inbox/inbox-table-header.component.html b/ui-ngx/src/app/modules/home/pages/notification/inbox/inbox-table-header.component.html new file mode 100644 index 0000000000..be19b8beaf --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/inbox/inbox-table-header.component.html @@ -0,0 +1,27 @@ + +
    + + {{ 'notification.unread' | translate }} + {{ 'notification.all' | translate }} + + +
    diff --git a/ui-ngx/src/app/modules/home/pages/notification/inbox/inbox-table-header.component.scss b/ui-ngx/src/app/modules/home/pages/notification/inbox/inbox-table-header.component.scss new file mode 100644 index 0000000000..01519235aa --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/inbox/inbox-table-header.component.scss @@ -0,0 +1,72 @@ +/** + * Copyright © 2016-2023 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: 14px; + } + } + + .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: 14px; + font-weight: 500; + letter-spacing: .25px; + } + + .mat-button-toggle-checked.mat-button-toggle-appearance-standard:not(.mat-button-toggle-disabled):hover .mat-button-toggle-focus-overlay { + opacity: .01; + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/notification/inbox/inbox-table-header.component.ts b/ui-ngx/src/app/modules/home/pages/notification/inbox/inbox-table-header.component.ts new file mode 100644 index 0000000000..120d22c15e --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/inbox/inbox-table-header.component.ts @@ -0,0 +1,39 @@ +/// +/// Copyright © 2016-2023 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 { Notification } from '@shared/models/notification.models'; + +@Component({ + selector: 'tb-inbox-table-header', + templateUrl: './inbox-table-header.component.html', + styleUrls: ['inbox-table-header.component.scss'] +}) +export class InboxTableHeaderComponent extends EntityTableHeaderComponent { + + constructor(protected store: Store) { + super(store); + } + + changeUnreadOnly(unreadOnly: boolean) { + this.entitiesTableConfig.componentsData.unreadOnly = unreadOnly; + this.entitiesTableConfig.getTable().resetSortAndFilter(true); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/notification/notification-routing.module.ts b/ui-ngx/src/app/modules/home/pages/notification/notification-routing.module.ts new file mode 100644 index 0000000000..480cf97f99 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/notification-routing.module.ts @@ -0,0 +1,140 @@ +/// +/// Copyright © 2016-2023 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 { RouterModule, Routes } from '@angular/router'; +import { Authority } from '@shared/models/authority.enum'; +import { NgModule } from '@angular/core'; +import { RouterTabsComponent } from '@home/components/router-tabs.component'; +import { EntitiesTableComponent } from '@home/components/entity/entities-table.component'; +import { InboxTableConfigResolver } from '@home/pages/notification/inbox/inbox-table-config.resolver'; +import { SentTableConfigResolver } from '@home/pages/notification/sent/sent-table-config.resolver'; +import { RecipientTableConfigResolver } from '@home/pages/notification/recipient/recipient-table-config.resolver'; +import { TemplateTableConfigResolver } from '@home/pages/notification/template/template-table-config.resolver'; +import { RuleTableConfigResolver } from '@home/pages/notification/rule/rule-table-config.resolver'; +import { SendNotificationButtonComponent } from '@home/components/notification/send-notification-button.component'; + +const routes: Routes = [ + { + path: 'notification', + component: RouterTabsComponent, + data: { + auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER, Authority.SYS_ADMIN], + breadcrumb: { + label: 'notification.notification-center', + icon: 'mdi:message-badge' + }, + routerTabsHeaderComponent: SendNotificationButtonComponent + }, + children: [ + { + path: '', + children: [], + data: { + auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER, Authority.SYS_ADMIN], + redirectTo: '/notification/inbox' + } + }, + { + path: 'inbox', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER, Authority.SYS_ADMIN], + title: 'notification.inbox', + breadcrumb: { + label: 'notification.inbox', + icon: 'inbox' + } + }, + resolve: { + entitiesTableConfig: InboxTableConfigResolver + } + }, + { + path: 'sent', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN, Authority.SYS_ADMIN], + title: 'notification.sent', + breadcrumb: { + label: 'notification.sent', + icon: 'outbox' + } + }, + resolve: { + entitiesTableConfig: SentTableConfigResolver + } + }, + { + path: 'templates', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN, Authority.SYS_ADMIN], + title: 'notification.templates', + breadcrumb: { + label: 'notification.templates', + icon: 'mdi:message-draw' + } + }, + resolve: { + entitiesTableConfig: TemplateTableConfigResolver + } + }, + { + path: 'recipients', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN, Authority.SYS_ADMIN], + title: 'notification.recipients', + breadcrumb: { + label: 'notification.recipients', + icon: 'contacts' + }, + }, + resolve: { + entitiesTableConfig: RecipientTableConfigResolver + } + }, + { + path: 'rules', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN, Authority.SYS_ADMIN], + title: 'notification.rules', + breadcrumb: { + label: 'notification.rules', + icon: 'mdi:message-cog' + } + }, + resolve: { + entitiesTableConfig: RuleTableConfigResolver + } + } + ] + } +]; + +@NgModule({ + providers: [ + InboxTableConfigResolver, + SentTableConfigResolver, + RecipientTableConfigResolver, + TemplateTableConfigResolver, + RuleTableConfigResolver + ], + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class NotificationRoutingModule { } diff --git a/ui-ngx/src/app/modules/home/pages/notification/notification.module.ts b/ui-ngx/src/app/modules/home/pages/notification/notification.module.ts new file mode 100644 index 0000000000..5ab7bfa957 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/notification.module.ts @@ -0,0 +1,61 @@ +/// +/// Copyright © 2016-2023 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 { NotificationRoutingModule } from '@home/pages/notification/notification-routing.module'; +import { InboxTableHeaderComponent } from '@home/pages/notification/inbox/inbox-table-header.component'; +import { InboxNotificationDialogComponent } from '@home/pages/notification/inbox/inbox-notification-dialog.component'; +import { HomeComponentsModule } from '@home/components/home-components.module'; +import { SentErrorDialogComponent } from '@home/pages/notification/sent/sent-error-dialog.component'; +import { SentNotificationDialogComponent } from '@home/pages/notification/sent/sent-notification-dialog.componet'; +import { + RecipientNotificationDialogComponent +} from '@home/pages/notification/recipient/recipient-notification-dialog.component'; +import { RecipientTableHeaderComponent } from '@home/pages/notification/recipient/recipient-table-header.component'; +import { + TemplateNotificationDialogComponent +} from '@home/pages/notification/template/template-notification-dialog.component'; +import { TemplateTableHeaderComponent } from '@home/pages/notification/template/template-table-header.component'; +import { EscalationFormComponent } from '@home/pages/notification/rule/escalation-form.component'; +import { EscalationsComponent } from '@home/pages/notification/rule/escalations.component'; +import { RuleNotificationDialogComponent } from '@home/pages/notification/rule/rule-notification-dialog.component'; +import { RuleTableHeaderComponent } from '@home/pages/notification/rule/rule-table-header.component'; + +@NgModule({ + declarations: [ + InboxTableHeaderComponent, + InboxNotificationDialogComponent, + SentErrorDialogComponent, + SentNotificationDialogComponent, + RecipientNotificationDialogComponent, + RecipientTableHeaderComponent, + TemplateNotificationDialogComponent, + TemplateTableHeaderComponent, + EscalationFormComponent, + EscalationsComponent, + RuleNotificationDialogComponent, + RuleTableHeaderComponent + ], + imports: [ + CommonModule, + SharedModule, + NotificationRoutingModule, + HomeComponentsModule + ] +}) +export class NotificationModule { } diff --git a/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-notification-dialog.component.html b/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-notification-dialog.component.html new file mode 100644 index 0000000000..9eecacf268 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-notification-dialog.component.html @@ -0,0 +1,139 @@ + +
    + +

    {{ (isAdd ? 'notification.add-notification-recipients-group' : 'notification.edit-notification-recipients-group') | translate }}

    + + +
    + + +
    +
    + + notification.name + + + {{ 'notification.name-required' | translate }} + + +
    + + + +
    +
    {{ notificationTargetTypeTranslationMap.get(notificationTargetType) | translate }}
    +
    +
    +
    +
    + + notification.recipient-type.user-filters + + + {{ notificationTargetConfigTypeInfoMap.get(type).name | translate }} + + + {{ notificationTargetConfigTypeInfoMap.get(targetNotificationForm.get('configuration.usersFilter.type').value).hint | translate }} + + + +
    +
    + + {{ 'tenant.tenant' | translate }} + {{ 'tenant-profile.tenant-profile' | translate }} + +
    + + + + + + + + +
    +
    + + + + + + + + +
    +
    +
    + + + + {{ slackChanelTypesTranslateMap.get(slackChanelType) | translate }} + + + + +
    + + notification.description + + +
    +
    + +
    + + +
    +
    diff --git a/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-notification-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-notification-dialog.component.scss new file mode 100644 index 0000000000..8406cae3fe --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-notification-dialog.component.scss @@ -0,0 +1,109 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import "../../../../../../scss/constants"; +@import "../../../../../../theme"; + +:host { + form.tb-dialog-container { + min-width: 600px; + max-height: 100vh; + } + + .mat-dialog-content { + height: 85%; + } + + .tb-title { + display: block; + padding-bottom: 6px; + } +} + +:host ::ng-deep { + .mat-mdc-radio-group { + display: flex; + flex-direction: row; + margin-bottom: 22px; + gap: 12px; + + .mat-mdc-radio-button { + flex: 1 1 100%; + padding: 4px; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 6px; + } + } + + @media #{$mat-xs} { + .mat-mdc-radio-group { + flex-direction: column; + } + } + + .mat-button-toggle-group.tb-notification-tenant-group { + &.mat-button-toggle-group-appearance-standard { + border: none; + border-radius: 18px; + margin-bottom: 14px; + + .mat-button-toggle + .mat-button-toggle { + border-left: none; + } + } + + .mat-button-toggle { + background: rgba(0, 0, 0, 0.06); + height: 36px; + align-items: center; + display: flex; + + .mat-button-toggle-ripple { + top: 2px; + left: 2px; + right: 2px; + bottom: 2px; + border-radius: 18px; + } + } + + .mat-button-toggle-button { + color: #959595; + } + + .mat-button-toggle-focus-overlay { + border-radius: 18px; + margin: 2px; + } + + .mat-button-toggle-checked .mat-button-toggle-button { + background-color: $tb-primary-color; + color: #fff; + border-radius: 18px; + margin-left: 2px; + margin-right: 2px; + } + + .mat-button-toggle-appearance-standard .mat-button-toggle-label-content { + line-height: 34px; + 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; + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-notification-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-notification-dialog.component.ts new file mode 100644 index 0000000000..c07d8beb75 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-notification-dialog.component.ts @@ -0,0 +1,210 @@ +/// +/// Copyright © 2016-2023 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 { + NotificationTarget, + NotificationTargetConfigType, + NotificationTargetConfigTypeInfoMap, + NotificationTargetType, + NotificationTargetTypeTranslationMap, + SlackChanelType, + SlackChanelTypesTranslateMap +} from '@shared/models/notification.models'; +import { Component, Inject, OnDestroy } 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 { deepTrim, isDefinedAndNotNull } from '@core/utils'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { Authority } from '@shared/models/authority.enum'; +import { AuthState } from '@core/auth/auth.models'; +import { getCurrentAuthState } from '@core/auth/auth.selectors'; +import { AuthUser } from '@shared/models/user.model'; +import { control } from 'leaflet'; + +export interface RecipientNotificationDialogData { + target?: NotificationTarget; + isAdd?: boolean; +} + +@Component({ + selector: 'tb-target-notification-dialog', + templateUrl: './recipient-notification-dialog.component.html', + styleUrls: ['recipient-notification-dialog.component.scss'] +}) +export class RecipientNotificationDialogComponent extends + DialogComponent implements OnDestroy { + + private authState: AuthState = getCurrentAuthState(this.store); + private authUser: AuthUser = this.authState.authUser; + + targetNotificationForm: FormGroup; + notificationTargetType = NotificationTargetType; + notificationTargetTypes: NotificationTargetType[] = Object.values(NotificationTargetType); + notificationTargetTypeTranslationMap = NotificationTargetTypeTranslationMap; + notificationTargetConfigType = NotificationTargetConfigType; + notificationTargetConfigTypes: NotificationTargetConfigType[] = this.allowNotificationTargetConfigTypes(); + notificationTargetConfigTypeInfoMap = NotificationTargetConfigTypeInfoMap; + slackChanelTypes = Object.keys(SlackChanelType) as SlackChanelType[]; + slackChanelTypesTranslateMap = SlackChanelTypesTranslateMap; + + entityType = EntityType; + isAdd = true; + + private readonly destroy$ = new Subject(); + private userFilterFormControls: string[]; + + constructor(protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: RecipientNotificationDialogData, + private fb: FormBuilder, + private notificationService: NotificationService) { + super(store, router, dialogRef); + + if (isDefinedAndNotNull(data.isAdd)) { + this.isAdd = data.isAdd; + } + + this.targetNotificationForm = this.fb.group({ + name: [null, Validators.required], + configuration: this.fb.group({ + type: [NotificationTargetType.PLATFORM_USERS], + usersFilter: this.fb.group({ + type: [NotificationTargetConfigType.ALL_USERS], + filterByTenants: [{value: true, disabled: true}], + tenantsIds: [{value: null, disabled: true}], + tenantProfilesIds: [{value: null, disabled: true}], + customerId: [{value: null, disabled: true}, Validators.required], + usersIds: [{value: null, disabled: true}, Validators.required], + }), + conversationType: [{value: SlackChanelType.PUBLIC_CHANNEL, disabled: true}], + conversation: [{value: '', disabled: true}, Validators.required], + description: [null] + }) + }); + + this.targetNotificationForm.get('configuration.type').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((type: NotificationTargetType) => { + this.targetNotificationForm.get('configuration').disable({emitEvent: false}); + switch (type) { + case NotificationTargetType.PLATFORM_USERS: + this.targetNotificationForm.get('configuration.usersFilter').enable({emitEvent: false}); + this.targetNotificationForm.get('configuration.usersFilter.type').updateValueAndValidity({onlySelf: true}); + break; + case NotificationTargetType.SLACK: + this.targetNotificationForm.get('configuration.conversationType').enable({emitEvent: false}); + this.targetNotificationForm.get('configuration.conversation').enable({emitEvent: false}); + break; + } + this.targetNotificationForm.get('configuration.type').enable({emitEvent: false}); + this.targetNotificationForm.get('configuration.description').enable({emitEvent: false}); + }); + + this.userFilterFormControls = Object.keys((this.targetNotificationForm.get('configuration.usersFilter') as FormGroup).controls) + .filter((controlName) => controlName !== 'type'); + + this.targetNotificationForm.get('configuration.usersFilter.type').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((type: NotificationTargetConfigType) => { + this.userFilterFormControls.forEach( + controlName => this.targetNotificationForm.get(`configuration.usersFilter.${controlName}`).disable({emitEvent: false}) + ); + switch (type) { + case NotificationTargetConfigType.TENANT_ADMINISTRATORS: + if (this.isSysAdmin()) { + this.targetNotificationForm.get('configuration.usersFilter.filterByTenants').enable({onlySelf: true}); + } + break; + case NotificationTargetConfigType.USER_LIST: + this.targetNotificationForm.get('configuration.usersFilter.usersIds').enable({emitEvent: false}); + break; + case NotificationTargetConfigType.CUSTOMER_USERS: + this.targetNotificationForm.get('configuration.usersFilter.customerId').enable({emitEvent: false}); + break; + } + }); + + this.targetNotificationForm.get('configuration.usersFilter.filterByTenants').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((value: boolean) => { + if (value) { + this.targetNotificationForm.get('configuration.usersFilter.tenantsIds').enable({emitEvent: false}); + this.targetNotificationForm.get('configuration.usersFilter.tenantProfilesIds').disable({emitEvent: false}); + } else { + this.targetNotificationForm.get('configuration.usersFilter.tenantsIds').disable({emitEvent: false}); + this.targetNotificationForm.get('configuration.usersFilter.tenantProfilesIds').enable({emitEvent: false}); + } + }); + + if (isDefinedAndNotNull(data.target)) { + this.targetNotificationForm.patchValue(data.target, {emitEvent: false}); + this.targetNotificationForm.get('configuration.type').updateValueAndValidity({onlySelf: true}); + if (this.isSysAdmin() && data.target.configuration.usersFilter.type === NotificationTargetConfigType.TENANT_ADMINISTRATORS) { + this.targetNotificationForm.get('configuration.usersFilter.filterByTenants') + .patchValue(!Array.isArray(this.data.target.configuration.usersFilter.tenantProfilesIds), {onlySelf: true}); + } + } + } + + ngOnDestroy() { + super.ngOnDestroy(); + this.destroy$.next(); + this.destroy$.complete(); + } + + cancel(): void { + this.dialogRef.close(null); + } + + save() { + let formValue = deepTrim(this.targetNotificationForm.value); + if (isDefinedAndNotNull(this.data.target)) { + formValue = Object.assign({}, this.data.target, formValue); + } + if (this.isSysAdmin() && formValue.configuration.type === NotificationTargetType.PLATFORM_USERS && + formValue.configuration.usersFilter.type === NotificationTargetConfigType.TENANT_ADMINISTRATORS) { + delete formValue.configuration.usersFilter.filterByTenants; + } + this.notificationService.saveNotificationTarget(formValue).subscribe( + (target) => this.dialogRef.close(target) + ); + } + + isSysAdmin(): boolean { + return this.authUser.authority === Authority.SYS_ADMIN; + } + + private allowNotificationTargetConfigTypes(): NotificationTargetConfigType[] { + if (this.isSysAdmin()) { + return [ + NotificationTargetConfigType.ALL_USERS, + NotificationTargetConfigType.TENANT_ADMINISTRATORS, + NotificationTargetConfigType.AFFECTED_TENANT_ADMINISTRATORS, + NotificationTargetConfigType.SYSTEM_ADMINISTRATORS + ]; + } + return Object.values(NotificationTargetConfigType).filter(type => + type !== NotificationTargetConfigType.AFFECTED_TENANT_ADMINISTRATORS && type !== NotificationTargetConfigType.SYSTEM_ADMINISTRATORS); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-table-config.resolver.ts new file mode 100644 index 0000000000..05259e1edb --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-table-config.resolver.ts @@ -0,0 +1,126 @@ +/// +/// Copyright © 2016-2023 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, + DateEntityTableColumn, + EntityTableColumn, + EntityTableConfig +} from '@home/models/entity/entities-table-config.models'; +import { EntityType, EntityTypeResource, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { Direction } from '@shared/models/page/sort-order'; +import { NotificationTarget, NotificationTargetTypeTranslationMap } from '@shared/models/notification.models'; +import { NotificationService } from '@core/http/notification.service'; +import { TranslateService } from '@ngx-translate/core'; +import { + RecipientNotificationDialogComponent, + RecipientNotificationDialogData +} from '@home/pages/notification/recipient/recipient-notification-dialog.component'; +import { MatDialog } from '@angular/material/dialog'; +import { EntityAction } from '@home/models/entity/entity-component.models'; +import { RecipientTableHeaderComponent } from '@home/pages/notification/recipient/recipient-table-header.component'; +import { ActivatedRouteSnapshot, Resolve } from '@angular/router'; +import { Injectable } from '@angular/core'; +import { DatePipe } from '@angular/common'; + +@Injectable() +export class RecipientTableConfigResolver implements Resolve> { + + private readonly config: EntityTableConfig = new EntityTableConfig(); + + constructor(private notificationService: NotificationService, + private translate: TranslateService, + private dialog: MatDialog, + private datePipe: DatePipe) { + + this.config.entityType = EntityType.NOTIFICATION_TARGET; + this.config.detailsPanelEnabled = false; + this.config.addEnabled = false; + this.config.rowPointer = true; + + this.config.entityTranslations = entityTypeTranslations.get(EntityType.NOTIFICATION_TARGET); + this.config.entityResources = {} as EntityTypeResource; + + this.config.entitiesFetchFunction = pageLink => this.notificationService.getNotificationTargets(pageLink); + + this.config.deleteEntityTitle = target => this.translate.instant('notification.delete-recipient-title', {recipientName: target.name}); + this.config.deleteEntityContent = () => this.translate.instant('notification.delete-recipient-text'); + this.config.deleteEntitiesTitle = count => this.translate.instant('notification.delete-recipients-title', {count}); + this.config.deleteEntitiesContent = () => this.translate.instant('notification.delete-recipients-text'); + + this.config.deleteEntity = id => this.notificationService.deleteNotificationTarget(id.id); + + this.config.cellActionDescriptors = this.configureCellActions(); + + this.config.headerComponent = RecipientTableHeaderComponent; + this.config.onEntityAction = action => this.onTargetAction(action); + + this.config.defaultSortOrder = {property: 'createdTime', direction: Direction.DESC}; + + this.config.handleRowClick = ($event, target) => { + this.editTarget($event, target); + return true; + }; + + this.config.columns.push( + new DateEntityTableColumn('createdTime', 'common.created-time', this.datePipe, '170px'), + new EntityTableColumn('name', 'notification.recipient-group', '20%'), + new EntityTableColumn('configuration.type', 'notification.type', '20%', + (target) => this.translate.instant(NotificationTargetTypeTranslationMap.get(target.configuration.type)), + () => ({}), false), + new EntityTableColumn('configuration.description', 'notification.description', '60%', + (target) => target.configuration.description || '', + () => ({}), false) + ); + } + + resolve(route: ActivatedRouteSnapshot): EntityTableConfig { + return this.config; + } + + private configureCellActions(): Array> { + return []; + } + + private editTarget($event: Event, target: NotificationTarget, isAdd = false) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(RecipientNotificationDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + isAdd, + target + } + }).afterClosed() + .subscribe((res) => { + if (res) { + this.config.updateData(); + } + }); + } + + private onTargetAction(action: EntityAction): boolean { + switch (action.action) { + case 'add': + this.editTarget(action.event, action.entity, true); + return true; + } + return false; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-table-header.component.html b/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-table-header.component.html new file mode 100644 index 0000000000..e2effa3207 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-table-header.component.html @@ -0,0 +1,24 @@ + +
    + +
    diff --git a/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-table-header.component.scss similarity index 82% rename from ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.scss rename to ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-table-header.component.scss index 1e64b91940..33537b4b23 100644 --- a/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-table-header.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2022 The Thingsboard Authors + * Copyright © 2016-2023 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. @@ -13,8 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -:host ::ng-deep { - .mat-checkbox-label { - white-space: normal; - } +:host{ + width: 10000px; } diff --git a/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-table-header.component.ts b/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-table-header.component.ts new file mode 100644 index 0000000000..f7285d3836 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-table-header.component.ts @@ -0,0 +1,38 @@ +/// +/// Copyright © 2016-2023 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 { NotificationTarget } from '@shared/models/notification.models'; + +@Component({ + selector: 'tb-recipient-table-header', + templateUrl: './recipient-table-header.component.html', + styleUrls: ['recipient-table-header.component.scss'] +}) +export class RecipientTableHeaderComponent 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/modules/home/pages/notification/rule/escalation-form.component.html b/ui-ngx/src/app/modules/home/pages/notification/rule/escalation-form.component.html new file mode 100644 index 0000000000..b9a5ef2119 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/rule/escalation-form.component.html @@ -0,0 +1,48 @@ + +
    +
    +
    notification.first-recipient
    + +
    + notification.after + + notification.notify +
    +
    + + + +
    +
    diff --git a/ui-ngx/src/app/modules/home/pages/notification/rule/escalation-form.component.scss b/ui-ngx/src/app/modules/home/pages/notification/rule/escalation-form.component.scss new file mode 100644 index 0000000000..52ff558723 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/rule/escalation-form.component.scss @@ -0,0 +1,66 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import "../../../../../../scss/constants"; + +:host { + .escalation { + height: 100%; + min-height: 74px; + max-height: 100%; + border-radius: 8px; + background: rgba(0,0,0,0.04); + padding: 0 10px; + + > div:first-child { + padding-right: 10px; + } + } + + .escalation-notify { + text-align: center; + } + + tb-timeinterval { + min-width: 100px; + padding: 0 10px; + } + + @media #{$mat-xs} { + .escalation { + > div:first-child { + padding: 10px 0 0; + } + } + + tb-timeinterval { + padding: 0; + } + } +} + +:host ::ng-deep { + .mat-mdc-form-field { + min-width: 100px; + + .mat-mdc-form-field-infix { + width: 100%; + } + + .mat-mdc-select { + min-width: 110px !important; + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/notification/rule/escalation-form.component.ts b/ui-ngx/src/app/modules/home/pages/notification/rule/escalation-form.component.ts new file mode 100644 index 0000000000..089b234a24 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/rule/escalation-form.component.ts @@ -0,0 +1,173 @@ +/// +/// Copyright © 2016-2023 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 { isDefinedAndNotNull } from '@core/utils'; +import { Subject } from 'rxjs'; +import { + NonConfirmedNotificationEscalation, + NotificationTarget, + NotificationType +} from '@shared/models/notification.models'; +import { EntityType } from '@shared/models/entity-type.models'; +import { takeUntil } from 'rxjs/operators'; +import { + RecipientNotificationDialogComponent, + RecipientNotificationDialogData +} from '@home/pages/notification/recipient/recipient-notification-dialog.component'; +import { MatDialog } from '@angular/material/dialog'; +import { MatButton } from '@angular/material/button'; + +@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; + notificationType = NotificationType; + + private modelValue; + private propagateChange = null; + private propagateChangePending = false; + private destroy$ = new Subject(); + + constructor(private fb: FormBuilder, + private dialog: MatDialog) { + } + + 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(); + } + } + + createTarget($event: Event, button: MatButton) { + if ($event) { + $event.stopPropagation(); + } + button._elementRef.nativeElement.blur(); + this.dialog.open(RecipientNotificationDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: {} + }).afterClosed() + .subscribe((res) => { + if (res) { + let formValue: string[] = this.escalationFormGroup.get('targets').value; + if (!formValue) { + formValue = []; + } + formValue.push(res.id.id); + this.escalationFormGroup.get('targets').patchValue(formValue); + } + }); + } + + 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/rule/escalations.component.html b/ui-ngx/src/app/modules/home/pages/notification/rule/escalations.component.html new file mode 100644 index 0000000000..1d7041ee43 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/rule/escalations.component.html @@ -0,0 +1,48 @@ + +
    +
    + + + + +
    +
    + notification.no-rule +
    +
    + +
    +
    diff --git a/ui-ngx/src/app/modules/home/pages/notification/rule/escalations.component.ts b/ui-ngx/src/app/modules/home/pages/notification/rule/escalations.component.ts new file mode 100644 index 0000000000..fb102b4243 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/rule/escalations.component.ts @@ -0,0 +1,160 @@ +/// +/// Copyright © 2016-2023 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, + 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 { Subject } from 'rxjs'; +import { NonConfirmedNotificationEscalation } from '@shared/models/notification.models'; +import { takeUntil } from 'rxjs/operators'; +import { coerceBoolean } from '@shared/decorators/coerce-boolean'; + +@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, OnInit, OnDestroy { + + escalationsFormGroup: FormGroup; + + @Input() + @coerceBoolean() + required: boolean; + + @Input() + disabled: boolean; + + private destroy$ = new Subject(); + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + 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: {[key: string]: Array} | null): void { + const escalationParse: Array = []; + // eslint-disable-next-line guard-for-in + for (const escalation in escalations) { + escalationParse.push({delayInSec: Number(escalation) * 1000, targets: escalations[escalation]}); + } + if (escalationParse.length === 0) { + this.addEscalation(0); + } else if (escalationParse?.length === this.escalationsFormArray.length) { + this.escalationsFormArray.patchValue(escalationParse, {emitEvent: false}); + } else { + const escalationsControls: Array = []; + escalationParse.forEach(escalation => { + escalationsControls.push(this.fb.control(escalation, [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(delay = 3600000) { + const escalation = { + delayInSec: delay, + targets: null + }; + const escalationArray = this.escalationsFormGroup.get('escalations') as FormArray; + escalationArray.push(this.fb.control(escalation, []), {emitEvent: false}); + } + + public validate(c: AbstractControl): ValidationErrors | null { + return this.escalationsFormGroup.valid ? null : { + escalation: { + valid: false, + }, + }; + } + + private updateModel() { + const escalations = {}; + this.escalationsFormGroup.get('escalations').value.forEach( + escalation => escalations[escalation.delayInSec / 1000] = escalation.targets + ); + this.propagateChange(escalations); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/notification/rule/rule-notification-dialog.component.html b/ui-ngx/src/app/modules/home/pages/notification/rule/rule-notification-dialog.component.html new file mode 100644 index 0000000000..a3e7d55b60 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/rule/rule-notification-dialog.component.html @@ -0,0 +1,491 @@ + + +

    {{ 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.escalation-chain +
    notification.recipients
    + + +
    +
    +
    + +
    +
    +
    +
    notification.stop-escalation-alarm-status-become
    + + alarm.alarm-status-list + + + {{ alarmSearchStatusTranslationMap.get(searchStatus) | translate }} + + + +
    +
    +
    +
    +
    + + {{ 'notification.alarm-trigger-settings' | translate }} +
    +
    +
    + notification.filter + + + + + alarm.alarm-severity-list + + + {{ alarmSeverityTranslationMap.get(alarmSeverity) | translate }} + + + +
    + + notification.notify-on + + + {{ alarmActionTranslationMap.get(alarmAction) | translate }} + + + + {{ 'notification.notify-on-required' | translate }} + + +
    +
    +
    +
    + + notification.description + + +
    +
    +
    + + + {{ 'notification.device-activity-trigger-settings' | translate }} +
    +
    +
    + + {{ 'device.device' | translate }} + {{ 'device-profile.device-profile' | translate }} + +
    + + + + + + + + + + notification.notify-on + + + {{ deviceEventTranslationMap.get(deviceEvent) | translate }} + + + + {{ 'notification.notify-on-required' | translate }} + + +
    +
    +
    +
    + + notification.description + + +
    +
    +
    + + + {{ 'notification.entity-action-trigger-settings' | translate }} +
    +
    + notification.filter + + +
    + notification.status + {{ 'notification.created' | translate }} + {{ 'notification.updated' | translate }} + {{ 'notification.deleted' | translate }} +
    +
    +
    +
    +
    + + notification.description + + +
    +
    +
    + + + {{ 'notification.alarm-comment-trigger-settings' | translate }} +
    +
    +
    + notification.filter + + + + + alarm.alarm-severity-list + + + {{ alarmSeverityTranslationMap.get(alarmSeverity) | translate }} + + + + + alarm.alarm-status-list + + + {{ alarmSearchStatusTranslationMap.get(searchStatus) | translate }} + + + +
    +
    + + {{ 'notification.notify-only-user-comments' | translate }} + + + {{ 'notification.notify-on-comment-update' | translate }} + +
    +
    +
    +
    +
    + + notification.description + + +
    +
    +
    + + {{ 'notification.alarm-assignment-trigger-settings' | translate }} +
    +
    +
    + notification.filter + + + + + alarm.alarm-severity-list + + + {{ alarmSeverityTranslationMap.get(alarmSeverity) | translate }} + + + + + alarm.alarm-status-list + + + {{ alarmSearchStatusTranslationMap.get(searchStatus) | translate }} + + + +
    + + notification.notify-on + + + {{ alarmAssignmentActionTranslationMap.get(alarmAssignmentAction) | translate }} + + + + {{ 'notification.notify-on-required' | translate }} + + +
    +
    +
    +
    + + notification.description + + +
    +
    +
    + + {{ 'notification.rule-engine-events-trigger-settings' | translate }} +
    +
    +
    + notification.rule-engine-filter + + + + rulechain.rulechain-events + + + {{ componentLifecycleEventTranslationMap.get(componentLifecycleEvent) | translate }} + + + + + {{ 'notification.only-rule-chain-lifecycle-failures' | translate }} + +
    +
    + notification.rule-node-filter + + {{ 'notification.track-rule-node-events' | translate }} + +
    + + rulenode.rule-node-events + + + {{ componentLifecycleEventTranslationMap.get(componentLifecycleEvent) | translate }} + + + + + {{ 'notification.only-rule-node-lifecycle-failures' | translate }} + +
    +
    +
    +
    +
    +
    + + notification.description + + +
    +
    +
    + + + {{ 'notification.entities-limit-trigger-settings' | translate }} +
    +
    + notification.filter + + +
    + +
    + + + + + + +
    +
    +
    +
    +
    +
    + + notification.description + + +
    +
    +
    + + + {{ 'notification.api-usage-trigger-settings' | translate }} +
    +
    + + api-usage.api-features + + + {{ apiFeatureTranslationMap.get(apiFeature) | translate }} + + + {{ 'notification.api-feature-hint' | translate }} + + + notification.notify-on + + + {{ apiUsageStateValueTranslationMap.get(apiUsageStateValue) | translate }} + + + + {{ 'notification.notify-on-required' | translate }} + + +
    +
    +
    +
    + + notification.description + + +
    +
    +
    +
    +
    + +
    + + + +
    diff --git a/ui-ngx/src/app/modules/home/pages/notification/rule/rule-notification-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/notification/rule/rule-notification-dialog.component.scss new file mode 100644 index 0000000000..7e582d2b7d --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/rule/rule-notification-dialog.component.scss @@ -0,0 +1,177 @@ +/** + * Copyright © 2016-2023 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"; +@import "../../../../../../scss/constants"; + +:host { + width: 900px; + height: 100%; + max-width: 100%; + max-height: 100vh; + display: flex; + flex-direction: column; + + .tb-hint { + margin-top: -1.25em; + padding: 0; + } + + .fields-group { + padding: 0 16px; + border: 1px groove rgba(0, 0, 0, .25); + border-radius: 4px; + + &.tb-margin { + margin-bottom: 12px; + } + + &.tb-margin-before-field { + margin-bottom: 12px; + } + + &.tb-hierarchy { + padding: 0 0 8px 16px; + } + + legend { + margin-bottom: 8px; + color: rgba(0, 0, 0, .7); + width: fit-content; + } + + .mat-body-2 { + margin-bottom: 8px; + color: rgba(0,0,0,.76); + } + + .clear-button-space { + margin-right: 48px; + } + + .mat-mdc-slide-toggle { + margin-bottom: 16px; + } + } + + .limit-slider-container { + .limit-slider-value { + margin-left: 16px; + min-width: 25px; + max-width: 100px; + } + mat-form-field input[type=number] { + text-align: center; + } + } + + @media #{$mat-gt-sm} { + .history-time-input { + min-width: 364px; + } + .limit-slider-container { + > label { + margin-right: 16px; + width: min-content; + max-width: 40%; + } + } + } + + ::ng-deep { + + .mat-mdc-dialog-content { + display: flex; + flex-direction: column; + height: 100%; + padding: 0 !important; + + .mat-stepper-horizontal { + display: flex; + height: 100%; + overflow: hidden; + + .mat-horizontal-stepper-wrapper { + flex: 1 1 100%; + } + + .mat-horizontal-content-container { + height: 700px; + max-height: 100%; + width: 100%;; + overflow-y: auto; + scrollbar-gutter: stable; + @media #{$mat-gt-sm} { + min-width: 500px; + } + } + } + } + + .mat-button-toggle-group.tb-notification-unread-toggle-group { + &.mat-button-toggle-group-appearance-standard { + border: none; + border-radius: 18px; + margin-bottom: 8px; + + .mat-button-toggle + .mat-button-toggle { + border-left: none; + } + } + + .mat-button-toggle { + background: rgba(0, 0, 0, 0.06); + height: 36px; + align-items: center; + display: flex; + + .mat-button-toggle-ripple { + top: 2px; + left: 2px; + right: 2px; + bottom: 2px; + border-radius: 18px; + } + } + + .mat-button-toggle-button { + color: #959595; + } + + .mat-button-toggle-focus-overlay { + border-radius: 18px; + margin: 2px; + } + + .mat-button-toggle-checked .mat-button-toggle-button { + background-color: $tb-primary-color; + color: #fff; + border-radius: 18px; + margin-left: 2px; + margin-right: 2px; + } + + .mat-button-toggle-appearance-standard .mat-button-toggle-label-content { + line-height: 34px; + 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; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/notification/rule/rule-notification-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/notification/rule/rule-notification-dialog.component.ts new file mode 100644 index 0000000000..1461cf5db3 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/rule/rule-notification-dialog.component.ts @@ -0,0 +1,458 @@ +/// +/// Copyright © 2016-2023 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 { + AlarmAction, + AlarmActionTranslationMap, + AlarmAssignmentAction, + AlarmAssignmentActionTranslationMap, + ComponentLifecycleEvent, + ComponentLifecycleEventTranslationMap, + DeviceEvent, + DeviceEventTranslationMap, + NotificationRule, + NotificationTarget, + TriggerType, + TriggerTypeTranslationMap +} from '@shared/models/notification.models'; +import { Component, 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, MatDialog, 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, Subject } from 'rxjs'; +import { map, 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 { + AlarmSearchStatus, + alarmSearchStatusTranslations, + AlarmSeverity, + alarmSeverityTranslations +} from '@shared/models/alarm.models'; +import { TranslateService } from '@ngx-translate/core'; +import { + RecipientNotificationDialogComponent, + RecipientNotificationDialogData +} from '@home/pages/notification/recipient/recipient-notification-dialog.component'; +import { MatButton } from '@angular/material/button'; +import { AuthState } from '@core/auth/auth.models'; +import { getCurrentAuthState } from '@core/auth/auth.selectors'; +import { AuthUser } from '@shared/models/user.model'; +import { Authority } from '@shared/models/authority.enum'; +import { + ApiFeature, + ApiFeatureTranslationMap, + ApiUsageStateValue, + ApiUsageStateValueTranslationMap +} from '@shared/models/api-usage.models'; + +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; + + stepperOrientation: Observable; + + ruleNotificationForm: FormGroup; + alarmTemplateForm: FormGroup; + deviceInactivityTemplateForm: FormGroup; + entityActionTemplateForm: FormGroup; + alarmCommentTemplateForm: FormGroup; + alarmAssignmentTemplateForm: FormGroup; + ruleEngineEventsTemplateForm: FormGroup; + entitiesLimitTemplateForm: FormGroup; + apiUsageLimitTemplateForm: FormGroup; + + triggerType = TriggerType; + triggerTypes: TriggerType[]; + triggerTypeTranslationMap = TriggerTypeTranslationMap; + + alarmSearchStatuses = [ + AlarmSearchStatus.ACTIVE, + AlarmSearchStatus.CLEARED, + AlarmSearchStatus.ACK, + AlarmSearchStatus.UNACK + ]; + alarmSearchStatusTranslationMap = alarmSearchStatusTranslations; + + alarmSeverityTranslationMap = alarmSeverityTranslations; + alarmSeverities = Object.keys(AlarmSeverity) as Array; + + alarmActions: AlarmAction[] = Object.values(AlarmAction); + alarmActionTranslationMap = AlarmActionTranslationMap; + + alarmAssignmentActions: AlarmAssignmentAction[] = Object.values(AlarmAssignmentAction); + alarmAssignmentActionTranslationMap = AlarmAssignmentActionTranslationMap; + + componentLifecycleEvents: ComponentLifecycleEvent[] = Object.values(ComponentLifecycleEvent); + componentLifecycleEventTranslationMap = ComponentLifecycleEventTranslationMap; + + deviceEvents: DeviceEvent[] = Object.values(DeviceEvent); + deviceEventTranslationMap = DeviceEventTranslationMap; + + apiUsageStateValues: ApiUsageStateValue[] = Object.values(ApiUsageStateValue); + apiUsageStateValueTranslationMap = ApiUsageStateValueTranslationMap; + + apiFeatures: ApiFeature[] = Object.values(ApiFeature); + apiFeatureTranslationMap = ApiFeatureTranslationMap; + + entityType = EntityType; + isAdd = true; + + allowEntityTypeForEntitiesLimit = [ + EntityType.DEVICE, + EntityType.ASSET, + EntityType.CUSTOMER, + EntityType.USER, + EntityType.DASHBOARD, + EntityType.RULE_CHAIN + ]; + + selectedIndex = 0; + + dialogTitle = 'notification.edit-rule'; + + private destroy$ = new Subject(); + + private readonly ruleNotification: NotificationRule; + + private triggerTypeFormsMap: Map; + private authState: AuthState = getCurrentAuthState(this.store); + private authUser: AuthUser = this.authState.authUser; + private _allowEntityTypeForEntityAction: EntityType[]; + + 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, + private notificationService: NotificationService, + private dialog: MatDialog) { + super(store, router, dialogRef); + + this.triggerTypes = this.allowTriggerTypes(); + + 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: [this.isSysAdmin() ? TriggerType.ENTITIES_LIMIT : TriggerType.ALARM, Validators.required], + recipientsConfig: this.fb.group({ + targets: [{value: null, disabled: !this.isSysAdmin()}, Validators.required], + escalationTable: [{value: null, disabled: this.isSysAdmin()}, Validators.required] + }), + triggerConfig: [null], + additionalConfig: this.fb.group({ + description: [''] + }) + }); + + this.ruleNotificationForm.get('triggerType').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(value => { + if (value === TriggerType.ALARM) { + this.ruleNotificationForm.get('recipientsConfig.escalationTable').enable({emitEvent: false}); + this.ruleNotificationForm.get('recipientsConfig.targets').disable({emitEvent: false}); + } else { + this.ruleNotificationForm.get('recipientsConfig.escalationTable').disable({emitEvent: false}); + this.ruleNotificationForm.get('recipientsConfig.targets').enable({emitEvent: false}); + } + }); + + this.ruleNotificationForm.get('recipientsConfig.escalationTable').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(() => { + if (this.countRecipientsChainConfig() > 1) { + this.alarmTemplateForm.get('triggerConfig.clearRule').enable({emitEvent: false}); + } else { + this.alarmTemplateForm.get('triggerConfig.clearRule').disable({emitEvent: false}); + } + }); + + this.alarmTemplateForm = this.fb.group({ + triggerConfig: this.fb.group({ + alarmTypes: [null], + alarmSeverities: [[]], + clearRule: this.fb.group({ + alarmStatuses: [[]] + }), + notifyOn: [[AlarmAction.CREATED], Validators.required] + }) + }); + + this.deviceInactivityTemplateForm = this.fb.group({ + triggerConfig: this.fb.group({ + filterByDevice: [true], + devices: [null], + deviceProfiles: [{value: null, disabled: true}], + notifyOn: [[DeviceEvent.INACTIVE], Validators.required] + }) + }); + + this.deviceInactivityTemplateForm.get('triggerConfig.filterByDevice').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(value => { + if (value) { + this.deviceInactivityTemplateForm.get('triggerConfig.devices').enable({emitEvent: false}); + this.deviceInactivityTemplateForm.get('triggerConfig.deviceProfiles').disable({emitEvent: false}); + } else { + this.deviceInactivityTemplateForm.get('triggerConfig.deviceProfiles').enable({emitEvent: false}); + this.deviceInactivityTemplateForm.get('triggerConfig.devices').disable({emitEvent: false}); + } + }); + + this.entityActionTemplateForm = this.fb.group({ + triggerConfig: this.fb.group({ + entityTypes: [[EntityType.DEVICE], Validators.required], + created: [false], + updated: [false], + deleted: [false] + }) + }); + + this.alarmCommentTemplateForm = this.fb.group({ + triggerConfig: this.fb.group({ + alarmTypes: [null], + alarmSeverities: [[]], + alarmStatuses: [[]], + onlyUserComments: [false], + notifyOnCommentUpdate: [false] + }) + }); + + this.alarmAssignmentTemplateForm = this.fb.group({ + triggerConfig: this.fb.group({ + alarmTypes: [null], + alarmSeverities: [[]], + alarmStatuses: [[]], + notifyOn: [[AlarmAssignmentAction.ASSIGNED], Validators.required] + }) + }); + + this.ruleEngineEventsTemplateForm = this.fb.group({ + triggerConfig: this.fb.group({ + ruleChains: [null], + ruleChainEvents: [null], + onlyRuleChainLifecycleFailures: [false], + trackRuleNodeEvents: [false], + ruleNodeEvents: [null], + onlyRuleNodeLifecycleFailures: [false] + }) + }); + + this.entitiesLimitTemplateForm = this.fb.group({ + triggerConfig: this.fb.group({ + entityTypes: [], + threshold: [.8, [Validators.min(0), Validators.max(1)]] + }) + }); + + this.apiUsageLimitTemplateForm = this.fb.group({ + triggerConfig: this.fb.group({ + apiFeatures: [[]], + notifyOn: [[ApiUsageStateValue.WARNING], Validators.required] + }) + }); + + this.triggerTypeFormsMap = new Map([ + [TriggerType.ALARM, this.alarmTemplateForm], + [TriggerType.ALARM_COMMENT, this.alarmCommentTemplateForm], + [TriggerType.DEVICE_ACTIVITY, this.deviceInactivityTemplateForm], + [TriggerType.ENTITY_ACTION, this.entityActionTemplateForm], + [TriggerType.ALARM_ASSIGNMENT, this.alarmAssignmentTemplateForm], + [TriggerType.RULE_ENGINE_COMPONENT_LIFECYCLE_EVENT, this.ruleEngineEventsTemplateForm], + [TriggerType.ENTITIES_LIMIT, this.entitiesLimitTemplateForm], + [TriggerType.API_USAGE_LIMIT, this.apiUsageLimitTemplateForm] + ]); + + 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')})`; + } else { + this.ruleNotificationForm.get('triggerType').disable({emitEvent: false}); + } + this.ruleNotificationForm.reset({}, {emitEvent: false}); + this.ruleNotificationForm.patchValue(this.ruleNotification, {emitEvent: false}); + this.ruleNotificationForm.get('triggerType').updateValueAndValidity({onlySelf: true}); + const currentForm = this.triggerTypeFormsMap.get(this.ruleNotification.triggerType); + currentForm.patchValue(this.ruleNotification, {emitEvent: false}); + if (this.ruleNotification.triggerType === TriggerType.DEVICE_ACTIVITY) { + this.deviceInactivityTemplateForm.get('triggerConfig.filterByDevice') + .patchValue(!!this.ruleNotification.triggerConfig.devices, {onlySelf: true}); + } + } + } + + ngOnDestroy() { + super.ngOnDestroy(); + this.destroy$.next(); + this.destroy$.complete(); + } + + 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 !== 0) { + return (this.data.isAdd || this.data.isCopy) ? 'action.add' : 'action.save'; + } + 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: TriggerType = this.ruleNotificationForm.get('triggerType').value; + const currentForm = this.triggerTypeFormsMap.get(triggerType); + Object.assign(formValue, currentForm.value); + if (triggerType === TriggerType.DEVICE_ACTIVITY) { + delete formValue.triggerConfig.filterByDevice; + } + formValue.recipientsConfig.triggerType = triggerType; + formValue.triggerConfig.triggerType = triggerType; + if (this.ruleNotification && !this.data.isCopy) { + 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; + } + }); + } + + cancel(): void { + this.dialogRef.close(null); + } + + createTarget($event: Event, button: MatButton) { + if ($event) { + $event.stopPropagation(); + } + button._elementRef.nativeElement.blur(); + this.dialog.open(RecipientNotificationDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: {} + }).afterClosed() + .subscribe((res) => { + if (res) { + let formValue: string[] = this.ruleNotificationForm.get('recipientsConfig.targets').value; + if (!formValue) { + formValue = []; + } + formValue.push(res.id.id); + this.ruleNotificationForm.get('recipientsConfig.targets').patchValue(formValue); + } + }); + } + + countRecipientsChainConfig(): number { + return Object.keys(this.ruleNotificationForm.get('recipientsConfig.escalationTable').value ?? {}).length; + } + + formatLabel(value: number): string { + const formatValue = (value * 100).toFixed(); + return `${formatValue}%`; + } + + private isSysAdmin(): boolean { + return this.authUser.authority === Authority.SYS_ADMIN; + } + + private allowTriggerTypes(): TriggerType[] { + if (this.isSysAdmin()) { + return [TriggerType.ENTITIES_LIMIT, TriggerType.API_USAGE_LIMIT]; + } + return Object.values(TriggerType).filter(type => type !== TriggerType.ENTITIES_LIMIT && type !== TriggerType.API_USAGE_LIMIT); + } + + get allowEntityTypeForEntityAction(): EntityType[] { + if (!this._allowEntityTypeForEntityAction) { + const excludeEntityType: Set = new Set([ + EntityType.API_USAGE_STATE, + EntityType.TENANT_PROFILE, + EntityType.RPC, + EntityType.QUEUE, + EntityType.NOTIFICATION, + EntityType.NOTIFICATION_REQUEST, + EntityType.WIDGET_TYPE + ]); + this._allowEntityTypeForEntityAction = Object.values(EntityType).filter(type => !excludeEntityType.has(type)); + } + return this._allowEntityTypeForEntityAction; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/notification/rule/rule-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/notification/rule/rule-table-config.resolver.ts new file mode 100644 index 0000000000..4d8962150f --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/rule/rule-table-config.resolver.ts @@ -0,0 +1,131 @@ +/// +/// Copyright © 2016-2023 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, + DateEntityTableColumn, + EntityTableColumn, + EntityTableConfig +} from '@home/models/entity/entities-table-config.models'; +import { EntityType, EntityTypeResource, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { Direction } from '@shared/models/page/sort-order'; +import { NotificationRule, TriggerTypeTranslationMap } from '@shared/models/notification.models'; +import { NotificationService } from '@core/http/notification.service'; +import { TranslateService } from '@ngx-translate/core'; +import { MatDialog } from '@angular/material/dialog'; +import { EntityAction } from '@home/models/entity/entity-component.models'; +import { RuleTableHeaderComponent } from '@home/pages/notification/rule/rule-table-header.component'; +import { + RuleNotificationDialogComponent, + RuleNotificationDialogData +} from '@home/pages/notification/rule/rule-notification-dialog.component'; +import { ActivatedRouteSnapshot, Resolve } from '@angular/router'; +import { Injectable } from '@angular/core'; +import { DatePipe } from '@angular/common'; + +@Injectable() +export class RuleTableConfigResolver implements Resolve> { + + private readonly config: EntityTableConfig = new EntityTableConfig(); + + constructor(private notificationService: NotificationService, + private translate: TranslateService, + private dialog: MatDialog, + private datePipe: DatePipe) { + + this.config.entityType = EntityType.NOTIFICATION_RULE; + this.config.detailsPanelEnabled = false; + this.config.addEnabled = false; + this.config.rowPointer = true; + + this.config.entityTranslations = entityTypeTranslations.get(EntityType.NOTIFICATION_RULE); + this.config.entityResources = {} as EntityTypeResource; + + this.config.entitiesFetchFunction = pageLink => this.notificationService.getNotificationRules(pageLink); + + this.config.deleteEntityTitle = rule => this.translate.instant('notification.delete-rule-title', {ruleName: rule.name}); + this.config.deleteEntityContent = () => this.translate.instant('notification.delete-rule-text'); + this.config.deleteEntitiesTitle = count => this.translate.instant('notification.delete-rules-title', {count}); + this.config.deleteEntitiesContent = () => this.translate.instant('notification.delete-rules-text'); + this.config.deleteEntity = id => this.notificationService.deleteNotificationRule(id.id); + + this.config.cellActionDescriptors = this.configureCellActions(); + this.config.headerComponent = RuleTableHeaderComponent; + this.config.onEntityAction = action => this.onTargetAction(action); + + this.config.defaultSortOrder = {property: 'createdTime', direction: Direction.DESC}; + + this.config.handleRowClick = ($event, rule) => { + this.editRule($event, rule); + return true; + }; + + this.config.columns.push( + new DateEntityTableColumn('createdTime', 'common.created-time', this.datePipe, '170px'), + 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) + ); + } + + resolve(route: ActivatedRouteSnapshot): EntityTableConfig { + return this.config; + } + + 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) + }]; + } + + 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.config.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/rule/rule-table-header.component.html b/ui-ngx/src/app/modules/home/pages/notification/rule/rule-table-header.component.html new file mode 100644 index 0000000000..b466fa2d06 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/rule/rule-table-header.component.html @@ -0,0 +1,24 @@ + +
    + +
    diff --git a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.scss b/ui-ngx/src/app/modules/home/pages/notification/rule/rule-table-header.component.scss similarity index 80% rename from ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.scss rename to ui-ngx/src/app/modules/home/pages/notification/rule/rule-table-header.component.scss index 45ce4ac603..33537b4b23 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.scss +++ b/ui-ngx/src/app/modules/home/pages/notification/rule/rule-table-header.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2022 The Thingsboard Authors + * Copyright © 2016-2023 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. @@ -13,9 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -:host ::ng-deep { - .mat-form-field-infix { - width: auto; - min-width: 100px; - } +:host{ + width: 10000px; } diff --git a/ui-ngx/src/app/modules/home/pages/notification/rule/rule-table-header.component.ts b/ui-ngx/src/app/modules/home/pages/notification/rule/rule-table-header.component.ts new file mode 100644 index 0000000000..c6270310d6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/rule/rule-table-header.component.ts @@ -0,0 +1,38 @@ +/// +/// Copyright © 2016-2023 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/modules/home/pages/notification/sent/sent-error-dialog.component.html b/ui-ngx/src/app/modules/home/pages/notification/sent/sent-error-dialog.component.html new file mode 100644 index 0000000000..654c75f515 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/sent/sent-error-dialog.component.html @@ -0,0 +1,40 @@ + + +

    notification.failed-send

    + + +
    +
    +
    +
    {{ notificationDeliveryMethodTranslateMap.get(errorStat.key) | translate }}
    + + + + + +
    + {{ error.key }} + {{ error.value }}
    + +
    +
    diff --git a/ui-ngx/src/app/shared/components/time/datetime.component.scss b/ui-ngx/src/app/modules/home/pages/notification/sent/sent-error-dialog.component.scss similarity index 53% rename from ui-ngx/src/app/shared/components/time/datetime.component.scss rename to ui-ngx/src/app/modules/home/pages/notification/sent/sent-error-dialog.component.scss index 9d54f69b90..95301b4daa 100644 --- a/ui-ngx/src/app/shared/components/time/datetime.component.scss +++ b/ui-ngx/src/app/modules/home/pages/notification/sent/sent-error-dialog.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2022 The Thingsboard Authors + * Copyright © 2016-2023 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. @@ -13,22 +13,36 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -:host ::ng-deep { - .mat-form-field-wrapper { - padding-bottom: 8px; +:host{ + display: block; + width: 600px; + max-width: 100%; + + h6 { + font-weight: 500; + font-size: 16px; + line-height: 20px; + margin-top: 0; } - .mat-form-field-underline { - bottom: 8px; - } - .mat-form-field-infix { - width: auto; - min-width: 100px; - } - mat-form-field { - &.no-label { - .mat-form-field-infix { - border-top-width: 0.2em; - } + + .tb-table-list { + table-layout: fixed; + width: 100%; + border: hidden; + border-collapse: collapse; + + td { + border: 8px solid transparent; + border-collapse: collapse; + font-size: 14px; } + + .tb-row-error { + word-wrap: break-word; + } + } + + .mat-divider { + margin: 24px 0; } } diff --git a/ui-ngx/src/app/modules/home/pages/notification/sent/sent-error-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/notification/sent/sent-error-dialog.component.ts new file mode 100644 index 0000000000..7ce286717a --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/sent/sent-error-dialog.component.ts @@ -0,0 +1,56 @@ +/// +/// Copyright © 2016-2023 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 } 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 { + NotificationDeliveryMethod, + NotificationDeliveryMethodTranslateMap, + NotificationRequest +} from '@shared/models/notification.models'; + +export interface NotificationRequestErrorDialogData { + notificationRequest: NotificationRequest; +} + +@Component({ + selector: 'tb-notification-send-error-dialog', + templateUrl: './sent-error-dialog.component.html', + styleUrls: ['sent-error-dialog.component.scss'] +}) +export class SentErrorDialogComponent extends DialogComponent { + + errorStats: { [key in NotificationDeliveryMethod]: {[errorKey in string]: string}}; + + notificationDeliveryMethodTranslateMap = NotificationDeliveryMethodTranslateMap; + + constructor(protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: NotificationRequestErrorDialogData) { + super(store, router, dialogRef); + + this.errorStats = data.notificationRequest.stats.errors; + } + + cancel(): void { + this.dialogRef.close(null); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.html b/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.html new file mode 100644 index 0000000000..3aa7c98069 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.html @@ -0,0 +1,363 @@ + + +

    {{ dialogTitle | translate }}

    + + +
    + + +
    +
    + + + check + + + {{ 'notification.compose' | translate }} +
    +
    + + {{ 'notification.start-from-scratch' | translate }} + {{ 'notification.use-template' | translate }} + +
    +
    + + + +
    + + +
    +
    + +
    notification.at-least-one-should-be-selected
    +
    +
    + + {{ notificationDeliveryMethodTranslateMap.get(deliveryMethods) | translate }} + +
    +
    +
    +
    +
    + + + + + +
    + + {{ 'notification.scheduler-later' | translate }} + +
    + + + + notification.time + + + + +
    +
    +
    +
    + + {{ 'notification.delivery-method.web' | translate }} +
    + {{ 'notification.input-fields-support-templatization' | translate}} + +
    +
    + + notification.subject + + + {{ 'notification.subject-required' | translate }} + + + + notification.message + + + {{ 'notification.message-required' | translate }} + + +
    +
    + + {{ 'icon.icon' | translate }} + +
    + + + + +
    +
    +
    + + {{ 'notification.action-button' | translate }} + +
    +
    + + notification.button-text + + + {{ 'notification.button-text-required' | translate }} + + +
    +
    + + notification.action-type + + + {{ actionButtonLinkTypeTranslateMap.get(actionButtonLinkType) | translate }} + + + + + notification.link + + + {{ 'notification.link-required' | translate }} + + + + + + + + +
    + + {{ 'notification.set-entity-from-notification' | translate }} + +
    +
    +
    +
    +
    + + {{ 'notification.delivery-method.email' | translate }} + +
    + {{ 'notification.input-fields-support-templatization' | translate}} + +
    +
    + + notification.subject + + + {{ 'notification.subject-required' | translate }} + + + notification.message + +
    +
    +
    + + {{ 'notification.delivery-method.sms' | translate }} +
    + {{ 'notification.input-field-support-templatization' | translate}} + +
    +
    + + notification.message + + + {{ 'notification.message-required' | translate }} + + +
    +
    + + {{ 'notification.delivery-method.slack' | translate }} +
    + {{ 'notification.input-field-support-templatization' | translate}} + +
    +
    + + notification.message + + + {{ 'notification.message-required' | translate }} + + +
    +
    + + {{ 'notification.review' | translate }} + + +
    +
    +
    + +
    notification.delivery-method.web-preview
    +
    +
    + +
    +
    +
    +
    + +
    notification.delivery-method.email-preview
    +
    +
    +
    {{ preview.processedTemplates.EMAIL.subject }}
    + +
    +
    +
    +
    +
    + +
    notification.delivery-method.sms-preview
    +
    +
    + {{ preview.processedTemplates.SMS.body }} +
    +
    +
    +
    + +
    notification.delivery-method.slack-preview
    +
    +
    + {{ preview.processedTemplates.SLACK.body }} +
    +
    +
    +
    + supervisor_account +
    {{ 'notification.recipients-count' | translate : {count: preview.totalRecipientsCount} }}
    +
    +
    +
    + {{ detail.value }}{{ detail.key }} +
    +
    + + + + {{ recipientTitle }} + + +
    +
    +
    +
    +
    + +
    + + + +
    diff --git a/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.scss new file mode 100644 index 0000000000..0f2ca8a228 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.scss @@ -0,0 +1,241 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import "../../../../../../scss/constants"; +@import "../../../../../../theme"; + +:host-context(.tb-fullscreen-dialog .mat-mdc-dialog-container) { + width: 820px; + height: 100%; + max-width: 100%; + max-height: 100vh; + display: flex; + flex-direction: column; + + .tb-title { + font-size: 16px; + line-height: 24px; + } + + .tb-hint { + padding: 0 0 8px; + } + + .tb-hint-available-params { + border-radius: 6px; + background-color: rgba(48, 86, 128, 0.04); + margin-bottom: 8px; + padding: 8px 16px; + + .content { + vertical-align: middle; + } + } + + .delivery-method-container { + padding-bottom: 8px; + + &.even { + padding-right: 8px; + } + + .delivery-method { + padding: 16px 12px; + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 6px; + width: 100%; + height: 100%; + } + } + + .additional-config-group { + padding: 16px 16px 0; + margin-bottom: 12px; + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 6px; + width: 100%; + height: 100%; + + .toggle { + margin-bottom: 16px; + } + } + + .preview-group { + padding: 16px; + margin-bottom: 10px; + border: 1px groove rgba(0, 0, 0, .12); + border-radius: 4px; + + &.notification { + background-color: #F3F6FA; + } + + .group-title { + font-weight: 500; + font-size: 14px; + } + + &> div:not(:last-child) { + margin-bottom: 12px; + } + + .details-recipient { + font-size: 14px; + line-height: 14px; + + .number { + font-weight: 500; + margin-right: 4px; + } + + &:not(:last-of-type) { + margin-bottom: 8px; + } + } + + .divider { + margin-bottom: 8px; + } + + .web-preview { + flex-direction: row; + align-items: center; + justify-content: center; + display: flex; + + tb-notification { + border: 1px groove $tb-primary-color; + border-radius: 4px; + max-width: 400px; + width: 80%; + background-color: #fff; + } + } + + .notification-content { + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 4px; + background: #fff; + padding: 12px 16px; + + .subject { + padding-bottom: 12px; + font-weight: 500; + letter-spacing: 0.25px; + } + + .html-content { + margin-top: 12px; + font-weight: 400; + font-size: 14px; + line-height: 20px; + } + } + } +} + +:host ::ng-deep { + .mat-mdc-dialog-content { + display: flex; + flex-direction: column; + height: 100%; + padding: 0 !important; + + .mat-stepper-horizontal { + display: flex; + height: 100%; + overflow: hidden; + + .mat-horizontal-stepper-wrapper { + flex: 1 1 100%; + } + + .mat-horizontal-content-container { + height: 680px; + max-height: 100%; + width: 100%;; + overflow-y: auto; + scrollbar-gutter: stable; + @media #{$mat-gt-sm} { + min-width: 500px; + } + } + } + } + + .mat-button-toggle-group.tb-notification-use-template-toggle-group { + &.mat-button-toggle-group-appearance-standard { + border: none; + border-radius: 18px; + margin-bottom: 24px; + + .mat-button-toggle + .mat-button-toggle { + border-left: none; + } + } + + .mat-button-toggle { + background: rgba(0, 0, 0, 0.06); + height: 36px; + align-items: center; + display: flex; + + .mat-button-toggle-ripple { + top: 2px; + left: 2px; + right: 2px; + bottom: 2px; + border-radius: 18px; + } + } + + .mat-button-toggle-button { + color: #959595; + } + + .mat-button-toggle-focus-overlay { + border-radius: 18px; + margin: 2px; + } + + .mat-button-toggle-checked .mat-button-toggle-button { + background-color: $tb-primary-color; + color: #fff; + border-radius: 18px; + margin-left: 2px; + margin-right: 2px; + } + + .mat-button-toggle-appearance-standard .mat-button-toggle-label-content { + line-height: 34px; + 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; + } + } + + .preview-group { + .notification-content { + .html-content, + .html-content * { + all: revert; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.componet.ts b/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.componet.ts new file mode 100644 index 0000000000..4efb374ff1 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.componet.ts @@ -0,0 +1,275 @@ +/// +/// Copyright © 2016-2023 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 { + NotificationDeliveryMethod, + NotificationDeliveryMethodTranslateMap, + NotificationRequest, + NotificationRequestPreview, + NotificationTarget, + NotificationType +} from '@shared/models/notification.models'; +import { Component, Inject, OnDestroy, ViewChild } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Router } from '@angular/router'; +import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { NotificationService } from '@core/http/notification.service'; +import { deepTrim, guid, isDefinedAndNotNull } from '@core/utils'; +import { Observable } from 'rxjs'; +import { EntityType } from '@shared/models/entity-type.models'; +import { BreakpointObserver } from '@angular/cdk/layout'; +import { MatStepper } from '@angular/material/stepper'; +import { StepperOrientation, StepperSelectionEvent } from '@angular/cdk/stepper'; +import { MediaBreakpoints } from '@shared/models/constants'; +import { map, takeUntil } from 'rxjs/operators'; +import { getCurrentTime } from '@shared/models/time/time.models'; +import { + RecipientNotificationDialogComponent, + RecipientNotificationDialogData +} from '@home/pages/notification/recipient/recipient-notification-dialog.component'; +import { MatButton } from '@angular/material/button'; +import { TemplateConfiguration } from '@home/pages/notification/template/template-configuration'; + +export interface RequestNotificationDialogData { + request?: NotificationRequest; + isAdd?: boolean; +} + +@Component({ + selector: 'tb-sent-notification-dialog', + templateUrl: './sent-notification-dialog.component.html', + styleUrls: ['./sent-notification-dialog.component.scss'] +}) +export class SentNotificationDialogComponent extends + TemplateConfiguration implements OnDestroy { + + @ViewChild('createNotification', {static: true}) createNotification: MatStepper; + stepperOrientation: Observable; + stepperLabelPosition: Observable<'bottom' | 'end'>; + + isAdd = true; + entityType = EntityType; + notificationType = NotificationType; + + notificationRequestForm: FormGroup; + + notificationDeliveryMethods = Object.keys(NotificationDeliveryMethod) as NotificationDeliveryMethod[]; + notificationDeliveryMethodTranslateMap = NotificationDeliveryMethodTranslateMap; + + selectedIndex = 0; + preview: NotificationRequestPreview = null; + + dialogTitle = 'notification.notify-again'; + + constructor(protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: RequestNotificationDialogData, + private breakpointObserver: BreakpointObserver, + protected fb: FormBuilder, + private notificationService: NotificationService, + private dialog: MatDialog) { + super(store, router, dialogRef, fb); + + this.stepperOrientation = this.breakpointObserver.observe(MediaBreakpoints['gt-sm']) + .pipe(map(({matches}) => matches ? 'horizontal' : 'vertical')); + + this.stepperLabelPosition = this.breakpointObserver.observe(MediaBreakpoints['gt-md']) + .pipe(map(({matches}) => matches ? 'end' : 'bottom')); + + this.notificationRequestForm = this.fb.group({ + useTemplate: [false], + templateId: [{value: null, disabled: true}, Validators.required], + targets: [null, Validators.required], + template: this.templateNotificationForm, + additionalConfig: this.fb.group({ + enabled: [false], + timezone: [{value: '', disabled: true}, Validators.required], + time: [{value: 0, disabled: true}, Validators.required] + }) + }); + + this.notificationRequestForm.get('template.name').setValue(guid()); + + this.notificationRequestForm.get('useTemplate').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(value => { + if (value) { + this.notificationRequestForm.get('templateId').enable({emitEvent: false}); + this.notificationRequestForm.get('template').disable({emitEvent: false}); + } else { + this.notificationRequestForm.get('templateId').disable({emitEvent: false}); + this.notificationRequestForm.get('template').enable({emitEvent: false}); + } + }); + + this.notificationRequestForm.get('additionalConfig.enabled').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(value => { + if (value) { + this.notificationRequestForm.get('additionalConfig.timezone').enable({emitEvent: false}); + this.notificationRequestForm.get('additionalConfig.time').enable({emitEvent: false}); + } else { + this.notificationRequestForm.get('additionalConfig.timezone').disable({emitEvent: false}); + this.notificationRequestForm.get('additionalConfig.time').disable({emitEvent: false}); + } + }); + + if (data.isAdd) { + this.dialogTitle = 'notification.new-notification'; + } + if (data.request) { + this.notificationRequestForm.reset({}, {emitEvent: false}); + this.notificationRequestForm.patchValue(this.data.request, {emitEvent: false}); + this.notificationRequestForm.get('template.name').setValue(guid()); + let useTemplate = true; + if (isDefinedAndNotNull(this.data.request.template)) { + useTemplate = false; + // eslint-disable-next-line guard-for-in + for (const method in this.data.request.template.configuration.deliveryMethodsTemplates) { + this.deliveryMethodFormsMap.get(NotificationDeliveryMethod[method]) + .patchValue(this.data.request.template.configuration.deliveryMethodsTemplates[method]); + } + } + this.notificationRequestForm.get('useTemplate').setValue(useTemplate, {onlySelf : true}); + } + } + + ngOnDestroy() { + super.ngOnDestroy(); + } + + cancel(): void { + this.dialogRef.close(null); + } + + nextStepLabel(): string { + if (this.selectedIndex >= this.maxStepperIndex) { + return 'action.send'; + } + return 'action.next'; + } + + changeStep($event: StepperSelectionEvent) { + this.selectedIndex = $event.selectedIndex; + if (this.selectedIndex === this.maxStepperIndex) { + this.getPreview(); + } + } + + backStep() { + this.createNotification.previous(); + } + + nextStep() { + if (this.selectedIndex >= this.maxStepperIndex) { + this.add(); + } else { + this.createNotification.next(); + } + } + + private get maxStepperIndex(): number { + return this.createNotification?._steps?.length - 1; + } + + private add(): void { + if (this.allValid()) { + this.notificationService.createNotificationRequest(this.notificationFormValue).subscribe( + (notification) => this.dialogRef.close(notification) + ); + } + } + + private getPreview() { + if (this.allValid()) { + this.preview = null; + this.notificationService.getNotificationRequestPreview(this.notificationFormValue).pipe( + map(data => { + if (data.processedTemplates.WEB?.enabled) { + (data.processedTemplates.WEB as any).text = data.processedTemplates.WEB.body; + } + return data; + }) + ).subscribe( + (preview) => this.preview = preview + ); + } + } + + private get notificationFormValue(): NotificationRequest { + const formValue = deepTrim(this.notificationRequestForm.value); + if (!formValue.useTemplate) { + formValue.template = super.getNotificationTemplateValue(); + } + delete formValue.useTemplate; + let delay = 0; + if (formValue.additionalConfig.enabled) { + delay = (this.notificationRequestForm.value.additionalConfig.time.valueOf() - this.minDate().valueOf()) / 1000; + } + formValue.additionalConfig = { + sendingDelayInSec: delay > 0 ? delay : 0 + }; + return formValue; + } + + private allValid(): boolean { + return !this.createNotification.steps.find((item, index) => { + if (item.stepControl?.invalid) { + item.interacted = true; + this.createNotification.selectedIndex = index; + return true; + } else { + return false; + } + }); + } + + minDate(): Date { + return new Date(getCurrentTime(this.notificationRequestForm.get('additionalConfig.timezone').value).format('lll')); + } + + maxDate(): Date { + const date = this.minDate(); + date.setDate(date.getDate() + 7); + return date; + } + + createTarget($event: Event, button: MatButton) { + if ($event) { + $event.stopPropagation(); + } + button._elementRef.nativeElement.blur(); + this.dialog.open(RecipientNotificationDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: {} + }).afterClosed() + .subscribe((res) => { + if (res) { + let formValue: string[] = this.notificationRequestForm.get('targets').value; + if (!formValue) { + formValue = []; + } + formValue.push(res.id.id); + this.notificationRequestForm.get('targets').patchValue(formValue); + } + }); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/notification/sent/sent-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/notification/sent/sent-table-config.resolver.ts new file mode 100644 index 0000000000..290774b6e9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/sent/sent-table-config.resolver.ts @@ -0,0 +1,208 @@ +/// +/// Copyright © 2016-2023 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, + DateEntityTableColumn, + EntityTableColumn, + EntityTableConfig +} from '@home/models/entity/entities-table-config.models'; +import { + NotificationDeliveryMethodTranslateMap, + NotificationRequest, + NotificationRequestInfo, + NotificationRequestStats, + NotificationRequestStatus, + NotificationRequestStatusTranslateMap, + NotificationTemplate +} from '@shared/models/notification.models'; +import { NotificationService } from '@core/http/notification.service'; +import { TranslateService } from '@ngx-translate/core'; +import { MatDialog } from '@angular/material/dialog'; +import { EntityType, EntityTypeResource, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { Direction } from '@shared/models/page/sort-order'; +import { DatePipe } from '@angular/common'; +import { EntityAction } from '@home/models/entity/entity-component.models'; +import { + RequestNotificationDialogData, + SentNotificationDialogComponent +} from '@home/pages/notification/sent/sent-notification-dialog.componet'; +import { PageLink } from '@shared/models/page/page-link'; +import { + NotificationRequestErrorDialogData, + SentErrorDialogComponent +} from '@home/pages/notification/sent/sent-error-dialog.component'; +import { ActivatedRouteSnapshot, Resolve } from '@angular/router'; +import { Injectable } from '@angular/core'; + +@Injectable() +export class SentTableConfigResolver implements Resolve> { + + private readonly config: EntityTableConfig = + new EntityTableConfig(); + + constructor(private notificationService: NotificationService, + private translate: TranslateService, + private dialog: MatDialog, + private datePipe: DatePipe) { + + this.config.entityType = EntityType.NOTIFICATION_REQUEST; + this.config.detailsPanelEnabled = false; + this.config.addEnabled = false; + this.config.searchEnabled = false; + this.config.entityTranslations = entityTypeTranslations.get(EntityType.NOTIFICATION_REQUEST); + this.config.entityResources = {} as EntityTypeResource; + + this.config.deleteEntityTitle = () => this.translate.instant('notification.delete-request-title'); + this.config.deleteEntityContent = () => this.translate.instant('notification.delete-request-text'); + this.config.deleteEntitiesTitle = count => this.translate.instant('notification.delete-requests-title', {count}); + this.config.deleteEntitiesContent = () => this.translate.instant('notification.delete-requests-text'); + + this.config.deleteEntity = id => this.notificationService.deleteNotificationRequest(id.id); + this.config.entitiesFetchFunction = pageLink => this.notificationService.getNotificationRequests(pageLink); + + this.config.cellActionDescriptors = this.configureCellActions(); + + this.config.onEntityAction = action => this.onRequestAction(action); + + this.config.handleRowClick = (event, entity) => { + if ((event.target as HTMLElement).getElementsByClassName('stats').length || (event.target as HTMLElement).className === 'stats') { + this.openStatsErrorDialog(event, entity); + } + return true; + }; + + this.config.defaultSortOrder = {property: 'createdTime', direction: Direction.DESC}; + + this.config.columns.push( + new DateEntityTableColumn('createdTime', 'common.created-time', this.datePipe, '170px'), + new EntityTableColumn('status', 'notification.status', '15%', + request => `${this.requestStatus(request.status)}${this.requestStats(request.stats)}`, + request => this.requestStatusStyle(request.status)), + new EntityTableColumn('deliveryMethods', 'notification.delivery-method.delivery-method', '15%', + (request) => request.deliveryMethods + .map((deliveryMethod) => this.translate.instant(NotificationDeliveryMethodTranslateMap.get(deliveryMethod))).join(', '), + () => ({}), false), + new EntityTableColumn('templateName', 'notification.template', '70%') + ); + } + + resolve(route: ActivatedRouteSnapshot): EntityTableConfig { + return this.config; + } + + private configureCellActions(): Array> { + return [{ + name: this.translate.instant('notification.notify-again'), + mdiIcon: 'mdi:repeat-variant', + isEnabled: (request) => request.status !== NotificationRequestStatus.SCHEDULED, + onAction: ($event, entity) => this.createRequest($event, entity) + }]; + } + + private createRequest($event: Event, request: NotificationRequest, isAdd = false, updateData = true) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(SentNotificationDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + isAdd, + request + } + }).afterClosed().subscribe((res) => { + if (res && updateData) { + this.config.updateData(); + } + }); + } + + private onRequestAction(action: EntityAction): boolean { + switch (action.action) { + case 'add': + this.createRequest(action.event, action.entity, true); + return true; + case 'add-without-update': + this.createRequest(action.event, action.entity, true, false); + return true; + } + return false; + } + + private requestStatus(status: NotificationRequestStatus): string { + const translateKey = NotificationRequestStatusTranslateMap.get(status); + let backgroundColor = 'rgba(25, 128, 56, 0.08)'; + switch (status) { + case NotificationRequestStatus.SCHEDULED: + backgroundColor = 'rgba(48, 86, 128, 0.08)'; + break; + case NotificationRequestStatus.PROCESSING: + backgroundColor = 'rgba(212, 125, 24, 0.08)'; + break; + } + return `
    + ${this.translate.instant(translateKey)} +
    `; + } + + private requestStats(stats: NotificationRequestStats): string { + if (!stats?.errors) { + return ''; + } + let countError = 0; + Object.keys(stats.errors).forEach(method => countError += Object.keys(stats.errors[method]).length); + if (countError === 0) { + return ''; + } + return `
    + ${this.translate.instant('notification.fails', {count: countError})} > +
    `; + } + + private requestStatusStyle(status: NotificationRequestStatus): object { + const styleObj = { + fontSize: '14px', + color: '#198038' + }; + switch (status) { + case NotificationRequestStatus.SCHEDULED: + styleObj.color = '#305680'; + break; + case NotificationRequestStatus.PROCESSING: + styleObj.color = '#D47D18'; + break; + } + return styleObj; + } + + private openStatsErrorDialog($event: Event, notificationRequest: NotificationRequest) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(SentErrorDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + notificationRequest + } + }).afterClosed().subscribe(() => {}); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/notification/template/template-configuration.ts b/ui-ngx/src/app/modules/home/pages/notification/template/template-configuration.ts new file mode 100644 index 0000000000..2eaf1807a0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/template/template-configuration.ts @@ -0,0 +1,201 @@ +/// +/// Copyright © 2016-2023 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 { FormBuilder, FormGroup, ValidationErrors, Validators } from '@angular/forms'; +import { + ActionButtonLinkType, + ActionButtonLinkTypeTranslateMap, + NotificationDeliveryMethod, + NotificationDeliveryMethodTranslateMap, + NotificationTemplate, + NotificationTemplateTypeTranslateMap, + NotificationType +} from '@shared/models/notification.models'; +import { takeUntil } from 'rxjs/operators'; +import { Subject } from 'rxjs'; +import { Directive, OnDestroy } 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 { MatDialogRef } from '@angular/material/dialog'; +import { deepClone, deepTrim } from '@core/utils'; + +@Directive() +// tslint:disable-next-line:directive-class-suffix +export abstract class TemplateConfiguration extends DialogComponent implements OnDestroy{ + + templateNotificationForm: FormGroup; + webTemplateForm: FormGroup; + emailTemplateForm: FormGroup; + smsTemplateForm: FormGroup; + slackTemplateForm: FormGroup; + + notificationDeliveryMethods = Object.keys(NotificationDeliveryMethod) as NotificationDeliveryMethod[]; + notificationDeliveryMethodTranslateMap = NotificationDeliveryMethodTranslateMap; + notificationTemplateTypeTranslateMap = NotificationTemplateTypeTranslateMap; + + actionButtonLinkType = ActionButtonLinkType; + actionButtonLinkTypes = Object.keys(ActionButtonLinkType) as ActionButtonLinkType[]; + actionButtonLinkTypeTranslateMap = ActionButtonLinkTypeTranslateMap; + + tinyMceOptions: Record = { + base_url: '/assets/tinymce', + suffix: '.min', + plugins: ['link table image imagetools code fullscreen'], + menubar: 'edit insert tools view format table', + toolbar: 'fontselect fontsizeselect | formatselect | bold italic strikethrough forecolor backcolor ' + + '| link | table | image | alignleft aligncenter alignright alignjustify ' + + '| numlist bullist outdent indent | removeformat | code | fullscreen', + height: 400, + autofocus: false, + branding: false + }; + + protected readonly destroy$ = new Subject(); + + protected deliveryMethodFormsMap: Map; + + protected constructor(protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + protected fb: FormBuilder) { + super(store, router, dialogRef); + + this.templateNotificationForm = this.fb.group({ + name: ['', Validators.required], + notificationType: [NotificationType.GENERAL], + configuration: this.fb.group({ + deliveryMethodsTemplates: this.fb.group({}, {validators: this.atLeastOne()}) + }) + }); + + this.notificationDeliveryMethods.forEach(method => { + (this.templateNotificationForm.get('configuration.deliveryMethodsTemplates') as FormGroup) + .addControl(method, this.fb.group({enabled: method === NotificationDeliveryMethod.WEB}), {emitEvent: false}); + }); + + this.webTemplateForm = this.fb.group({ + subject: ['', Validators.required], + body: ['', Validators.required], + additionalConfig: this.fb.group({ + icon: this.fb.group({ + enabled: [false], + icon: [{value: 'notifications', disabled: true}, Validators.required], + color: ['#757575'] + }), + actionButtonConfig: this.fb.group({ + enabled: [false], + text: [{value: '', disabled: true}, Validators.required], + linkType: [ActionButtonLinkType.LINK], + link: [{value: '', disabled: true}, Validators.required], + dashboardId: [{value: null, disabled: true}, Validators.required], + dashboardState: [{value: null, disabled: true}], + setEntityIdInState: [{value: true, disabled: true}], + }), + }) + }); + + this.webTemplateForm.get('additionalConfig.icon.enabled').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((value) => { + if (value) { + this.webTemplateForm.get('additionalConfig.icon.icon').enable({emitEvent: false}); + } else { + this.webTemplateForm.get('additionalConfig.icon.icon').disable({emitEvent: false}); + } + }); + + this.webTemplateForm.get('additionalConfig.actionButtonConfig.enabled').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((value) => { + if (value) { + this.webTemplateForm.get('additionalConfig.actionButtonConfig').enable({emitEvent: false}); + this.webTemplateForm.get('additionalConfig.actionButtonConfig.linkType').updateValueAndValidity({onlySelf: true}); + } else { + this.webTemplateForm.get('additionalConfig.actionButtonConfig').disable({emitEvent: false}); + this.webTemplateForm.get('additionalConfig.actionButtonConfig.enabled').enable({emitEvent: false}); + } + }); + + this.webTemplateForm.get('additionalConfig.actionButtonConfig.linkType').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((value) => { + const isEnabled = this.webTemplateForm.get('additionalConfig.actionButtonConfig.enabled').value; + if (isEnabled) { + if (value === ActionButtonLinkType.LINK) { + this.webTemplateForm.get('additionalConfig.actionButtonConfig.link').enable({emitEvent: false}); + this.webTemplateForm.get('additionalConfig.actionButtonConfig.dashboardId').disable({emitEvent: false}); + this.webTemplateForm.get('additionalConfig.actionButtonConfig.dashboardState').disable({emitEvent: false}); + this.webTemplateForm.get('additionalConfig.actionButtonConfig.setEntityIdInState').disable({emitEvent: false}); + } else { + this.webTemplateForm.get('additionalConfig.actionButtonConfig.link').disable({emitEvent: false}); + this.webTemplateForm.get('additionalConfig.actionButtonConfig.dashboardId').enable({emitEvent: false}); + this.webTemplateForm.get('additionalConfig.actionButtonConfig.dashboardState').enable({emitEvent: false}); + this.webTemplateForm.get('additionalConfig.actionButtonConfig.setEntityIdInState').enable({emitEvent: false}); + } + } + }); + + this.emailTemplateForm = this.fb.group({ + subject: ['', Validators.required], + body: ['', Validators.required] + }); + + this.smsTemplateForm = this.fb.group({ + body: ['', Validators.required] + }); + + this.slackTemplateForm = this.fb.group({ + body: ['', Validators.required] + }); + + this.deliveryMethodFormsMap = new Map([ + [NotificationDeliveryMethod.WEB, this.webTemplateForm], + [NotificationDeliveryMethod.EMAIL, this.emailTemplateForm], + [NotificationDeliveryMethod.SMS, this.smsTemplateForm], + [NotificationDeliveryMethod.SLACK, this.slackTemplateForm] + ]); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + atLeastOne() { + return (group: FormGroup): ValidationErrors | null => { + let hasAtLeastOne = true; + if (group?.controls) { + const controlsFormValue: FormGroup[] = Object.entries(group.controls).map(method => method[1]) as any; + hasAtLeastOne = controlsFormValue.some(value => value.controls.enabled.value); + } + return hasAtLeastOne ? null : {atLeastOne: true}; + }; + } + + protected getNotificationTemplateValue(): NotificationTemplate { + const template: NotificationTemplate = deepClone(this.templateNotificationForm.value); + this.notificationDeliveryMethods.forEach(method => { + if (template.configuration.deliveryMethodsTemplates[method].enabled) { + Object.assign(template.configuration.deliveryMethodsTemplates[method], this.deliveryMethodFormsMap.get(method).value, {method}); + } else { + delete template.configuration.deliveryMethodsTemplates[method]; + } + }); + return deepTrim(template); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/notification/template/template-notification-dialog.component.html b/ui-ngx/src/app/modules/home/pages/notification/template/template-notification-dialog.component.html new file mode 100644 index 0000000000..a7576f7265 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/template/template-notification-dialog.component.html @@ -0,0 +1,256 @@ + + +

    {{ dialogTitle | translate }}

    + + +
    + + +
    +
    + + + check + + + {{ 'notification.basic-settings' | translate }} +
    + + notification.name + + + {{ 'notification.name-required' | translate }} + + + + notification.type + + + {{ notificationTemplateTypeTranslateMap.get(notificationType).name | translate }} + + + +
    + +
    notification.at-least-one-should-be-selected
    +
    +
    + + {{ notificationDeliveryMethodTranslateMap.get(deliveryMethods) | translate }} + +
    +
    +
    +
    +
    + + {{ 'notification.delivery-method.web' | translate }} +
    + {{ 'notification.input-fields-support-templatization' | translate}} + +
    +
    + + notification.subject + + + {{ 'notification.subject-required' | translate }} + + + + notification.message + + + {{ 'notification.message-required' | translate }} + + +
    +
    + + {{ 'icon.icon' | translate }} + +
    + + + + +
    +
    +
    + + {{ 'notification.action-button' | translate }} + +
    +
    + + notification.button-text + + + {{ 'notification.button-text-required' | translate }} + + +
    +
    + + notification.action-type + + + {{ actionButtonLinkTypeTranslateMap.get(actionButtonLinkType) | translate }} + + + + + notification.link + + + {{ 'notification.link-required' | translate }} + + + + + + + + +
    + + {{ 'notification.set-entity-from-notification' | translate }} + +
    +
    +
    +
    +
    + + {{ 'notification.delivery-method.email' | translate }} + +
    + {{ 'notification.input-fields-support-templatization' | translate}} + +
    +
    + + notification.subject + + + {{ 'notification.subject-required' | translate }} + + + notification.message + +
    +
    +
    + + {{ 'notification.delivery-method.sms' | translate }} +
    + {{ 'notification.input-field-support-templatization' | translate}} + +
    +
    + + notification.message + + + {{ 'notification.message-required' | translate }} + + +
    +
    + + {{ 'notification.delivery-method.slack' | translate }} +
    + {{ 'notification.input-field-support-templatization' | translate}} + +
    +
    + + notification.message + + + {{ 'notification.message-required' | translate }} + + +
    +
    +
    +
    + +
    + + + +
    diff --git a/ui-ngx/src/app/modules/home/pages/notification/template/template-notification-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/notification/template/template-notification-dialog.component.scss new file mode 100644 index 0000000000..1fc1b427d0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/template/template-notification-dialog.component.scss @@ -0,0 +1,105 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import "../../../../../../scss/constants"; +@import "../../../../../../theme"; + +:host-context(.tb-fullscreen-dialog .mat-mdc-dialog-container) { + width: 800px; + height: 100%; + max-width: 100%; + max-height: 100vh; + display: flex; + flex-direction: column; + + .tb-title { + font-size: 16px; + line-height: 24px; + } + + .tb-hint { + padding: 0 0 8px; + } + + .tb-hint-available-params { + border-radius: 6px; + background-color: rgba(48, 86, 128, 0.04); + margin-bottom: 8px; + padding: 8px 16px; + + .content { + vertical-align: middle; + } + } + + .delivery-method-container { + padding-bottom: 8px; + + &.even { + padding-right: 8px; + } + + .delivery-method { + padding: 16px 12px; + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 6px; + width: 100%; + height: 100%; + } + } + + .additional-config-group { + padding: 16px 16px 4px; + margin-bottom: 12px; + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 6px; + width: 100%; + height: 100%; + + .toggle { + margin-bottom: 12px; + } + } +} + +:host ::ng-deep { + .mat-mdc-dialog-content { + display: flex; + flex-direction: column; + height: 100%; + padding: 0 !important; + + .mat-stepper-horizontal { + display: flex; + height: 100%; + overflow: hidden; + + .mat-horizontal-stepper-wrapper { + flex: 1 1 100%; + } + + .mat-horizontal-content-container { + height: 550px; + max-height: 100%; + width: 100%;; + overflow-y: auto; + scrollbar-gutter: stable; + @media #{$mat-gt-sm} { + min-width: 500px; + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/notification/template/template-notification-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/notification/template/template-notification-dialog.component.ts new file mode 100644 index 0000000000..a92afa7c8e --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/template/template-notification-dialog.component.ts @@ -0,0 +1,186 @@ +/// +/// Copyright © 2016-2023 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 { NotificationDeliveryMethod, NotificationTemplate, NotificationType } from '@shared/models/notification.models'; +import { Component, Inject, OnDestroy, ViewChild } from '@angular/core'; +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 } from '@angular/forms'; +import { NotificationService } from '@core/http/notification.service'; +import { deepClone, isDefinedAndNotNull } from '@core/utils'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { StepperOrientation, StepperSelectionEvent } from '@angular/cdk/stepper'; +import { MatStepper } from '@angular/material/stepper'; +import { BreakpointObserver } from '@angular/cdk/layout'; +import { MediaBreakpoints } from '@shared/models/constants'; +import { TranslateService } from '@ngx-translate/core'; +import { TemplateConfiguration } from '@home/pages/notification/template/template-configuration'; +import { AuthState } from '@core/auth/auth.models'; +import { getCurrentAuthState } from '@core/auth/auth.selectors'; +import { AuthUser } from '@shared/models/user.model'; +import { Authority } from '@shared/models/authority.enum'; + +export interface TemplateNotificationDialogData { + template?: NotificationTemplate; + predefinedType?: NotificationType; + isAdd?: boolean; + isCopy?: boolean; +} + +@Component({ + selector: 'tb-template-notification-dialog', + templateUrl: './template-notification-dialog.component.html', + styleUrls: ['./template-notification-dialog.component.scss'] +}) +export class TemplateNotificationDialogComponent + extends TemplateConfiguration implements OnDestroy { + + @ViewChild('notificationTemplateStepper', {static: true}) notificationTemplateStepper: MatStepper; + + stepperOrientation: Observable; + stepperLabelPosition: Observable<'bottom' | 'end'>; + + dialogTitle = 'notification.edit-notification-template'; + + notificationTypes: NotificationType[]; + + selectedIndex = 0; + hideSelectType = false; + + private readonly templateNotification: NotificationTemplate; + private authState: AuthState = getCurrentAuthState(this.store); + private authUser: AuthUser = this.authState.authUser; + + constructor(protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: TemplateNotificationDialogData, + private breakpointObserver: BreakpointObserver, + protected fb: FormBuilder, + private notificationService: NotificationService, + private translate: TranslateService) { + super(store, router, dialogRef, fb); + + this.notificationTypes = this.allowNotificationType(); + + this.stepperOrientation = this.breakpointObserver.observe(MediaBreakpoints['gt-sm']) + .pipe(map(({matches}) => matches ? 'horizontal' : 'vertical')); + + this.stepperLabelPosition = this.breakpointObserver.observe(MediaBreakpoints['gt-md']) + .pipe(map(({matches}) => matches ? 'end' : 'bottom')); + + if (isDefinedAndNotNull(this.data?.predefinedType)) { + this.hideSelectType = true; + this.templateNotificationForm.get('notificationType').setValue(this.data.predefinedType, {emitEvent: false}); + } + + if (data.isAdd || data.isCopy) { + this.dialogTitle = 'notification.add-notification-template'; + } + this.templateNotification = deepClone(this.data.template); + + if (this.templateNotification) { + if (this.data.isCopy) { + this.templateNotification.name += ` (${this.translate.instant('action.copy')})`; + } else { + this.templateNotificationForm.get('notificationType').disable({emitEvent: false}); + } + this.templateNotificationForm.reset({}, {emitEvent: false}); + this.templateNotificationForm.patchValue(this.templateNotification, {emitEvent: false}); + // eslint-disable-next-line guard-for-in + for (const method in this.templateNotification.configuration.deliveryMethodsTemplates) { + this.deliveryMethodFormsMap.get(NotificationDeliveryMethod[method]) + .patchValue(this.templateNotification.configuration.deliveryMethodsTemplates[method]); + } + } + } + + ngOnDestroy() { + super.ngOnDestroy(); + this.destroy$.next(); + this.destroy$.complete(); + } + + cancel(): void { + this.dialogRef.close(null); + } + + changeStep($event: StepperSelectionEvent) { + this.selectedIndex = $event.selectedIndex; + } + + backStep() { + this.notificationTemplateStepper.previous(); + } + + nextStep() { + if (this.selectedIndex >= this.maxStepperIndex) { + this.add(); + } else { + this.notificationTemplateStepper.next(); + } + } + + nextStepLabel(): string { + if (this.selectedIndex >= this.maxStepperIndex) { + return (this.data.isAdd || this.data.isCopy) ? 'action.add' : 'action.save'; + } + return 'action.next'; + } + + private get maxStepperIndex(): number { + return this.notificationTemplateStepper?._steps?.length - 1; + } + + private add(): void { + if (this.allValid()) { + let template = this.getNotificationTemplateValue(); + if (this.templateNotification && !this.data.isCopy) { + template = {...this.templateNotification, ...template}; + } + this.notificationService.saveNotificationTemplate(template).subscribe( + (target) => this.dialogRef.close(target) + ); + } + } + + private allValid(): boolean { + return !this.notificationTemplateStepper.steps.find((item, index) => { + if (item.stepControl.invalid) { + item.interacted = true; + this.notificationTemplateStepper.selectedIndex = index; + return true; + } else { + return false; + } + }); + } + + private isSysAdmin(): boolean { + return this.authUser.authority === Authority.SYS_ADMIN; + } + + private allowNotificationType(): NotificationType[] { + if (this.isSysAdmin()) { + return [NotificationType.GENERAL, NotificationType.ENTITIES_LIMIT, NotificationType.API_USAGE_LIMIT]; + } + return Object.values(NotificationType) + .filter(type => type !== NotificationType.ENTITIES_LIMIT && type !== NotificationType.API_USAGE_LIMIT); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/notification/template/template-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/notification/template/template-table-config.resolver.ts new file mode 100644 index 0000000000..34f0b42f12 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/template/template-table-config.resolver.ts @@ -0,0 +1,130 @@ +/// +/// Copyright © 2016-2023 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, + DateEntityTableColumn, + EntityTableColumn, + EntityTableConfig +} from '@home/models/entity/entities-table-config.models'; +import { EntityType, EntityTypeResource, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { Direction } from '@shared/models/page/sort-order'; +import { NotificationTemplate, NotificationTemplateTypeTranslateMap } from '@shared/models/notification.models'; +import { NotificationService } from '@core/http/notification.service'; +import { EntityAction } from '@home/models/entity/entity-component.models'; +import { MatDialog } from '@angular/material/dialog'; +import { + TemplateNotificationDialogComponent, + TemplateNotificationDialogData +} from '@home/pages/notification/template/template-notification-dialog.component'; +import { TranslateService } from '@ngx-translate/core'; +import { TemplateTableHeaderComponent } from '@home/pages/notification/template/template-table-header.component'; +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve } from '@angular/router'; +import { DatePipe } from '@angular/common'; + +@Injectable() +export class TemplateTableConfigResolver implements Resolve> { + + private readonly config: EntityTableConfig = new EntityTableConfig(); + + constructor(private notificationService: NotificationService, + private translate: TranslateService, + private dialog: MatDialog, + private datePipe: DatePipe) { + + this.config.entityType = EntityType.NOTIFICATION_TEMPLATE; + this.config.detailsPanelEnabled = false; + this.config.addEnabled = false; + this.config.rowPointer = true; + + this.config.entityTranslations = entityTypeTranslations.get(EntityType.NOTIFICATION_TEMPLATE); + this.config.entityResources = {} as EntityTypeResource; + + this.config.entitiesFetchFunction = pageLink => this.notificationService.getNotificationTemplates(pageLink); + + this.config.deleteEntityTitle = template => this.translate.instant('notification.delete-template-title', {templateName: template.name}); + this.config.deleteEntityContent = () => this.translate.instant('notification.delete-template-text'); + this.config.deleteEntitiesTitle = count => this.translate.instant('notification.delete-templates-title', {count}); + this.config.deleteEntitiesContent = () => this.translate.instant('notification.delete-templates-text'); + this.config.deleteEntity = id => this.notificationService.deleteNotificationTemplate(id.id); + + this.config.cellActionDescriptors = this.configureCellActions(); + + this.config.headerComponent = TemplateTableHeaderComponent; + this.config.onEntityAction = action => this.onTemplateAction(action); + + this.config.defaultSortOrder = {property: 'createdTime', direction: Direction.DESC}; + + this.config.handleRowClick = ($event, template) => { + this.editTemplate($event, template); + return true; + }; + + this.config.columns.push( + new DateEntityTableColumn('createdTime', 'common.created-time', this.datePipe, '170px'), + new EntityTableColumn('notificationType', 'notification.type', '20%', + (template) => this.translate.instant(NotificationTemplateTypeTranslateMap.get(template.notificationType).name)), + new EntityTableColumn('name', 'notification.template', '80%') + ); + } + + resolve(route: ActivatedRouteSnapshot): EntityTableConfig { + return this.config; + } + + private configureCellActions(): Array> { + return [ + { + name: this.translate.instant('notification.copy-template'), + icon: 'content_copy', + isEnabled: () => true, + onAction: ($event, entity) => this.editTemplate($event, entity, false, true) + } + ]; + } + + private editTemplate($event: Event, template: NotificationTemplate, isAdd = false, isCopy = false) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(TemplateNotificationDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + isAdd, + isCopy, + template + } + }).afterClosed() + .subscribe((res) => { + if (res) { + this.config.updateData(); + } + }); + } + + private onTemplateAction(action: EntityAction): boolean { + switch (action.action) { + case 'add': + this.editTemplate(action.event, action.entity, true); + return true; + } + return false; + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/notification/template/template-table-header.component.html b/ui-ngx/src/app/modules/home/pages/notification/template/template-table-header.component.html new file mode 100644 index 0000000000..39fe86994e --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/template/template-table-header.component.html @@ -0,0 +1,24 @@ + +
    + +
    diff --git a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-duration-predicate-value.component.scss b/ui-ngx/src/app/modules/home/pages/notification/template/template-table-header.component.scss similarity index 79% rename from ui-ngx/src/app/modules/home/components/profile/alarm/alarm-duration-predicate-value.component.scss rename to ui-ngx/src/app/modules/home/pages/notification/template/template-table-header.component.scss index e5f3e6532f..33537b4b23 100644 --- a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-duration-predicate-value.component.scss +++ b/ui-ngx/src/app/modules/home/pages/notification/template/template-table-header.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2022 The Thingsboard Authors + * Copyright © 2016-2023 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. @@ -13,10 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -:host ::ng-deep { - .source-attribute { - .mat-form-field-infix{ - width: 100%; - } - } +:host{ + width: 10000px; } diff --git a/ui-ngx/src/app/modules/home/pages/notification/template/template-table-header.component.ts b/ui-ngx/src/app/modules/home/pages/notification/template/template-table-header.component.ts new file mode 100644 index 0000000000..f6fd3f7e75 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/notification/template/template-table-header.component.ts @@ -0,0 +1,38 @@ +/// +/// Copyright © 2016-2023 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 { NotificationTemplate } from '@shared/models/notification.models'; + +@Component({ + selector: 'tb-template-table-header', + templateUrl: './template-table-header.component.html', + styleUrls: ['template-table-header.component.scss'] +}) +export class TemplateTableHeaderComponent extends EntityTableHeaderComponent { + + constructor(protected store: Store) { + super(store); + } + + createTemplate($event: Event) { + this.entitiesTableConfig.onEntityAction({event: $event, action: 'add', entity: null}); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/ota-update/ota-update-routing.module.ts b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update-routing.module.ts index 9d0f01e663..2af6910e23 100644 --- a/ui-ngx/src/app/modules/home/pages/ota-update/ota-update-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update-routing.module.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -24,7 +24,7 @@ import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages.models'; import { BreadCrumbConfig } from '@shared/components/breadcrumb'; -const routes: Routes = [ +export const otaUpdatesRoutes: Routes = [ { path: 'otaUpdates', data: { @@ -65,6 +65,18 @@ const routes: Routes = [ } ]; +const routes: Routes = [ + { + path: 'otaUpdates', + pathMatch: 'full', + redirectTo: '/features/otaUpdates' + }, + { + path: 'otaUpdates/:entityId', + redirectTo: '/features/otaUpdates/:entityId' + } +]; + @NgModule({ imports: [RouterModule.forChild(routes)], exports: [RouterModule], diff --git a/ui-ngx/src/app/modules/home/pages/ota-update/ota-update-table-config.resolve.ts b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update-table-config.resolve.ts index 8d173ad759..cbadbc8553 100644 --- a/ui-ngx/src/app/modules/home/pages/ota-update/ota-update-table-config.resolve.ts +++ b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update-table-config.resolve.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -73,6 +73,7 @@ export class OtaUpdateTableConfigResolve implements Resolve + [showDetailsPageLink]="true">
    ota-update.package-type diff --git a/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.ts b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.ts index 4e3db409dd..da479fa190 100644 --- a/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.ts +++ b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -20,7 +20,7 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { TranslateService } from '@ngx-translate/core'; import { EntityTableConfig } from '@home/models/entity/entities-table-config.models'; -import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { EntityComponent } from '@home/components/entity/entity.component'; import { ChecksumAlgorithm, @@ -39,7 +39,7 @@ import { isNotEmptyStr } from '@core/utils'; }) export class OtaUpdateComponent extends EntityComponent implements OnInit, OnDestroy { - private destroy$ = new Subject(); + private destroy$ = new Subject(); checksumAlgorithms = Object.values(ChecksumAlgorithm); checksumAlgorithmTranslationMap = ChecksumAlgorithmTranslationMap; @@ -50,7 +50,7 @@ export class OtaUpdateComponent extends EntityComponent implements O protected translate: TranslateService, @Inject('entity') protected entityValue: OtaPackage, @Inject('entitiesTableConfig') protected entitiesTableConfigValue: EntityTableConfig, - public fb: FormBuilder, + public fb: UntypedFormBuilder, protected cd: ChangeDetectorRef) { super(store, fb, entityValue, entitiesTableConfigValue, cd); } @@ -100,7 +100,7 @@ export class OtaUpdateComponent extends EntityComponent implements O } } - buildForm(entity: OtaPackage): FormGroup { + buildForm(entity: OtaPackage): UntypedFormGroup { const form = this.fb.group({ title: [entity ? entity.title : '', [Validators.required, Validators.maxLength(255)]], version: [entity ? entity.version : '', [Validators.required, Validators.maxLength(255)]], diff --git a/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.module.ts b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.module.ts index c768167a5e..1734b10f65 100644 --- a/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.module.ts +++ b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.module.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/modules/home/pages/profile/profile-routing.module.ts b/ui-ngx/src/app/modules/home/pages/profile/profile-routing.module.ts index 440db606fd..c14e450745 100644 --- a/ui-ngx/src/app/modules/home/pages/profile/profile-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/profile/profile-routing.module.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/modules/home/pages/profile/profile.component.html b/ui-ngx/src/app/modules/home/pages/profile/profile.component.html index 3a15616055..fad97beb68 100644 --- a/ui-ngx/src/app/modules/home/pages/profile/profile.component.html +++ b/ui-ngx/src/app/modules/home/pages/profile/profile.component.html @@ -1,6 +1,6 @@
    - - -
    + +
    - profile.profile + profile.profile {{ profile ? profile.get('email').value : '' }}
    - profile.last-login-time + profile.last-login-time
    - - - +
    @@ -56,6 +54,11 @@ user.last-name + + language.language @@ -67,7 +70,7 @@
    rulenode.link-labels - - + {{label.name}} close - + - + ; @@ -72,7 +72,7 @@ export class LinkLabelsComponent implements ControlValueAccessor, OnInit, OnChan @Input() sourceRuleChainId: string; - linksFormGroup: FormGroup; + linksFormGroup: UntypedFormGroup; modelValue: Array; @@ -88,7 +88,7 @@ export class LinkLabelsComponent implements ControlValueAccessor, OnInit, OnChan private propagateChange = (v: any) => { }; - constructor(private fb: FormBuilder, + constructor(private fb: UntypedFormBuilder, public truncate: TruncatePipe, public translate: TranslateService, private ruleChainService: RuleChainService) { diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-colors.scss b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-colors.scss index d16ba312ca..931f5ac6fc 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-colors.scss +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-colors.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2022 The Thingsboard Authors + * Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.html b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.html index b7abe74096..97f2214f2e 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.html +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.html @@ -1,6 +1,6 @@
    - + - profile.jwt-token + profile.jwt-token - -
    -
    {{ 'profile.token-valid-till' | translate }} {{ jwtTokenExpiration | date: 'yyyy-MM-dd HH:mm:ss' }}
    - -
    -
    +
    +
    {{ 'profile.token-valid-till' | translate }} {{ jwtTokenExpiration | date: 'yyyy-MM-dd HH:mm:ss' }}
    + +
    - - + +
    @@ -123,51 +121,49 @@
    - +
    - + - admin.2fa.2fa + admin.2fa.2fa -
    security.2fa.2fa-description
    +
    security.2fa.2fa-description
    - -

    security.2fa.authenticate-with

    -
    - -
    -

    {{ providersData.get(provider).name | translate }}

    -
    -
    - {{ providersData.get(provider).description | translate }} -
    - -
    - {{ providersData.get(provider).activatedHint | translate: providerDataInfo(provider) }} -
    -
    - - +

    security.2fa.authenticate-with

    + + +
    +

    {{ providersData.get(provider).name | translate }}

    +
    +
    + {{ providersData.get(provider).description | translate }}
    - - security.2fa.main-2fa-method - - + +
    + {{ providersData.get(provider).activatedHint | translate: providerDataInfo(provider) }} +
    +
    + +
    - - - - + + security.2fa.main-2fa-method + + +
    + +
    +
    diff --git a/ui-ngx/src/app/modules/home/pages/security/security.component.scss b/ui-ngx/src/app/modules/home/pages/security/security.component.scss index b593be3a2a..5a9f968275 100644 --- a/ui-ngx/src/app/modules/home/pages/security/security.component.scss +++ b/ui-ngx/src/app/modules/home/pages/security/security.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2022 The Thingsboard Authors + * Copyright © 2016-2023 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. @@ -19,8 +19,7 @@ .profile-container { padding: 8px; } - - mat-card.profile-card { + .mat-mdc-card.profile-card { padding: 24px; @media #{$mat-gt-sm} { width: 80%; @@ -52,7 +51,7 @@ margin-bottom: 25px; } - .mat-form-field { + .mat-mdc-form-field { margin-bottom: 4px; } @@ -114,17 +113,12 @@ opacity: 0.87; } - .mat-checkbox { + .mat-mdc-checkbox { margin-top: 8px; } - .mat-stroked-button { + .mat-mdc-outlined-button { margin-top: 8px; } } } -:host ::ng-deep { - .mat-form-field-appearance-fill .mat-form-field-underline::before { - background-color: transparent; - } -} diff --git a/ui-ngx/src/app/modules/home/pages/security/security.component.ts b/ui-ngx/src/app/modules/home/pages/security/security.component.ts index 1ecfb66954..9a99e7d6d4 100644 --- a/ui-ngx/src/app/modules/home/pages/security/security.component.ts +++ b/ui-ngx/src/app/modules/home/pages/security/security.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -21,8 +21,8 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { AbstractControl, - FormBuilder, - FormGroup, FormGroupDirective, + UntypedFormBuilder, + UntypedFormGroup, FormGroupDirective, NgForm, ValidationErrors, ValidatorFn, @@ -51,6 +51,7 @@ import { Observable, of, Subject } from 'rxjs'; import { isDefinedAndNotNull, isEqual } from '@core/utils'; import { AuthService } from '@core/auth/auth.service'; import { UserPasswordPolicy } from '@shared/models/settings.models'; +import { MatCheckboxChange } from '@angular/material/checkbox'; @Component({ selector: 'tb-security', @@ -62,8 +63,8 @@ export class SecurityComponent extends PageComponent implements OnInit, OnDestro private readonly destroy$ = new Subject(); private accountConfig: AccountTwoFaSettingProviders; - twoFactorAuth: FormGroup; - changePassword: FormGroup; + twoFactorAuth: UntypedFormGroup; + changePassword: UntypedFormGroup; user: User; passwordPolicy: UserPasswordPolicy; @@ -93,7 +94,7 @@ export class SecurityComponent extends PageComponent implements OnInit, OnDestro private twoFaService: TwoFactorAuthenticationService, public dialog: MatDialog, public dialogService: DialogService, - public fb: FormBuilder, + public fb: UntypedFormBuilder, private datePipe: DatePipe, private authService: AuthService, private clipboardService: ClipboardService) { @@ -290,14 +291,14 @@ export class SecurityComponent extends PageComponent implements OnInit, OnDestro }); } - changeDefaultProvider(event: MouseEvent, provider: TwoFactorAuthProviderType) { - event.stopPropagation(); - event.preventDefault(); + changeDefaultProvider(event: MatCheckboxChange, provider: TwoFactorAuthProviderType) { if (this.useByDefault !== provider) { this.twoFactorAuth.disable({emitEvent: false}); this.twoFaService.updateTwoFaAccountConfig(provider, true) .pipe(tap(() => this.twoFactorAuth.enable({emitEvent: false}))) .subscribe(data => this.processTwoFactorAuthConfig(data)); + } else { + event.source.checked = true; } } diff --git a/ui-ngx/src/app/modules/home/pages/security/security.module.ts b/ui-ngx/src/app/modules/home/pages/security/security.module.ts index 5db12c2a68..52d6636e0f 100644 --- a/ui-ngx/src/app/modules/home/pages/security/security.module.ts +++ b/ui-ngx/src/app/modules/home/pages/security/security.module.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-routing.module.ts b/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-routing.module.ts index b2acabc0d5..f17b78021e 100644 --- a/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-routing.module.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-tabs.component.html b/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-tabs.component.html index 4aa21e2aa5..cec78fd60d 100644 --- a/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-tabs.component.html @@ -1,6 +1,6 @@
    - - - login.create-password - + + + + login.create-password + + @@ -28,13 +30,13 @@
    - + common.password lock - + login.password-again lock @@ -45,7 +47,7 @@ - diff --git a/ui-ngx/src/app/modules/login/pages/login/create-password.component.scss b/ui-ngx/src/app/modules/login/pages/login/create-password.component.scss index 8ddc3b7982..e49395af06 100644 --- a/ui-ngx/src/app/modules/login/pages/login/create-password.component.scss +++ b/ui-ngx/src/app/modules/login/pages/login/create-password.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2022 The Thingsboard Authors + * Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/modules/login/pages/login/create-password.component.ts b/ui-ngx/src/app/modules/login/pages/login/create-password.component.ts index 5c95dd8f87..f305d183c1 100644 --- a/ui-ngx/src/app/modules/login/pages/login/create-password.component.ts +++ b/ui-ngx/src/app/modules/login/pages/login/create-password.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -19,7 +19,7 @@ import { AuthService } from '@core/auth/auth.service'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { PageComponent } from '@shared/components/page.component'; -import { FormBuilder } from '@angular/forms'; +import { UntypedFormBuilder } from '@angular/forms'; import { ActionNotificationShow } from '@core/notification/notification.actions'; import { TranslateService } from '@ngx-translate/core'; import { ActivatedRoute } from '@angular/router'; @@ -44,7 +44,7 @@ export class CreatePasswordComponent extends PageComponent implements OnInit, On private route: ActivatedRoute, private authService: AuthService, private translate: TranslateService, - public fb: FormBuilder) { + public fb: UntypedFormBuilder) { super(store); } diff --git a/ui-ngx/src/app/modules/login/pages/login/login.component.html b/ui-ngx/src/app/modules/login/pages/login/login.component.html index ea814b4def..f5797ae574 100644 --- a/ui-ngx/src/app/modules/login/pages/login/login.component.html +++ b/ui-ngx/src/app/modules/login/pages/login/login.component.html @@ -1,6 +1,6 @@
    - + login.username email @@ -49,7 +49,7 @@ {{ 'user.invalid-email-format' | translate }} - + common.password diff --git a/ui-ngx/src/app/modules/login/pages/login/login.component.scss b/ui-ngx/src/app/modules/login/pages/login/login.component.scss index 5013345566..8b72d3ee3d 100644 --- a/ui-ngx/src/app/modules/login/pages/login/login.component.scss +++ b/ui-ngx/src/app/modules/login/pages/login/login.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2022 The Thingsboard Authors + * Copyright © 2016-2023 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. @@ -14,6 +14,7 @@ * limitations under the License. */ @import '../../../../../scss/constants'; +@import '../../../../../theme'; :host { display: flex; @@ -70,23 +71,17 @@ a.login-with-button { color: rgba(black, 0.87); + background-color: map-get($tb-dark-theme-background, raised-button); &:hover { border-bottom: 0; } - .icon{ + .icon { height: 20px; width: 20px; - vertical-align: sub; } } - - .centered ::ng-deep .mat-button-wrapper { - display: flex; - justify-content: center; - align-items: center; - } } } } diff --git a/ui-ngx/src/app/modules/login/pages/login/login.component.ts b/ui-ngx/src/app/modules/login/pages/login/login.component.ts index e889969e4d..2fc1c7c50d 100644 --- a/ui-ngx/src/app/modules/login/pages/login/login.component.ts +++ b/ui-ngx/src/app/modules/login/pages/login/login.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -19,7 +19,7 @@ import { AuthService } from '@core/auth/auth.service'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { PageComponent } from '@shared/components/page.component'; -import { FormBuilder } from '@angular/forms'; +import { UntypedFormBuilder } from '@angular/forms'; import { HttpErrorResponse } from '@angular/common/http'; import { Constants } from '@shared/models/constants'; import { Router } from '@angular/router'; @@ -40,7 +40,7 @@ export class LoginComponent extends PageComponent implements OnInit { constructor(protected store: Store, private authService: AuthService, - public fb: FormBuilder, + public fb: UntypedFormBuilder, private router: Router) { super(store); } diff --git a/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.html b/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.html index c7832f6c91..53357903c8 100644 --- a/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.html +++ b/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.html @@ -1,6 +1,6 @@
    - - - login.request-password-reset - + + + + login.request-password-reset + + @@ -29,7 +31,7 @@
    - + login.email email @@ -42,7 +44,7 @@ - diff --git a/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.scss b/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.scss index 0b2dfc2e59..9e64eef74d 100644 --- a/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.scss +++ b/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2022 The Thingsboard Authors + * Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.ts b/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.ts index 233cf5f32d..e51df631c9 100644 --- a/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.ts +++ b/ui-ngx/src/app/modules/login/pages/login/reset-password-request.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -19,7 +19,7 @@ import { AuthService } from '@core/auth/auth.service'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { PageComponent } from '@shared/components/page.component'; -import { FormBuilder, Validators } from '@angular/forms'; +import { UntypedFormBuilder, Validators } from '@angular/forms'; import { ActionNotificationShow } from '@core/notification/notification.actions'; import { TranslateService } from '@ngx-translate/core'; @@ -39,7 +39,7 @@ export class ResetPasswordRequestComponent extends PageComponent implements OnIn constructor(protected store: Store, private authService: AuthService, private translate: TranslateService, - public fb: FormBuilder) { + public fb: UntypedFormBuilder) { super(store); } diff --git a/ui-ngx/src/app/modules/login/pages/login/reset-password.component.html b/ui-ngx/src/app/modules/login/pages/login/reset-password.component.html index b0e0e6737b..bb45938716 100644 --- a/ui-ngx/src/app/modules/login/pages/login/reset-password.component.html +++ b/ui-ngx/src/app/modules/login/pages/login/reset-password.component.html @@ -1,6 +1,6 @@
    - - - login.password-reset - - -
    login.expired-password-reset-message
    -
    + + + + login.password-reset + + +
    login.expired-password-reset-message
    +
    +
    @@ -31,13 +33,13 @@
    - + login.new-password lock - + login.new-password-again lock @@ -48,7 +50,7 @@ - diff --git a/ui-ngx/src/app/modules/login/pages/login/reset-password.component.scss b/ui-ngx/src/app/modules/login/pages/login/reset-password.component.scss index 41200be335..101054ac70 100644 --- a/ui-ngx/src/app/modules/login/pages/login/reset-password.component.scss +++ b/ui-ngx/src/app/modules/login/pages/login/reset-password.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2022 The Thingsboard Authors + * Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/modules/login/pages/login/reset-password.component.ts b/ui-ngx/src/app/modules/login/pages/login/reset-password.component.ts index ab0928ec66..68be67a9da 100644 --- a/ui-ngx/src/app/modules/login/pages/login/reset-password.component.ts +++ b/ui-ngx/src/app/modules/login/pages/login/reset-password.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -19,7 +19,7 @@ import { AuthService } from '@core/auth/auth.service'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { PageComponent } from '@shared/components/page.component'; -import { FormBuilder } from '@angular/forms'; +import { UntypedFormBuilder } from '@angular/forms'; import { ActionNotificationShow } from '@core/notification/notification.actions'; import { TranslateService } from '@ngx-translate/core'; import { ActivatedRoute } from '@angular/router'; @@ -46,7 +46,7 @@ export class ResetPasswordComponent extends PageComponent implements OnInit, OnD private route: ActivatedRoute, private authService: AuthService, private translate: TranslateService, - public fb: FormBuilder) { + public fb: UntypedFormBuilder) { super(store); } diff --git a/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.html b/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.html index f6592e686e..56565588cd 100644 --- a/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.html +++ b/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.html @@ -1,6 +1,6 @@
    + +
    + + +
    diff --git a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.scss b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.scss index a8de9e6623..70f43f410c 100644 --- a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.scss +++ b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2022 The Thingsboard Authors + * Copyright © 2016-2023 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. @@ -16,16 +16,14 @@ @import "../../../../scss/constants"; :host { - width: 100%; - height: 100%; - form, - fieldset { - height: 100%; - } + display: flex; + flex-direction: column; + max-height: 100%; + max-width: 100%; + background-color: #fff; .mat-content { overflow: hidden; - background-color: #fff; } .mat-padding { @@ -45,7 +43,7 @@ .limit-slider-value { margin-left: 16px; min-width: 25px; - max-width: 80px; + max-width: 100px; } mat-form-field input[type=number] { text-align: center; @@ -68,21 +66,16 @@ } :host ::ng-deep { - mat-radio-button { + .mat-mdc-radio-button { display: block; - margin-bottom: 16px; - .mat-radio-label { - width: 100%; + .mdc-form-field { align-items: start; - .mat-radio-label-content { - width: 100%; + > label { + padding-top: 10px; } } } - .mat-slider-horizontal .mat-slider-thumb-label { - width: 38px; - height: 38px; - top: -46px; - right: -19px; + .mat-mdc-tab-group:not(.tb-headless) { + height: 100%; } } diff --git a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.ts b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.ts index 67ee2ac72b..7b30759cdf 100644 --- a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.ts +++ b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -25,14 +25,13 @@ import { Timewindow, TimewindowType } from '@shared/models/time/time.models'; -import { OverlayRef } from '@angular/cdk/overlay'; import { PageComponent } from '@shared/components/page.component'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; -import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { TimeService } from '@core/services/time.service'; - -export const TIMEWINDOW_PANEL_DATA = new InjectionToken('TimewindowPanelData'); +import { isDefined } from '@core/utils'; +import { OverlayRef } from '@angular/cdk/overlay'; export interface TimewindowPanelData { historyOnly: boolean; @@ -43,6 +42,8 @@ export interface TimewindowPanelData { isEdit: boolean; } +export const TIMEWINDOW_PANEL_DATA = new InjectionToken('TimewindowPanelData'); + @Component({ selector: 'tb-timewindow-panel', templateUrl: './timewindow-panel.component.html', @@ -62,9 +63,7 @@ export class TimewindowPanelComponent extends PageComponent implements OnInit { timewindow: Timewindow; - result: Timewindow; - - timewindowForm: FormGroup; + timewindowForm: UntypedFormGroup; historyTypes = HistoryWindowType; @@ -78,10 +77,12 @@ export class TimewindowPanelComponent extends PageComponent implements OnInit { aggregationTypesTranslations = aggregationTranslations; + result: Timewindow; + constructor(@Inject(TIMEWINDOW_PANEL_DATA) public data: TimewindowPanelData, public overlayRef: OverlayRef, protected store: Store, - public fb: FormBuilder, + public fb: UntypedFormBuilder, private timeService: TimeService, public viewContainerRef: ViewContainerRef) { super(store); @@ -101,77 +102,60 @@ export class TimewindowPanelComponent extends PageComponent implements OnInit { const hideAggInterval = this.timewindow.hideAggInterval || false; const hideTimezone = this.timewindow.hideTimezone || false; + const realtime = this.timewindow.realtime; + const history = this.timewindow.history; + const aggregation = this.timewindow.aggregation; + this.timewindowForm = this.fb.group({ - realtime: this.fb.group( - { - realtimeType: this.fb.control({ - value: this.timewindow.realtime && typeof this.timewindow.realtime.realtimeType !== 'undefined' - ? this.timewindow.realtime.realtimeType : RealtimeWindowType.LAST_INTERVAL, - disabled: hideInterval - }), - timewindowMs: this.fb.control({ - value: this.timewindow.realtime && typeof this.timewindow.realtime.timewindowMs !== 'undefined' - ? this.timewindow.realtime.timewindowMs : null, - disabled: hideInterval || hideLastInterval - }), - interval: [ - this.timewindow.realtime && typeof this.timewindow.realtime.interval !== 'undefined' - ? this.timewindow.realtime.interval : null - ], - quickInterval: this.fb.control({ - value: this.timewindow.realtime && typeof this.timewindow.realtime.quickInterval !== 'undefined' - ? this.timewindow.realtime.quickInterval : null, - disabled: hideInterval || hideQuickInterval - }) - } - ), - history: this.fb.group( - { - historyType: this.fb.control({ - value: this.timewindow.history && typeof this.timewindow.history.historyType !== 'undefined' - ? this.timewindow.history.historyType : HistoryWindowType.LAST_INTERVAL, - disabled: hideInterval - }), - timewindowMs: this.fb.control({ - value: this.timewindow.history && typeof this.timewindow.history.timewindowMs !== 'undefined' - ? this.timewindow.history.timewindowMs : null, - disabled: hideInterval - }), - interval: [ - this.timewindow.history && typeof this.timewindow.history.interval !== 'undefined' - ? this.timewindow.history.interval : null - ], - fixedTimewindow: this.fb.control({ - value: this.timewindow.history && typeof this.timewindow.history.fixedTimewindow !== 'undefined' - ? this.timewindow.history.fixedTimewindow : null, - disabled: hideInterval - }), - quickInterval: this.fb.control({ - value: this.timewindow.history && typeof this.timewindow.history.quickInterval !== 'undefined' - ? this.timewindow.history.quickInterval : null, - disabled: hideInterval - }) - } - ), - aggregation: this.fb.group( - { - type: this.fb.control({ - value: this.timewindow.aggregation && typeof this.timewindow.aggregation.type !== 'undefined' - ? this.timewindow.aggregation.type : null, - disabled: hideAggregation - }), - limit: this.fb.control({ - value: this.timewindow.aggregation && typeof this.timewindow.aggregation.limit !== 'undefined' - ? this.checkLimit(this.timewindow.aggregation.limit) : null, - disabled: hideAggInterval - }, []) - } - ), - timezone: this.fb.control({ - value: this.timewindow.timezone !== 'undefined' - ? this.timewindow.timezone : null, - disabled: hideTimezone - }) + realtime: this.fb.group({ + realtimeType: [{ + value: isDefined(realtime?.realtimeType) ? this.timewindow.realtime.realtimeType : RealtimeWindowType.LAST_INTERVAL, + disabled: hideInterval + }], + timewindowMs: [{ + value: isDefined(realtime?.timewindowMs) ? this.timewindow.realtime.timewindowMs : null, + disabled: hideInterval || hideLastInterval + }], + interval: [isDefined(realtime?.interval) ? this.timewindow.realtime.interval : null], + quickInterval: [{ + value: isDefined(realtime?.quickInterval) ? this.timewindow.realtime.quickInterval : null, + disabled: hideInterval || hideQuickInterval + }] + }), + history: this.fb.group({ + historyType: [{ + value: isDefined(history?.historyType) ? this.timewindow.history.historyType : HistoryWindowType.LAST_INTERVAL, + disabled: hideInterval + }], + timewindowMs: [{ + value: isDefined(history?.timewindowMs) ? this.timewindow.history.timewindowMs : null, + disabled: hideInterval + }], + interval: [ isDefined(history?.interval) ? this.timewindow.history.interval : null + ], + fixedTimewindow: [{ + value: isDefined(history?.fixedTimewindow) ? this.timewindow.history.fixedTimewindow : null, + disabled: hideInterval + }], + quickInterval: [{ + value: isDefined(history?.quickInterval) ? this.timewindow.history.quickInterval : null, + disabled: hideInterval + }] + }), + aggregation: this.fb.group({ + type: [{ + value: isDefined(aggregation?.type) ? this.timewindow.aggregation.type : null, + disabled: hideAggregation + }], + limit: [{ + value: isDefined(aggregation?.limit) ? this.checkLimit(this.timewindow.aggregation.limit) : null, + disabled: hideAggInterval + }, []] + }), + timezone: [{ + value: isDefined(this.timewindow.timezone) ? this.timewindow.timezone : null, + disabled: hideTimezone + }] }); this.updateValidators(); this.timewindowForm.get('aggregation.type').valueChanges.subscribe(() => { diff --git a/ui-ngx/src/app/shared/components/time/timewindow.component.html b/ui-ngx/src/app/shared/components/time/timewindow.component.html index ca1a4b1f5f..2b689831a9 100644 --- a/ui-ngx/src/app/shared/components/time/timewindow.component.html +++ b/ui-ngx/src/app/shared/components/time/timewindow.component.html @@ -1,6 +1,6 @@ - -
    +
    {{innerValue?.displayValue}} | {{innerValue.displayTimezoneAbbr}} diff --git a/ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.scss b/ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.scss index 06b464db27..8470316f0b 100644 --- a/ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.scss +++ b/ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2022 The Thingsboard Authors + * Copyright © 2016-2023 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. @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -.mat-option.branch-option { +.mat-mdc-option.branch-option { .mat-icon, .check-placeholder { margin-right: 8px; } @@ -21,7 +21,7 @@ width: 18px; display: inline-block; } - .mat-option-text { + .mdc-list-item__primary-text { width: 100%; .default-branch { float: right; diff --git a/ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.ts b/ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.ts index c135b0a8f7..1b87c9ca3d 100644 --- a/ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -25,7 +25,7 @@ import { ViewChild, ViewEncapsulation } from '@angular/core'; -import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { ControlValueAccessor, UntypedFormBuilder, UntypedFormGroup, NG_VALUE_ACCESSOR } from '@angular/forms'; import { Observable } from 'rxjs'; import { debounceTime, distinctUntilChanged, map, share, switchMap, tap } from 'rxjs/operators'; import { Store } from '@ngrx/store'; @@ -35,6 +35,7 @@ import { BranchInfo } from '@shared/models/vc.models'; import { EntitiesVersionControlService } from '@core/http/entities-version-control.service'; import { isNotEmptyStr } from '@core/utils'; import { MatAutocomplete, MatAutocompleteTrigger } from '@angular/material/autocomplete'; +import { SubscriptSizing } from '@angular/material/form-field'; @Component({ selector: 'tb-branch-autocomplete', @@ -49,10 +50,13 @@ import { MatAutocomplete, MatAutocompleteTrigger } from '@angular/material/autoc }) export class BranchAutocompleteComponent implements ControlValueAccessor, OnInit, AfterViewInit { - branchFormGroup: FormGroup; + branchFormGroup: UntypedFormGroup; modelValue: string | null; + @Input() + subscriptSizing: SubscriptSizing = 'fixed'; + private requiredValue: boolean; get required(): boolean { @@ -91,7 +95,7 @@ export class BranchAutocompleteComponent implements ControlValueAccessor, OnInit @ViewChild('branchAutocomplete') matAutocomplete: MatAutocomplete; @ViewChild('branchInput', { read: MatAutocompleteTrigger, static: true }) autoCompleteTrigger: MatAutocompleteTrigger; - @ViewChild('branchInput', {static: true}) branchInput: ElementRef; + @ViewChild('branchInput', {static: true}) branchInput: ElementRef; filteredBranches: Observable>; @@ -109,7 +113,7 @@ export class BranchAutocompleteComponent implements ControlValueAccessor, OnInit constructor(private store: Store, private entitiesVersionControlService: EntitiesVersionControlService, - private fb: FormBuilder, + private fb: UntypedFormBuilder, private zone: NgZone) { this.branchFormGroup = this.fb.group({ branch: [null, []] @@ -264,9 +268,7 @@ export class BranchAutocompleteComponent implements ControlValueAccessor, OnInit this.searchText = searchText; return this.getBranches().pipe( map(branches => { - let res = branches.filter(branch => { - return searchText ? branch.name.toUpperCase().startsWith(searchText.toUpperCase()) : true; - }); + let res = branches.filter(branch => searchText ? branch.name.toUpperCase().startsWith(searchText.toUpperCase()) : true); if (!this.selectionMode && isNotEmptyStr(searchText) && !res.find(b => b.name === searchText)) { res = [{name: searchText, default: false}, ...res]; } diff --git a/ui-ngx/src/app/shared/components/widgets-bundle-search.component.html b/ui-ngx/src/app/shared/components/widgets-bundle-search.component.html index 0db97bdf23..4790d7e93b 100644 --- a/ui-ngx/src/app/shared/components/widgets-bundle-search.component.html +++ b/ui-ngx/src/app/shared/components/widgets-bundle-search.component.html @@ -1,6 +1,6 @@ - - + (target: any, key: string): void => { + const getter = function() { + return this['__' + key]; + }; + + const setter = function(next: any) { + this['__' + key] = coerceBooleanProperty(next); + }; + + Object.defineProperty(target, key, { + get: getter, + set: setter, + enumerable: true, + configurable: true, + }); +}; diff --git a/ui-ngx/src/app/shared/decorators/enumerable.ts b/ui-ngx/src/app/shared/decorators/enumerable.ts index 0c66a3bc0d..1e89ff0ca3 100644 --- a/ui-ngx/src/app/shared/decorators/enumerable.ts +++ b/ui-ngx/src/app/shared/decorators/enumerable.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/decorators/tb-inject.ts b/ui-ngx/src/app/shared/decorators/tb-inject.ts index 2c5150600f..6a082b9b63 100644 --- a/ui-ngx/src/app/shared/decorators/tb-inject.ts +++ b/ui-ngx/src/app/shared/decorators/tb-inject.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/ace/ace.models.ts b/ui-ngx/src/app/shared/models/ace/ace.models.ts index 9777935922..5b37c7c2c3 100644 --- a/ui-ngx/src/app/shared/models/ace/ace.models.ts +++ b/ui-ngx/src/app/shared/models/ace/ace.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/ace/completion.models.ts b/ui-ngx/src/app/shared/models/ace/completion.models.ts index 0941fcbe39..c2b8013d63 100644 --- a/ui-ngx/src/app/shared/models/ace/completion.models.ts +++ b/ui-ngx/src/app/shared/models/ace/completion.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/ace/service-completion.models.ts b/ui-ngx/src/app/shared/models/ace/service-completion.models.ts index bcad0a000c..c96dc0d98f 100644 --- a/ui-ngx/src/app/shared/models/ace/service-completion.models.ts +++ b/ui-ngx/src/app/shared/models/ace/service-completion.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/ace/widget-completion.models.ts b/ui-ngx/src/app/shared/models/ace/widget-completion.models.ts index 7112283b36..b9d44c64cf 100644 --- a/ui-ngx/src/app/shared/models/ace/widget-completion.models.ts +++ b/ui-ngx/src/app/shared/models/ace/widget-completion.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/alarm.models.ts b/ui-ngx/src/app/shared/models/alarm.models.ts index fd2e200090..a176d2a69a 100644 --- a/ui-ngx/src/app/shared/models/alarm.models.ts +++ b/ui-ngx/src/app/shared/models/alarm.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -23,6 +23,13 @@ import { NULL_UUID } from '@shared/models/id/has-uuid'; import { EntityType } from '@shared/models/entity-type.models'; import { CustomerId } from '@shared/models/id/customer-id'; import { TableCellButtonActionDescriptor } from '@home/components/widget/lib/table-widget.models'; +import { AlarmCommentId } from '@shared/models/id/alarm-comment-id'; +import { UserId } from '@shared/models/id/user-id'; + +export enum AlarmsMode { + ALL, + ENTITY +} export enum AlarmSeverity { CRITICAL = 'CRITICAL', @@ -89,6 +96,7 @@ export const alarmSeverityColors = new Map( export interface Alarm extends BaseData { tenantId: TenantId; customerId: CustomerId; + assigneeId: UserId; type: string; originator: EntityId; severity: AlarmSeverity; @@ -97,12 +105,43 @@ export interface Alarm extends BaseData { endTs: number; ackTs: number; clearTs: number; + assignTs: number; propagate: boolean; details?: any; } +export enum AlarmCommentType { + SYSTEM = 'SYSTEM', + OTHER = 'OTHER' +} + +export interface AlarmComment extends BaseData { + alarmId: AlarmId; + userId?: UserId; + type: AlarmCommentType; + comment: { + text: string; + edited?: boolean; + editedOn?: number; + } +} + +export interface AlarmCommentInfo extends AlarmComment { + firstName?: string; + lastName?: string; + email?: string; +} + export interface AlarmInfo extends Alarm { originatorName: string; + originatorLabel: string; + assignee: AlarmAssignee; +} + +export interface AlarmAssignee { + firstName: string; + lastName: string; + email: string; } export interface AlarmDataInfo extends AlarmInfo { @@ -115,12 +154,20 @@ export const simulatedAlarm: AlarmInfo = { id: new AlarmId(NULL_UUID), tenantId: new TenantId(NULL_UUID), customerId: new CustomerId(NULL_UUID), + assigneeId: new UserId(NULL_UUID), createdTime: new Date().getTime(), startTs: new Date().getTime(), endTs: 0, ackTs: 0, clearTs: 0, + assignTs: 0, originatorName: 'Simulated', + originatorLabel: 'Simulated', + assignee: { + firstName: "", + lastName: "", + email: "test@example.com", + }, originator: { entityType: EntityType.DEVICE, id: '1' @@ -172,11 +219,22 @@ export const alarmFields: {[fieldName: string]: AlarmField} = { name: 'alarm.clear-time', time: true }, + assignTime: { + keyName: 'assignTime', + value: 'assignTs', + name: 'alarm.assign-time', + time: true + }, originator: { keyName: 'originator', value: 'originatorName', name: 'alarm.originator' }, + originatorLabel: { + keyName: 'originatorLabel', + value: 'originatorLabel', + name: 'alarm.originator-label' + }, originatorType: { keyName: 'originatorType', value: 'originator.entityType', @@ -196,6 +254,11 @@ export const alarmFields: {[fieldName: string]: AlarmField} = { keyName: 'status', value: 'status', name: 'alarm.status' + }, + assignee: { + keyName: 'assignee', + value: 'assignee', + name: 'alarm.assignee' } }; @@ -206,19 +269,21 @@ export class AlarmQuery { searchStatus: AlarmSearchStatus; status: AlarmStatus; fetchOriginator: boolean; + assigneeId?: UserId; constructor(entityId: EntityId, pageLink: TimePageLink, searchStatus: AlarmSearchStatus, status: AlarmStatus, - fetchOriginator: boolean) { + fetchOriginator: boolean, assigneeId?: UserId) { this.affectedEntityId = entityId; this.pageLink = pageLink; this.searchStatus = searchStatus; this.status = status; this.fetchOriginator = fetchOriginator; + this.assigneeId = assigneeId; } public toQuery(): string { - let query = `/${this.affectedEntityId.entityType}/${this.affectedEntityId.id}`; + let query = this.affectedEntityId ? `/${this.affectedEntityId.entityType}/${this.affectedEntityId.id}` : ''; query += this.pageLink.toQuery(); if (this.searchStatus) { query += `&searchStatus=${this.searchStatus}`; @@ -228,6 +293,9 @@ export class AlarmQuery { if (typeof this.fetchOriginator !== 'undefined' && this.fetchOriginator !== null) { query += `&fetchOriginator=${this.fetchOriginator}`; } + if (typeof this.assigneeId !== 'undefined' && this.assigneeId !== null) { + query += `&assigneeId=${this.assigneeId.id}`; + } return query; } diff --git a/ui-ngx/src/app/shared/models/alias.models.ts b/ui-ngx/src/app/shared/models/alias.models.ts index f67c0d50a7..fdcc3407a6 100644 --- a/ui-ngx/src/app/shared/models/alias.models.ts +++ b/ui-ngx/src/app/shared/models/alias.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -127,7 +127,7 @@ export interface EntitySearchQueryFilter { fetchLastLevelOnly?: boolean; } -// tslint:disable-next-line:no-empty-interface +// eslint-disable-next-line @typescript-eslint/no-empty-interface export interface ApiUsageStateFilter { } diff --git a/ui-ngx/src/app/shared/models/api-usage.models.ts b/ui-ngx/src/app/shared/models/api-usage.models.ts new file mode 100644 index 0000000000..bbc0ff5597 --- /dev/null +++ b/ui-ngx/src/app/shared/models/api-usage.models.ts @@ -0,0 +1,47 @@ +/// +/// Copyright © 2016-2023 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. +/// + +export enum ApiUsageStateValue { + ENABLED = 'ENABLED', + WARNING = 'WARNING', + DISABLED = 'DISABLED' +} + +export const ApiUsageStateValueTranslationMap = new Map([ + [ApiUsageStateValue.ENABLED, 'notification.enabled'], + [ApiUsageStateValue.WARNING, 'notification.warning'], + [ApiUsageStateValue.DISABLED, 'notification.disabled'], +]); + +export enum ApiFeature { + TRANSPORT = 'TRANSPORT', + DB = 'DB', + RE = 'RE', + JS = 'JS', + EMAIL = 'EMAIL', + SMS = 'SMS', + ALARM = 'ALARM' +} + +export const ApiFeatureTranslationMap = new Map([ + [ApiFeature.TRANSPORT, 'api-usage.device-api'], + [ApiFeature.DB, 'api-usage.telemetry-persistence'], + [ApiFeature.RE, 'api-usage.rule-engine-executions'], + [ApiFeature.JS, 'api-usage.javascript-executions'], + [ApiFeature.EMAIL, 'api-usage.email-messages'], + [ApiFeature.SMS, 'api-usage.sms-messages'], + [ApiFeature.ALARM, 'api-usage.alarm'], +]); diff --git a/ui-ngx/src/app/shared/models/asset.models.ts b/ui-ngx/src/app/shared/models/asset.models.ts index 029c560310..a1ab16fdc8 100644 --- a/ui-ngx/src/app/shared/models/asset.models.ts +++ b/ui-ngx/src/app/shared/models/asset.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -35,9 +35,11 @@ export interface AssetProfile extends BaseData, ExportableEntity defaultRuleChainId?: RuleChainId; defaultDashboardId?: DashboardId; defaultQueueName?: string; + defaultEdgeRuleChainId?: RuleChainId; } export interface AssetProfileInfo extends EntityInfoData { + tenantId?: TenantId; image?: string; defaultDashboardId?: DashboardId; } diff --git a/ui-ngx/src/app/shared/models/audit-log.models.ts b/ui-ngx/src/app/shared/models/audit-log.models.ts index 056c6aa569..8f4e26171e 100644 --- a/ui-ngx/src/app/shared/models/audit-log.models.ts +++ b/ui-ngx/src/app/shared/models/audit-log.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -47,6 +47,11 @@ export enum ActionType { RELATIONS_DELETED = 'RELATIONS_DELETED', ALARM_ACK = 'ALARM_ACK', ALARM_CLEAR = 'ALARM_CLEAR', + ALARM_ASSIGN = 'ALARM_ASSIGN', + ALARM_UNASSIGN = 'ALARM_UNASSIGN', + ADDED_COMMENT = 'ADDED_COMMENT', + UPDATED_COMMENT = 'UPDATED_COMMENT', + DELETED_COMMENT = 'DELETED_COMMENT', LOGIN = 'LOGIN', LOGOUT = 'LOGOUT', LOCKOUT = 'LOCKOUT', @@ -85,6 +90,11 @@ export const actionTypeTranslations = new Map( [ActionType.RELATIONS_DELETED, 'audit-log.type-relations-delete'], [ActionType.ALARM_ACK, 'audit-log.type-alarm-ack'], [ActionType.ALARM_CLEAR, 'audit-log.type-alarm-clear'], + [ActionType.ALARM_ASSIGN, 'audit-log.type-alarm-assign'], + [ActionType.ALARM_UNASSIGN, 'audit-log.type-alarm-unassign'], + [ActionType.ADDED_COMMENT, 'audit-log.type-added-comment'], + [ActionType.UPDATED_COMMENT, 'audit-log.type-updated-comment'], + [ActionType.DELETED_COMMENT, 'audit-log.type-deleted-comment'], [ActionType.LOGIN, 'audit-log.type-login'], [ActionType.LOGOUT, 'audit-log.type-logout'], [ActionType.LOCKOUT, 'audit-log.type-lockout'], diff --git a/ui-ngx/src/app/shared/models/authority.enum.ts b/ui-ngx/src/app/shared/models/authority.enum.ts index 683c153b07..c736fe9cb6 100644 --- a/ui-ngx/src/app/shared/models/authority.enum.ts +++ b/ui-ngx/src/app/shared/models/authority.enum.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/base-data.ts b/ui-ngx/src/app/shared/models/base-data.ts index 7b3ea9a642..16022641aa 100644 --- a/ui-ngx/src/app/shared/models/base-data.ts +++ b/ui-ngx/src/app/shared/models/base-data.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -27,6 +27,17 @@ export interface BaseData { label?: string; } +export function sortEntitiesByIds>(entities: T[], entityIds: string[]): T[] { + entities.sort((entity1, entity2) => { + const id1 = entity1.id.id; + const id2 = entity2.id.id; + const index1 = entityIds.indexOf(id1); + const index2 = entityIds.indexOf(id2); + return index1 - index2; + }); + return entities; +} + export interface ExportableEntity { createdTime?: number; id?: T; diff --git a/ui-ngx/src/app/shared/models/beautify.models.ts b/ui-ngx/src/app/shared/models/beautify.models.ts index 6e2affc6fe..ad7c6427c0 100644 --- a/ui-ngx/src/app/shared/models/beautify.models.ts +++ b/ui-ngx/src/app/shared/models/beautify.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/component-descriptor.models.ts b/ui-ngx/src/app/shared/models/component-descriptor.models.ts index 47e7d5af42..56dd29cb4e 100644 --- a/ui-ngx/src/app/shared/models/component-descriptor.models.ts +++ b/ui-ngx/src/app/shared/models/component-descriptor.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/constants.ts b/ui-ngx/src/app/shared/models/constants.ts index 6d2a098ca6..a93df2e3fd 100644 --- a/ui-ngx/src/app/shared/models/constants.ts +++ b/ui-ngx/src/app/shared/models/constants.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -76,6 +76,7 @@ export const HelpLinks = { linksMap: { outgoingMailSettings: helpBaseUrl + '/docs/user-guide/ui/mail-settings', smsProviderSettings: helpBaseUrl + '/docs/user-guide/ui/sms-provider-settings', + slackSettings: helpBaseUrl + '/docs/user-guide/ui/slack-settings', securitySettings: helpBaseUrl + '/docs/user-guide/ui/security-settings', oauth2Settings: helpBaseUrl + '/docs/user-guide/oauth-2-support/', twoFactorAuthSettings: helpBaseUrl + '/docs/', @@ -85,6 +86,9 @@ export const HelpLinks = { ruleNodeGpsGeofencingFilter: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/filter-nodes/#gps-geofencing-filter-node', ruleNodeJsFilter: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/filter-nodes/#script-filter-node', ruleNodeJsSwitch: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/filter-nodes/#switch-node', + ruleNodeAssetProfileSwitch: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/filter-nodes/#asset-profile-switch', + ruleNodeDeviceProfileSwitch: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/filter-nodes/#device-profile-switch', + ruleNodeCheckAlarmStatus: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/filter-nodes/#check-alarm-status', ruleNodeMessageTypeFilter: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/filter-nodes/#message-type-filter-node', ruleNodeMessageTypeSwitch: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/filter-nodes/#message-type-switch-node', ruleNodeOriginatorTypeFilter: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/filter-nodes/#originator-type-filter-node', diff --git a/ui-ngx/src/app/shared/models/contact-based.model.ts b/ui-ngx/src/app/shared/models/contact-based.model.ts index f39f1fa290..d7da687098 100644 --- a/ui-ngx/src/app/shared/models/contact-based.model.ts +++ b/ui-ngx/src/app/shared/models/contact-based.model.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/country.models.ts b/ui-ngx/src/app/shared/models/country.models.ts index afba058c6d..f243ba3a5c 100644 --- a/ui-ngx/src/app/shared/models/country.models.ts +++ b/ui-ngx/src/app/shared/models/country.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/customer.model.ts b/ui-ngx/src/app/shared/models/customer.model.ts index 67c1429189..d26725814f 100644 --- a/ui-ngx/src/app/shared/models/customer.model.ts +++ b/ui-ngx/src/app/shared/models/customer.model.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/dashboard.models.ts b/ui-ngx/src/app/shared/models/dashboard.models.ts index bf71997e35..8548bc73d2 100644 --- a/ui-ngx/src/app/shared/models/dashboard.models.ts +++ b/ui-ngx/src/app/shared/models/dashboard.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/device.models.ts b/ui-ngx/src/app/shared/models/device.models.ts index d06963544e..ae601b0039 100644 --- a/ui-ngx/src/app/shared/models/device.models.ts +++ b/ui-ngx/src/app/shared/models/device.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -242,6 +242,7 @@ export interface DefaultDeviceProfileTransportConfiguration { export interface MqttDeviceProfileTransportConfiguration { deviceTelemetryTopic?: string; deviceAttributesTopic?: string; + sparkplug?: boolean; sendAckOnValidationException?: boolean; transportPayloadTypeConfiguration?: { transportPayloadType?: TransportPayloadType; @@ -359,6 +360,7 @@ export function createDeviceProfileTransportConfiguration(type: DeviceTransportT const mqttTransportConfiguration: MqttDeviceProfileTransportConfiguration = { deviceTelemetryTopic: 'v1/devices/me/telemetry', deviceAttributesTopic: 'v1/devices/me/attributes', + sparkplug: false, sendAckOnValidationException: false, transportPayloadTypeConfiguration: { transportPayloadType: TransportPayloadType.JSON, @@ -498,13 +500,15 @@ export interface CustomTimeSchedulerItem{ endsOn: number; } -export interface AlarmRule { +interface AlarmRule { condition: AlarmCondition; alarmDetails?: string; dashboardId?: DashboardId; schedule?: AlarmSchedule; } +export { AlarmRule as DeviceProfileAlarmRule }; + export function alarmRuleValidator(control: AbstractControl): ValidationErrors | null { const alarmRule: AlarmRule = control.value; return alarmRuleValid(alarmRule) ? null : {alarmRule: true}; @@ -579,9 +583,11 @@ export interface DeviceProfile extends BaseData, ExportableEnti firmwareId?: OtaPackageId; softwareId?: OtaPackageId; profileData: DeviceProfileData; + defaultEdgeRuleChainId?: RuleChainId; } export interface DeviceProfileInfo extends EntityInfoData { + tenantId?: TenantId; type: DeviceProfileType; transportType: DeviceTransportType; image?: string; diff --git a/ui-ngx/src/app/shared/models/edge.models.ts b/ui-ngx/src/app/shared/models/edge.models.ts index 59f9d54318..372a910402 100644 --- a/ui-ngx/src/app/shared/models/edge.models.ts +++ b/ui-ngx/src/app/shared/models/edge.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -167,3 +167,7 @@ export interface EdgeEvent extends BaseData { uid: string; body: string; } + +export interface EdgeInstallInstructions { + dockerInstallInstructions: string; +} 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 83a7ebebf8..033fe363c8 100644 --- a/ui-ngx/src/app/shared/models/entity-type.models.ts +++ b/ui-ngx/src/app/shared/models/entity-type.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -38,7 +38,12 @@ export enum EntityType { TB_RESOURCE = 'TB_RESOURCE', OTA_PACKAGE = 'OTA_PACKAGE', RPC = 'RPC', - QUEUE = 'QUEUE' + QUEUE = 'QUEUE', + NOTIFICATION = 'NOTIFICATION', + NOTIFICATION_REQUEST = 'NOTIFICATION_REQUEST', + NOTIFICATION_RULE = 'NOTIFICATION_RULE', + NOTIFICATION_TARGET = 'NOTIFICATION_TARGET', + NOTIFICATION_TEMPLATE = 'NOTIFICATION_TEMPLATE' } export enum AliasEntityType { @@ -251,7 +256,7 @@ export const entityTypeTranslations = new Map([ [EntityType.CUSTOMER, '/customers'], [EntityType.USER, '/users'], [EntityType.DASHBOARD, '/dashboards'], - [EntityType.ASSET, '/assets'], - [EntityType.DEVICE, '/devices'], + [EntityType.ASSET, '/entities/assets'], + [EntityType.DEVICE, '/entities/devices'], [EntityType.DEVICE_PROFILE, '/profiles/deviceProfiles'], [EntityType.ASSET_PROFILE, '/profiles/assetProfiles'], [EntityType.RULE_CHAIN, '/ruleChains'], - [EntityType.EDGE, '/edgeInstances'], - [EntityType.ENTITY_VIEW, '/entityViews'], - [EntityType.TB_RESOURCE, '/settings/resources-library'], - [EntityType.OTA_PACKAGE, '/otaUpdates'], + [EntityType.EDGE, '/edgeManagement/instances'], + [EntityType.ENTITY_VIEW, '/entities/entityViews'], + [EntityType.TB_RESOURCE, '/resources/resources-library'], + [EntityType.OTA_PACKAGE, '/features/otaUpdates'], [EntityType.QUEUE, '/settings/queues'] ]); diff --git a/ui-ngx/src/app/shared/models/entity-view.models.ts b/ui-ngx/src/app/shared/models/entity-view.models.ts index 9605edfc85..dd16914bc1 100644 --- a/ui-ngx/src/app/shared/models/entity-view.models.ts +++ b/ui-ngx/src/app/shared/models/entity-view.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/entity.models.ts b/ui-ngx/src/app/shared/models/entity.models.ts index 8bcfa6c34a..548b912b04 100644 --- a/ui-ngx/src/app/shared/models/entity.models.ts +++ b/ui-ngx/src/app/shared/models/entity.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/error.models.ts b/ui-ngx/src/app/shared/models/error.models.ts index ddc0b676ce..794d8f6891 100644 --- a/ui-ngx/src/app/shared/models/error.models.ts +++ b/ui-ngx/src/app/shared/models/error.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/event.models.ts b/ui-ngx/src/app/shared/models/event.models.ts index 89e93d4fa1..9664c88fcf 100644 --- a/ui-ngx/src/app/shared/models/event.models.ts +++ b/ui-ngx/src/app/shared/models/event.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/id/alarm-comment-id.ts b/ui-ngx/src/app/shared/models/id/alarm-comment-id.ts new file mode 100644 index 0000000000..c161e3095c --- /dev/null +++ b/ui-ngx/src/app/shared/models/id/alarm-comment-id.ts @@ -0,0 +1,24 @@ +/// +/// Copyright © 2016-2023 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 { HasUUID } from '@shared/models/id/has-uuid'; + +export class AlarmCommentId implements HasUUID { + id: string; + constructor(id: string) { + this.id = id; + } +} diff --git a/ui-ngx/src/app/shared/models/id/alarm-id.ts b/ui-ngx/src/app/shared/models/id/alarm-id.ts index 95d0deb18f..3cd21f1157 100644 --- a/ui-ngx/src/app/shared/models/id/alarm-id.ts +++ b/ui-ngx/src/app/shared/models/id/alarm-id.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/id/asset-id.ts b/ui-ngx/src/app/shared/models/id/asset-id.ts index 2a8a911428..9adf209128 100644 --- a/ui-ngx/src/app/shared/models/id/asset-id.ts +++ b/ui-ngx/src/app/shared/models/id/asset-id.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/id/asset-profile-id.ts b/ui-ngx/src/app/shared/models/id/asset-profile-id.ts index 8fc021a16f..55d36db2fa 100644 --- a/ui-ngx/src/app/shared/models/id/asset-profile-id.ts +++ b/ui-ngx/src/app/shared/models/id/asset-profile-id.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/id/audit-log-id.ts b/ui-ngx/src/app/shared/models/id/audit-log-id.ts index ba5e022dbf..bccaa6ed17 100644 --- a/ui-ngx/src/app/shared/models/id/audit-log-id.ts +++ b/ui-ngx/src/app/shared/models/id/audit-log-id.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/id/customer-id.ts b/ui-ngx/src/app/shared/models/id/customer-id.ts index e5a6a49601..8e55155c53 100644 --- a/ui-ngx/src/app/shared/models/id/customer-id.ts +++ b/ui-ngx/src/app/shared/models/id/customer-id.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/id/dashboard-id.ts b/ui-ngx/src/app/shared/models/id/dashboard-id.ts index 54ae24c2d5..a83900193d 100644 --- a/ui-ngx/src/app/shared/models/id/dashboard-id.ts +++ b/ui-ngx/src/app/shared/models/id/dashboard-id.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/id/device-credentials-id.ts b/ui-ngx/src/app/shared/models/id/device-credentials-id.ts index 1f428c0be8..21b73659a6 100644 --- a/ui-ngx/src/app/shared/models/id/device-credentials-id.ts +++ b/ui-ngx/src/app/shared/models/id/device-credentials-id.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/id/device-id.ts b/ui-ngx/src/app/shared/models/id/device-id.ts index 2432215133..71cc69de5a 100644 --- a/ui-ngx/src/app/shared/models/id/device-id.ts +++ b/ui-ngx/src/app/shared/models/id/device-id.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/id/device-profile-id.ts b/ui-ngx/src/app/shared/models/id/device-profile-id.ts index a50def52ad..a9596461eb 100644 --- a/ui-ngx/src/app/shared/models/id/device-profile-id.ts +++ b/ui-ngx/src/app/shared/models/id/device-profile-id.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/id/edge-id.ts b/ui-ngx/src/app/shared/models/id/edge-id.ts index ef21c384e7..5d26c14fee 100644 --- a/ui-ngx/src/app/shared/models/id/edge-id.ts +++ b/ui-ngx/src/app/shared/models/id/edge-id.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/id/entity-id.ts b/ui-ngx/src/app/shared/models/id/entity-id.ts index b208de441b..dc71119cbc 100644 --- a/ui-ngx/src/app/shared/models/id/entity-id.ts +++ b/ui-ngx/src/app/shared/models/id/entity-id.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/id/entity-view-id.ts b/ui-ngx/src/app/shared/models/id/entity-view-id.ts index d48f29955f..4ee80a8790 100644 --- a/ui-ngx/src/app/shared/models/id/entity-view-id.ts +++ b/ui-ngx/src/app/shared/models/id/entity-view-id.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/id/event-id.ts b/ui-ngx/src/app/shared/models/id/event-id.ts index 9d84b95a91..af15972856 100644 --- a/ui-ngx/src/app/shared/models/id/event-id.ts +++ b/ui-ngx/src/app/shared/models/id/event-id.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/id/has-uuid.ts b/ui-ngx/src/app/shared/models/id/has-uuid.ts index 2c6cb83715..e53f008c1f 100644 --- a/ui-ngx/src/app/shared/models/id/has-uuid.ts +++ b/ui-ngx/src/app/shared/models/id/has-uuid.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/id/notification-id.ts b/ui-ngx/src/app/shared/models/id/notification-id.ts new file mode 100644 index 0000000000..a3286342bb --- /dev/null +++ b/ui-ngx/src/app/shared/models/id/notification-id.ts @@ -0,0 +1,26 @@ +/// +/// Copyright © 2016-2023 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 { EntityId } from '@shared/models/id/entity-id'; +import { EntityType } from '@shared/models/entity-type.models'; + +export class NotificationId implements EntityId { + entityType = EntityType.NOTIFICATION; + id: string; + constructor(id: string) { + this.id = id; + } +} diff --git a/ui-ngx/src/app/shared/models/id/notification-request-id.ts b/ui-ngx/src/app/shared/models/id/notification-request-id.ts new file mode 100644 index 0000000000..518ccc884d --- /dev/null +++ b/ui-ngx/src/app/shared/models/id/notification-request-id.ts @@ -0,0 +1,26 @@ +/// +/// Copyright © 2016-2023 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 { EntityId } from '@shared/models/id/entity-id'; +import { EntityType } from '@shared/models/entity-type.models'; + +export class NotificationRequestId implements EntityId { + entityType = EntityType.NOTIFICATION_REQUEST; + id: string; + constructor(id: string) { + this.id = id; + } +} diff --git a/ui-ngx/src/app/shared/models/id/notification-rule-id.ts b/ui-ngx/src/app/shared/models/id/notification-rule-id.ts new file mode 100644 index 0000000000..7c1941db65 --- /dev/null +++ b/ui-ngx/src/app/shared/models/id/notification-rule-id.ts @@ -0,0 +1,26 @@ +/// +/// Copyright © 2016-2023 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 { EntityId } from '@shared/models/id/entity-id'; +import { EntityType } from '@shared/models/entity-type.models'; + +export class NotificationRuleId implements EntityId { + entityType = EntityType.NOTIFICATION_RULE; + id: string; + constructor(id: string) { + this.id = id; + } +} diff --git a/ui-ngx/src/app/shared/models/id/notification-target-id.ts b/ui-ngx/src/app/shared/models/id/notification-target-id.ts new file mode 100644 index 0000000000..ad0ac93f4f --- /dev/null +++ b/ui-ngx/src/app/shared/models/id/notification-target-id.ts @@ -0,0 +1,26 @@ +/// +/// Copyright © 2016-2023 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 { EntityId } from '@shared/models/id/entity-id'; +import { EntityType } from '@shared/models/entity-type.models'; + +export class NotificationTargetId implements EntityId { + entityType = EntityType.NOTIFICATION_TARGET; + id: string; + constructor(id: string) { + this.id = id; + } +} diff --git a/ui-ngx/src/app/shared/models/id/notification-template-id.ts b/ui-ngx/src/app/shared/models/id/notification-template-id.ts new file mode 100644 index 0000000000..be93db5d21 --- /dev/null +++ b/ui-ngx/src/app/shared/models/id/notification-template-id.ts @@ -0,0 +1,26 @@ +/// +/// Copyright © 2016-2023 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 { EntityId } from '@shared/models/id/entity-id'; +import { EntityType } from '@shared/models/entity-type.models'; + +export class NotificationTemplateId implements EntityId { + entityType = EntityType.NOTIFICATION_TEMPLATE; + id: string; + constructor(id: string) { + this.id = id; + } +} diff --git a/ui-ngx/src/app/shared/models/id/ota-package-id.ts b/ui-ngx/src/app/shared/models/id/ota-package-id.ts index 41b3f9b7bb..1bf241ee9a 100644 --- a/ui-ngx/src/app/shared/models/id/ota-package-id.ts +++ b/ui-ngx/src/app/shared/models/id/ota-package-id.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/id/public-api.ts b/ui-ngx/src/app/shared/models/id/public-api.ts index f30a5ffbcf..fd6e5854d8 100644 --- a/ui-ngx/src/app/shared/models/id/public-api.ts +++ b/ui-ngx/src/app/shared/models/id/public-api.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -26,6 +26,11 @@ export * from './entity-id'; export * from './entity-view-id'; export * from './event-id'; export * from './has-uuid'; +export * from './notification-id'; +export * from './notification-request-id'; +export * from './notification-rule-id'; +export * from './notification-target-id'; +export * from './notification-template-id'; export * from './ota-package-id'; export * from './rpc-id'; export * from './rule-chain-id'; diff --git a/ui-ngx/src/app/shared/models/id/queue-id.ts b/ui-ngx/src/app/shared/models/id/queue-id.ts index b36a12547b..743583e32e 100644 --- a/ui-ngx/src/app/shared/models/id/queue-id.ts +++ b/ui-ngx/src/app/shared/models/id/queue-id.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/id/rpc-id.ts b/ui-ngx/src/app/shared/models/id/rpc-id.ts index a3b9cfec91..7173237ac0 100644 --- a/ui-ngx/src/app/shared/models/id/rpc-id.ts +++ b/ui-ngx/src/app/shared/models/id/rpc-id.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/id/rule-chain-id.ts b/ui-ngx/src/app/shared/models/id/rule-chain-id.ts index ebcc47d415..27623bd15a 100644 --- a/ui-ngx/src/app/shared/models/id/rule-chain-id.ts +++ b/ui-ngx/src/app/shared/models/id/rule-chain-id.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/id/rule-node-id.ts b/ui-ngx/src/app/shared/models/id/rule-node-id.ts index 68ebfc2925..73d57f68f2 100644 --- a/ui-ngx/src/app/shared/models/id/rule-node-id.ts +++ b/ui-ngx/src/app/shared/models/id/rule-node-id.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/id/tb-resource-id.ts b/ui-ngx/src/app/shared/models/id/tb-resource-id.ts index f36aa36890..57376181e0 100644 --- a/ui-ngx/src/app/shared/models/id/tb-resource-id.ts +++ b/ui-ngx/src/app/shared/models/id/tb-resource-id.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/id/tenant-id.ts b/ui-ngx/src/app/shared/models/id/tenant-id.ts index 078dd0b399..59864eb9d9 100644 --- a/ui-ngx/src/app/shared/models/id/tenant-id.ts +++ b/ui-ngx/src/app/shared/models/id/tenant-id.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/id/tenant-profile-id.ts b/ui-ngx/src/app/shared/models/id/tenant-profile-id.ts index 0cef7d7d13..a829b4e82c 100644 --- a/ui-ngx/src/app/shared/models/id/tenant-profile-id.ts +++ b/ui-ngx/src/app/shared/models/id/tenant-profile-id.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/id/user-id.ts b/ui-ngx/src/app/shared/models/id/user-id.ts index 392378cd66..59c46be97f 100644 --- a/ui-ngx/src/app/shared/models/id/user-id.ts +++ b/ui-ngx/src/app/shared/models/id/user-id.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/id/widget-type-id.ts b/ui-ngx/src/app/shared/models/id/widget-type-id.ts index 38575843be..298ef70ae1 100644 --- a/ui-ngx/src/app/shared/models/id/widget-type-id.ts +++ b/ui-ngx/src/app/shared/models/id/widget-type-id.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/id/widgets-bundle-id.ts b/ui-ngx/src/app/shared/models/id/widgets-bundle-id.ts index 9b6f7cee44..fc16726820 100644 --- a/ui-ngx/src/app/shared/models/id/widgets-bundle-id.ts +++ b/ui-ngx/src/app/shared/models/id/widgets-bundle-id.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/login.models.ts b/ui-ngx/src/app/shared/models/login.models.ts index d3f1598fc5..0da429f239 100644 --- a/ui-ngx/src/app/shared/models/login.models.ts +++ b/ui-ngx/src/app/shared/models/login.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/lwm2m-security-config.models.ts b/ui-ngx/src/app/shared/models/lwm2m-security-config.models.ts index 9c6b5cce6a..26cff38c28 100644 --- a/ui-ngx/src/app/shared/models/lwm2m-security-config.models.ts +++ b/ui-ngx/src/app/shared/models/lwm2m-security-config.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/material.models.ts b/ui-ngx/src/app/shared/models/material.models.ts index 5defa7d044..ea75978f26 100644 --- a/ui-ngx/src/app/shared/models/material.models.ts +++ b/ui-ngx/src/app/shared/models/material.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/notification.models.ts b/ui-ngx/src/app/shared/models/notification.models.ts new file mode 100644 index 0000000000..0969af78d4 --- /dev/null +++ b/ui-ngx/src/app/shared/models/notification.models.ts @@ -0,0 +1,567 @@ +/// +/// Copyright © 2016-2023 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 { NotificationId } from '@shared/models/id/notification-id'; +import { NotificationRequestId } from '@shared/models/id/notification-request-id'; +import { UserId } from '@shared/models/id/user-id'; +import { BaseData } from '@shared/models/base-data'; +import { TenantId } from '@shared/models/id/tenant-id'; +import { NotificationTargetId } from '@shared/models/id/notification-target-id'; +import { NotificationTemplateId } from '@shared/models/id/notification-template-id'; +import { EntityId } from '@shared/models/id/entity-id'; +import { NotificationRuleId } from '@shared/models/id/notification-rule-id'; +import { AlarmSearchStatus, AlarmSeverity, AlarmStatus } from '@shared/models/alarm.models'; +import { EntityType } from '@shared/models/entity-type.models'; +import { User } from '@shared/models/user.model'; +import { ApiFeature, ApiUsageStateValue } from '@shared/models/api-usage.models'; + +export interface Notification { + readonly id: NotificationId; + readonly requestId: NotificationRequestId; + readonly recipientId: UserId; + readonly type: NotificationType; + readonly subject: string; + readonly text: string; + readonly info: NotificationInfo; + readonly status: NotificationStatus; + readonly createdTime: number; + readonly additionalConfig?: WebDeliveryMethodAdditionalConfig; +} + +export interface NotificationInfo { + description: string; + type: string; + alarmSeverity?: AlarmSeverity; + alarmStatus?: AlarmStatus; + alarmType?: string; + stateEntityId?: EntityId; +} + +export interface NotificationRequest extends Omit, 'label'> { + tenantId?: TenantId; + targets: Array; + templateId?: NotificationTemplateId; + template?: NotificationTemplate; + info?: NotificationInfo; + deliveryMethods: Array; + originatorEntityId: EntityId; + status: NotificationRequestStatus; + stats: NotificationRequestStats; + additionalConfig: NotificationRequestConfig; +} + +export interface NotificationRequestInfo extends NotificationRequest { + templateName: string; + deliveryMethods: NotificationDeliveryMethod[]; +} + +export interface NotificationRequestPreview { + totalRecipientsCount: number; + recipientsCountByTarget: { [key in string]: number }; + processedTemplates: { [key in NotificationDeliveryMethod]: DeliveryMethodNotificationTemplate }; + recipientsPreview: Array; +} + +export interface NotificationRequestStats { + sent: Map; + errors: { [key in NotificationDeliveryMethod]: {[errorKey in string]: string}}; + processedRecipients: Map>; +} + +export interface NotificationRequestConfig { + sendingDelayInSec: number; +} + +export interface NotificationSettings { + deliveryMethodsConfigs: { [key in NotificationDeliveryMethod]: NotificationDeliveryMethodConfig }; +} + +export interface NotificationDeliveryMethodConfig extends Partial{ + enabled: boolean; + method: NotificationDeliveryMethod; +} + +interface SlackNotificationDeliveryMethodConfig { + botToken: string; +} + +export interface SlackConversation { + id: string; + title: string; + shortName: string; + wholeName: string; + email: string; +} + +export interface NotificationRule extends Omit, 'label'>{ + tenantId: TenantId; + templateId: NotificationTemplateId; + triggerType: TriggerType; + triggerConfig: NotificationRuleTriggerConfig; + recipientsConfig: NotificationRuleRecipientConfig; + additionalConfig: {description: string}; +} + +export type NotificationRuleTriggerConfig = Partial; + +export interface AlarmNotificationRuleTriggerConfig { + alarmTypes?: Array; + alarmSeverities?: Array; + notifyOn: Array; + clearRule?: ClearRule; +} + +interface ClearRule { + alarmStatuses: Array; +} + +export interface DeviceInactivityNotificationRuleTriggerConfig { + devices?: Array; + deviceProfiles?: Array; +} + +export interface EntityActionNotificationRuleTriggerConfig { + entityTypes: EntityType[]; + created: boolean; + updated: boolean; + deleted: boolean; +} + +export interface AlarmCommentNotificationRuleTriggerConfig { + alarmTypes?: Array; + alarmSeverities?: Array; + alarmStatuses?: Array; + onlyUserComments?: boolean; + notifyOnCommentUpdate?: boolean; +} + +export interface AlarmAssignmentNotificationRuleTriggerConfig { + alarmTypes?: Array; + alarmSeverities?: Array; + alarmStatuses?: Array; + notifyOn: Array; +} + +export interface RuleEngineLifecycleEventNotificationRuleTriggerConfig { + ruleChains?: Array; + ruleChainEvents?: Array; + onlyRuleChainLifecycleFailures: boolean; + trackRuleNodeEvents: boolean; + ruleNodeEvents: Array; + onlyRuleNodeLifecycleFailures: ComponentLifecycleEvent; +} + +export interface EntitiesLimitNotificationRuleTriggerConfig { + entityTypes: EntityType[]; + threshold: number; +} + +export interface ApiUsageLimitNotificationRuleTriggerConfig { + apiFeatures: ApiFeature[]; + notifyOn: ApiUsageStateValue[]; +} + +export enum ComponentLifecycleEvent { + STARTED = 'STARTED', + UPDATED = 'UPDATED', + STOPPED = 'STOPPED' +} + +export const ComponentLifecycleEventTranslationMap = new Map([ + [ComponentLifecycleEvent.STARTED, 'event.started'], + [ComponentLifecycleEvent.UPDATED, 'event.updated'], + [ComponentLifecycleEvent.STOPPED, 'event.stopped'] +]); + +export enum AlarmAction { + CREATED = 'CREATED', + SEVERITY_CHANGED = 'SEVERITY_CHANGED', + ACKNOWLEDGED = 'ACKNOWLEDGED', + CLEARED = 'CLEARED' +} + +export const AlarmActionTranslationMap = new Map([ + [AlarmAction.CREATED, 'notification.notify-alarm-action.created'], + [AlarmAction.SEVERITY_CHANGED, 'notification.notify-alarm-action.severity-changed'], + [AlarmAction.ACKNOWLEDGED, 'notification.notify-alarm-action.acknowledged'], + [AlarmAction.CLEARED, 'notification.notify-alarm-action.cleared'] +]); + +export enum AlarmAssignmentAction { + ASSIGNED = 'ASSIGNED', + UNASSIGNED = 'UNASSIGNED' +} + +export const AlarmAssignmentActionTranslationMap = new Map([ + [AlarmAssignmentAction.ASSIGNED, 'notification.notify-alarm-action.assigned'], + [AlarmAssignmentAction.UNASSIGNED, 'notification.notify-alarm-action.unassigned'] +]); + +export enum DeviceEvent { + ACTIVE = 'ACTIVE', + INACTIVE = 'INACTIVE' +} + +export const DeviceEventTranslationMap = new Map([ + [DeviceEvent.ACTIVE, 'notification.active'], + [DeviceEvent.INACTIVE, 'notification.inactive'] +]); + +export interface NotificationRuleRecipientConfig { + targets?: Array; + escalationTable?: {[key: number]: Array}; +} + +export interface NonConfirmedNotificationEscalation { + delayInSec: number; + targets: Array; +} + +export interface NotificationTarget extends Omit, 'label'>{ + tenantId: TenantId; + configuration: NotificationTargetConfig; +} + +export interface NotificationTargetConfig extends Partial { + description?: string; + type: NotificationTargetType; +} +export interface PlatformUsersNotificationTargetConfig { + usersFilter: UsersFilter; +} + +export interface UsersFilter extends + Partial{ + type: NotificationTargetConfigType; +} + +interface UserListFilter { + usersIds: Array; +} + +interface CustomerUsersFilter { + customerId: string; +} + +interface TenantAdministratorsFilter { + tenantsIds?: Array; + tenantProfilesIds?: Array; +} + +export interface SlackNotificationTargetConfig { + conversationType: SlackChanelType; + conversation: SlackConversation; +} +export enum NotificationTargetType { + PLATFORM_USERS = 'PLATFORM_USERS', + SLACK = 'SLACK' +} + +export const NotificationTargetTypeTranslationMap = new Map([ + [NotificationTargetType.PLATFORM_USERS, 'notification.platform-users'], + [NotificationTargetType.SLACK, 'notification.delivery-method.slack'] +]); + +export interface NotificationTemplate extends Omit, 'label'>{ + tenantId: TenantId; + notificationType: NotificationType; + configuration: NotificationTemplateConfig; +} + +interface NotificationTemplateConfig { + deliveryMethodsTemplates: { + [key in NotificationDeliveryMethod]: DeliveryMethodNotificationTemplate + }; +} + +export interface DeliveryMethodNotificationTemplate extends + Partial{ + body?: string; + enabled: boolean; + method: NotificationDeliveryMethod; +} + +interface WebDeliveryMethodNotificationTemplate { + subject?: string; + additionalConfig: WebDeliveryMethodAdditionalConfig; +} + +interface WebDeliveryMethodAdditionalConfig { + icon: { + enabled: boolean; + icon: string; + color: string; + }; + actionButtonConfig: { + enabled: boolean; + text: string; + linkType: ActionButtonLinkType; + link?: string; + dashboardId?: string; + dashboardState?: string; + setEntityIdInState?: boolean; + }; +} + +interface EmailDeliveryMethodNotificationTemplate { + subject: string; +} + +interface SlackDeliveryMethodNotificationTemplate { + conversationType: SlackChanelType; + conversationId: string; +} + +export enum NotificationStatus { + SENT = 'SENT', + READ = 'READ' +} + +export enum NotificationDeliveryMethod { + WEB = 'WEB', + SMS = 'SMS', + EMAIL = 'EMAIL', + SLACK = 'SLACK' +} + +export const NotificationDeliveryMethodTranslateMap = new Map([ + [NotificationDeliveryMethod.WEB, 'notification.delivery-method.web'], + [NotificationDeliveryMethod.SMS, 'notification.delivery-method.sms'], + [NotificationDeliveryMethod.EMAIL, 'notification.delivery-method.email'], + [NotificationDeliveryMethod.SLACK, 'notification.delivery-method.slack'] +]); + +export enum NotificationRequestStatus { + PROCESSING = 'PROCESSING', + SCHEDULED = 'SCHEDULED', + SENT = 'SENT' +} + +export const NotificationRequestStatusTranslateMap = new Map([ + [NotificationRequestStatus.PROCESSING, 'notification.request-status.processing'], + [NotificationRequestStatus.SCHEDULED, 'notification.request-status.scheduled'], + [NotificationRequestStatus.SENT, 'notification.request-status.sent'] +]); + +export enum SlackChanelType { + PUBLIC_CHANNEL = 'PUBLIC_CHANNEL', + PRIVATE_CHANNEL = 'PRIVATE_CHANNEL', + DIRECT= 'DIRECT' +} + +export const SlackChanelTypesTranslateMap = new Map([ + [SlackChanelType.DIRECT, 'notification.slack-chanel-types.direct'], + [SlackChanelType.PUBLIC_CHANNEL, 'notification.slack-chanel-types.public-channel'], + [SlackChanelType.PRIVATE_CHANNEL, 'notification.slack-chanel-types.private-channel'] +]); + +export enum NotificationTargetConfigType { + ALL_USERS = 'ALL_USERS', + TENANT_ADMINISTRATORS = 'TENANT_ADMINISTRATORS', + CUSTOMER_USERS = 'CUSTOMER_USERS', + USER_LIST = 'USER_LIST', + ORIGINATOR_ENTITY_OWNER_USERS = 'ORIGINATOR_ENTITY_OWNER_USERS', + AFFECTED_USER = 'AFFECTED_USER', + SYSTEM_ADMINISTRATORS = 'SYSTEM_ADMINISTRATORS', + AFFECTED_TENANT_ADMINISTRATORS = 'AFFECTED_TENANT_ADMINISTRATORS' +} + +export interface NotificationTargetConfigTypeInfo { + name: string; + hint?: string; +} + +export const NotificationTargetConfigTypeInfoMap = new Map([ + [NotificationTargetConfigType.ALL_USERS, + { + name: 'notification.recipient-type.all-users' + } + ], + [NotificationTargetConfigType.TENANT_ADMINISTRATORS, + { + name: 'notification.recipient-type.tenant-administrators' + } + ], + [NotificationTargetConfigType.CUSTOMER_USERS, + { + name: 'notification.recipient-type.customer-users' + } + ], + [NotificationTargetConfigType.USER_LIST, + { + name: 'notification.recipient-type.user-list' + } + ], + [NotificationTargetConfigType.ORIGINATOR_ENTITY_OWNER_USERS, + { + name: 'notification.recipient-type.users-entity-owner', + hint: 'notification.recipient-type.users-entity-owner-hint' + } + ], + [NotificationTargetConfigType.AFFECTED_USER, + { + name: 'notification.recipient-type.affected-user', + hint: 'notification.recipient-type.affected-user-hint' + } + ], + [NotificationTargetConfigType.SYSTEM_ADMINISTRATORS, + { + name: 'notification.recipient-type.system-administrators' + } + ], + [NotificationTargetConfigType.AFFECTED_TENANT_ADMINISTRATORS, + { + name: 'notification.recipient-type.affected-tenant-administrators' + } + ] +]); + +export enum NotificationType { + GENERAL = 'GENERAL', + ALARM = 'ALARM', + DEVICE_ACTIVITY = 'DEVICE_ACTIVITY', + ENTITY_ACTION = 'ENTITY_ACTION', + ALARM_COMMENT = 'ALARM_COMMENT', + ALARM_ASSIGNMENT = 'ALARM_ASSIGNMENT', + RULE_ENGINE_COMPONENT_LIFECYCLE_EVENT = 'RULE_ENGINE_COMPONENT_LIFECYCLE_EVENT', + ENTITIES_LIMIT = 'ENTITIES_LIMIT', + API_USAGE_LIMIT = 'API_USAGE_LIMIT', + RULE_ENGINE = 'RULE_ENGINE' +} + +export const NotificationTypeIcons = new Map([ + [NotificationType.ALARM, 'warning'], + [NotificationType.DEVICE_ACTIVITY, 'phonelink_off'], + [NotificationType.ENTITY_ACTION, 'devices'], + [NotificationType.ALARM_COMMENT, 'comment'], + [NotificationType.ALARM_ASSIGNMENT, 'assignment_turned_in'], + [NotificationType.RULE_ENGINE_COMPONENT_LIFECYCLE_EVENT, 'settings_ethernet'], + [NotificationType.ENTITIES_LIMIT, 'data_thresholding'], + [NotificationType.API_USAGE_LIMIT, 'insert_chart'], +]); + +export const AlarmSeverityNotificationColors = new Map( + [ + [AlarmSeverity.CRITICAL, '#D12730'], + [AlarmSeverity.MAJOR, '#FEAC0C'], + [AlarmSeverity.MINOR, '#F2DA05'], + [AlarmSeverity.WARNING, '#F66716'], + [AlarmSeverity.INDETERMINATE, '#00000061'] + ] +); + +export enum ActionButtonLinkType { + LINK = 'LINK', + DASHBOARD = 'DASHBOARD' +} + +export const ActionButtonLinkTypeTranslateMap = new Map([ + [ActionButtonLinkType.LINK, 'notification.link-type.link'], + [ActionButtonLinkType.DASHBOARD, 'notification.link-type.dashboard'] +]); + +export interface NotificationTemplateTypeTranslate { + name: string; + helpId?: string; +} + +export const NotificationTemplateTypeTranslateMap = new Map([ + [NotificationType.GENERAL, + { + name: 'notification.template-type.general', + helpId: 'notification/general' + } + ], + [NotificationType.ALARM, + { + name: 'notification.template-type.alarm', + helpId: 'notification/alarm' + } + ], + [NotificationType.DEVICE_ACTIVITY, + { + name: 'notification.template-type.device-activity', + helpId: 'notification/device_activity' + } + ], + [NotificationType.ENTITY_ACTION, + { + name: 'notification.template-type.entity-action', + helpId: 'notification/entity_action' + } + ], + [NotificationType.ALARM_COMMENT, + { + name: 'notification.template-type.alarm-comment', + helpId: 'notification/alarm_comment' + } + ], + [NotificationType.ALARM_ASSIGNMENT, + { + name: 'notification.template-type.alarm-assignment', + helpId: 'notification/alarm_assignment' + } + ], + [NotificationType.RULE_ENGINE_COMPONENT_LIFECYCLE_EVENT, + { + name: 'notification.template-type.rule-engine-lifecycle-event', + helpId: 'notification/rule_engine_lifecycle_event' + } + ], + [NotificationType.ENTITIES_LIMIT, + { + name: 'notification.template-type.entities-limit', + helpId: 'notification/entities_limit' + } + ], + [NotificationType.API_USAGE_LIMIT, + { + name: 'notification.template-type.api-usage-limit', + helpId: 'notification/api_usage_limit' + } + ], + [NotificationType.RULE_ENGINE, + { + name: 'notification.template-type.rule-engine', + helpId: 'notification/rule_engine' + } + ] +]); + +export enum TriggerType { + ALARM = 'ALARM', + DEVICE_ACTIVITY = 'DEVICE_ACTIVITY', + ENTITY_ACTION = 'ENTITY_ACTION', + ALARM_COMMENT = 'ALARM_COMMENT', + ALARM_ASSIGNMENT = 'ALARM_ASSIGNMENT', + RULE_ENGINE_COMPONENT_LIFECYCLE_EVENT = 'RULE_ENGINE_COMPONENT_LIFECYCLE_EVENT', + ENTITIES_LIMIT = 'ENTITIES_LIMIT', + API_USAGE_LIMIT = 'API_USAGE_LIMIT' +} + +export const TriggerTypeTranslationMap = new Map([ + [TriggerType.ALARM, 'notification.trigger.alarm'], + [TriggerType.DEVICE_ACTIVITY, 'notification.trigger.device-activity'], + [TriggerType.ENTITY_ACTION, 'notification.trigger.entity-action'], + [TriggerType.ALARM_COMMENT, 'notification.trigger.alarm-comment'], + [TriggerType.ALARM_ASSIGNMENT, 'notification.trigger.alarm-assignment'], + [TriggerType.RULE_ENGINE_COMPONENT_LIFECYCLE_EVENT, 'notification.trigger.rule-engine-lifecycle-event'], + [TriggerType.ENTITIES_LIMIT, 'notification.trigger.entities-limit'], + [TriggerType.API_USAGE_LIMIT, 'notification.trigger.api-usage-limit'], +]); diff --git a/ui-ngx/src/app/shared/models/oauth2.models.ts b/ui-ngx/src/app/shared/models/oauth2.models.ts index b774546547..483a9553ab 100644 --- a/ui-ngx/src/app/shared/models/oauth2.models.ts +++ b/ui-ngx/src/app/shared/models/oauth2.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/ota-package.models.ts b/ui-ngx/src/app/shared/models/ota-package.models.ts index 34963be66b..3af7b9ae30 100644 --- a/ui-ngx/src/app/shared/models/ota-package.models.ts +++ b/ui-ngx/src/app/shared/models/ota-package.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/page/page-data.ts b/ui-ngx/src/app/shared/models/page/page-data.ts index bc42f93d79..6c144325ee 100644 --- a/ui-ngx/src/app/shared/models/page/page-data.ts +++ b/ui-ngx/src/app/shared/models/page/page-data.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/page/page-link.ts b/ui-ngx/src/app/shared/models/page/page-link.ts index 683b83298c..e55ac494e0 100644 --- a/ui-ngx/src/app/shared/models/page/page-link.ts +++ b/ui-ngx/src/app/shared/models/page/page-link.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/page/public-api.ts b/ui-ngx/src/app/shared/models/page/public-api.ts index eefdd67f71..3bf44ef8e7 100644 --- a/ui-ngx/src/app/shared/models/page/public-api.ts +++ b/ui-ngx/src/app/shared/models/page/public-api.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/page/sort-order.ts b/ui-ngx/src/app/shared/models/page/sort-order.ts index 253d3b313b..f21840c6eb 100644 --- a/ui-ngx/src/app/shared/models/page/sort-order.ts +++ b/ui-ngx/src/app/shared/models/page/sort-order.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/public-api.ts b/ui-ngx/src/app/shared/models/public-api.ts index 8c97669481..1629c656da 100644 --- a/ui-ngx/src/app/shared/models/public-api.ts +++ b/ui-ngx/src/app/shared/models/public-api.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -20,6 +20,7 @@ export * from './telemetry/telemetry.models'; export * from './time/time.models'; export * from './alarm.models'; export * from './alias.models'; +export * from './api-usage.models'; export * from './asset.models'; export * from './audit-log.models'; export * from './authority.enum'; @@ -38,6 +39,9 @@ export * from './error.models'; export * from './event.models'; export * from './login.models'; export * from './material.models'; +export * from './notification.models'; +export * from './websocket/notification-ws.models'; +export * from './websocket/websocket.models'; export * from './oauth2.models'; export * from './queue.models'; export * from './relation.models'; @@ -48,6 +52,7 @@ export * from './rule-node.models'; export * from './settings.models'; export * from './tenant.model'; export * from './user.model'; +export * from './user-settings.models'; export * from './widget.models'; export * from './widgets-bundle.model'; export * from './window-message.model'; diff --git a/ui-ngx/src/app/shared/models/query/query.models.ts b/ui-ngx/src/app/shared/models/query/query.models.ts index c1bf255cd3..9c50afecf4 100644 --- a/ui-ngx/src/app/shared/models/query/query.models.ts +++ b/ui-ngx/src/app/shared/models/query/query.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/queue.models.ts b/ui-ngx/src/app/shared/models/queue.models.ts index 5814a6eeb9..76ee0bf022 100644 --- a/ui-ngx/src/app/shared/models/queue.models.ts +++ b/ui-ngx/src/app/shared/models/queue.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/relation.models.ts b/ui-ngx/src/app/shared/models/relation.models.ts index e42b915e9e..c51c60664c 100644 --- a/ui-ngx/src/app/shared/models/relation.models.ts +++ b/ui-ngx/src/app/shared/models/relation.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/resource.models.ts b/ui-ngx/src/app/shared/models/resource.models.ts index 778cfb57a4..871bf147ea 100644 --- a/ui-ngx/src/app/shared/models/resource.models.ts +++ b/ui-ngx/src/app/shared/models/resource.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/rpc.models.ts b/ui-ngx/src/app/shared/models/rpc.models.ts index b0c97062ae..cbdda5902a 100644 --- a/ui-ngx/src/app/shared/models/rpc.models.ts +++ b/ui-ngx/src/app/shared/models/rpc.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/rule-chain.models.ts b/ui-ngx/src/app/shared/models/rule-chain.models.ts index 01f9d268c3..20086ca21b 100644 --- a/ui-ngx/src/app/shared/models/rule-chain.models.ts +++ b/ui-ngx/src/app/shared/models/rule-chain.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/rule-node.models.ts b/ui-ngx/src/app/shared/models/rule-node.models.ts index 0d224c52e5..9d78a5ced8 100644 --- a/ui-ngx/src/app/shared/models/rule-node.models.ts +++ b/ui-ngx/src/app/shared/models/rule-node.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -24,7 +24,7 @@ import { PageComponent } from '@shared/components/page.component'; import { AfterViewInit, EventEmitter, Inject, OnInit, Directive } from '@angular/core'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; -import { AbstractControl, FormGroup } from '@angular/forms'; +import { AbstractControl, UntypedFormGroup } from '@angular/forms'; import { RuleChainType } from '@shared/models/rule-chain.models'; export interface RuleNodeConfiguration { @@ -77,7 +77,7 @@ export interface IRuleNodeConfigurationComponent { } @Directive() -// tslint:disable-next-line:directive-class-suffix +// eslint-disable-next-line @angular-eslint/directive-class-suffix export abstract class RuleNodeConfigurationComponent extends PageComponent implements IRuleNodeConfigurationComponent, OnInit, AfterViewInit { @@ -179,7 +179,7 @@ export abstract class RuleNodeConfigurationComponent extends PageComponent imple protected onValidate() {} - protected abstract configForm(): FormGroup; + protected abstract configForm(): UntypedFormGroup; protected abstract onConfigurationSet(configuration: RuleNodeConfiguration); @@ -422,6 +422,9 @@ const ruleNodeClazzHelpLinkMap = { 'org.thingsboard.rule.engine.geo.TbGpsGeofencingFilterNode': 'ruleNodeGpsGeofencingFilter', 'org.thingsboard.rule.engine.filter.TbJsFilterNode': 'ruleNodeJsFilter', 'org.thingsboard.rule.engine.filter.TbJsSwitchNode': 'ruleNodeJsSwitch', + 'org.thingsboard.rule.engine.filter.TbAssetTypeSwitchNode': 'ruleNodeAssetProfileSwitch', + 'org.thingsboard.rule.engine.filter.TbDeviceTypeSwitchNode': 'ruleNodeDeviceProfileSwitch', + 'org.thingsboard.rule.engine.filter.TbCheckAlarmStatusNode': 'ruleNodeCheckAlarmStatus', 'org.thingsboard.rule.engine.filter.TbMsgTypeFilterNode': 'ruleNodeMessageTypeFilter', 'org.thingsboard.rule.engine.filter.TbMsgTypeSwitchNode': 'ruleNodeMessageTypeSwitch', 'org.thingsboard.rule.engine.filter.TbOriginatorTypeFilterNode': 'ruleNodeOriginatorTypeFilter', diff --git a/ui-ngx/src/app/shared/models/settings.models.ts b/ui-ngx/src/app/shared/models/settings.models.ts index 256c13cbf5..a673e0cacc 100644 --- a/ui-ngx/src/app/shared/models/settings.models.ts +++ b/ui-ngx/src/app/shared/models/settings.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts b/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts index 1cd44652a9..44af6afc30 100644 --- a/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts +++ b/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -17,7 +17,7 @@ import { EntityType } from '@shared/models/entity-type.models'; import { AggregationType } from '../time/time.models'; -import { Observable, ReplaySubject, Subject } from 'rxjs'; +import { Observable, ReplaySubject } from 'rxjs'; import { EntityId } from '@shared/models/id/entity-id'; import { map } from 'rxjs/operators'; import { NgZone } from '@angular/core'; @@ -33,6 +33,8 @@ import { PageData } from '@shared/models/page/page-data'; import { alarmFields } from '@shared/models/alarm.models'; import { entityFields } from '@shared/models/entity.models'; import { isUndefined } from '@core/utils'; +import { CmdWrapper, WsSubscriber } from '@shared/models/websocket/websocket.models'; +import { TelemetryWebsocketService } from '@core/ws/telemetry-websocket.service'; export enum DataKeyType { timeseries = 'timeseries', @@ -60,13 +62,13 @@ export enum TelemetryFeature { export type TelemetryType = LatestTelemetry | AttributeScope; -export function toTelemetryType(val: string): TelemetryType { +export const toTelemetryType = (val: string): TelemetryType => { if (LatestTelemetry[val]) { return LatestTelemetry[val]; } else { return AttributeScope[val]; } -} +}; export const telemetryTypeTranslations = new Map( [ @@ -234,7 +236,7 @@ export class AlarmDataUnsubscribeCmd implements WebsocketCmd { cmdId: number; } -export class TelemetryPluginCmdsWrapper { +export class TelemetryPluginCmdsWrapper implements CmdWrapper { constructor() { this.attrSubCmds = []; @@ -335,7 +337,9 @@ export interface SubscriptionUpdateMsg extends SubscriptionDataHolder { export enum CmdUpdateType { ENTITY_DATA = 'ENTITY_DATA', ALARM_DATA = 'ALARM_DATA', - COUNT_DATA = 'COUNT_DATA' + COUNT_DATA = 'COUNT_DATA', + NOTIFICATIONS_COUNT = 'NOTIFICATIONS_COUNT', + NOTIFICATIONS = 'NOTIFICATIONS' } export interface CmdUpdateMsg { @@ -367,20 +371,20 @@ export interface EntityCountUpdateMsg extends CmdUpdateMsg { export type WebsocketDataMsg = AlarmDataUpdateMsg | EntityDataUpdateMsg | EntityCountUpdateMsg | SubscriptionUpdateMsg; -export function isEntityDataUpdateMsg(message: WebsocketDataMsg): message is EntityDataUpdateMsg { +export const isEntityDataUpdateMsg = (message: WebsocketDataMsg): message is EntityDataUpdateMsg => { const updateMsg = (message as CmdUpdateMsg); return updateMsg.cmdId !== undefined && updateMsg.cmdUpdateType === CmdUpdateType.ENTITY_DATA; -} +}; -export function isAlarmDataUpdateMsg(message: WebsocketDataMsg): message is AlarmDataUpdateMsg { +export const isAlarmDataUpdateMsg = (message: WebsocketDataMsg): message is AlarmDataUpdateMsg => { const updateMsg = (message as CmdUpdateMsg); return updateMsg.cmdId !== undefined && updateMsg.cmdUpdateType === CmdUpdateType.ALARM_DATA; -} +}; -export function isEntityCountUpdateMsg(message: WebsocketDataMsg): message is EntityCountUpdateMsg { +export const isEntityCountUpdateMsg = (message: WebsocketDataMsg): message is EntityCountUpdateMsg => { const updateMsg = (message as CmdUpdateMsg); return updateMsg.cmdId !== undefined && updateMsg.cmdUpdateType === CmdUpdateType.COUNT_DATA; -} +}; export class SubscriptionUpdate implements SubscriptionUpdateMsg { subscriptionId: number; @@ -562,31 +566,20 @@ export class EntityCountUpdate extends CmdUpdate { } } -export interface TelemetryService { - subscribe(subscriber: TelemetrySubscriber); - update(subscriber: TelemetrySubscriber); - unsubscribe(subscriber: TelemetrySubscriber); -} - -export class TelemetrySubscriber { +export class TelemetrySubscriber extends WsSubscriber { private dataSubject = new ReplaySubject(1); private entityDataSubject = new ReplaySubject(1); private alarmDataSubject = new ReplaySubject(1); private entityCountSubject = new ReplaySubject(1); - private reconnectSubject = new Subject(); - private tsOffset = undefined; - public subscriptionCommands: Array; - public data$ = this.dataSubject.asObservable(); public entityData$ = this.entityDataSubject.asObservable(); public alarmData$ = this.alarmDataSubject.asObservable(); public entityCount$ = this.entityCountSubject.asObservable(); - public reconnect$ = this.reconnectSubject.asObservable(); - public static createEntityAttributesSubscription(telemetryService: TelemetryService, + public static createEntityAttributesSubscription(telemetryService: TelemetryWebsocketService, entityId: EntityId, attributeScope: TelemetryType, zone: NgZone, keys: string[] = null): TelemetrySubscriber { let subscriptionCommand: SubscriptionCmd; @@ -606,21 +599,8 @@ export class TelemetrySubscriber { return subscriber; } - constructor(private telemetryService: TelemetryService, private zone?: NgZone) { - this.subscriptionCommands = []; - } - - public subscribe() { - this.telemetryService.subscribe(this); - } - - public update() { - this.telemetryService.update(this); - } - - public unsubscribe() { - this.telemetryService.unsubscribe(this); - this.complete(); + constructor(private telemetryService: TelemetryWebsocketService, protected zone?: NgZone) { + super(telemetryService, zone); } public complete() { @@ -628,7 +608,7 @@ export class TelemetrySubscriber { this.entityDataSubject.complete(); this.alarmDataSubject.complete(); this.entityCountSubject.complete(); - this.reconnectSubject.complete(); + super.complete(); } public setTsOffset(tsOffset: number): boolean { @@ -705,10 +685,6 @@ export class TelemetrySubscriber { } } - public onReconnected() { - this.reconnectSubject.next(); - } - public attributeData$(): Observable> { const attributeData = new Array(); return this.data$.pipe( diff --git a/ui-ngx/src/app/shared/models/tenant.model.ts b/ui-ngx/src/app/shared/models/tenant.model.ts index 27b86583ea..95e0314a52 100644 --- a/ui-ngx/src/app/shared/models/tenant.model.ts +++ b/ui-ngx/src/app/shared/models/tenant.model.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -43,6 +43,7 @@ export interface DefaultTenantProfileConfiguration { tenantEntityExportRateLimit?: string; tenantEntityImportRateLimit?: string; + tenantNotificationRequestsRateLimit?: string; maxTransportMessages: number; maxTransportDataPoints: number; diff --git a/ui-ngx/src/app/shared/models/time/time.models.ts b/ui-ngx/src/app/shared/models/time/time.models.ts index d657d5cda4..ae0cfe0b34 100644 --- a/ui-ngx/src/app/shared/models/time/time.models.ts +++ b/ui-ngx/src/app/shared/models/time/time.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -140,6 +140,8 @@ export enum QuickTimeInterval { PREVIOUS_WEEK = 'PREVIOUS_WEEK', PREVIOUS_WEEK_ISO = 'PREVIOUS_WEEK_ISO', PREVIOUS_MONTH = 'PREVIOUS_MONTH', + PREVIOUS_QUARTER = 'PREVIOUS_QUARTER', + PREVIOUS_HALF_YEAR = 'PREVIOUS_HALF_YEAR', PREVIOUS_YEAR = 'PREVIOUS_YEAR', CURRENT_HOUR = 'CURRENT_HOUR', CURRENT_DAY = 'CURRENT_DAY', @@ -150,6 +152,10 @@ export enum QuickTimeInterval { CURRENT_WEEK_ISO_SO_FAR = 'CURRENT_WEEK_ISO_SO_FAR', CURRENT_MONTH = 'CURRENT_MONTH', CURRENT_MONTH_SO_FAR = 'CURRENT_MONTH_SO_FAR', + CURRENT_QUARTER = 'CURRENT_QUARTER', + CURRENT_QUARTER_SO_FAR = 'CURRENT_QUARTER_SO_FAR', + CURRENT_HALF_YEAR = 'CURRENT_HALF_YEAR', + CURRENT_HALF_YEAR_SO_FAR = 'CURRENT_HALF_YEAR_SO_FAR', CURRENT_YEAR = 'CURRENT_YEAR', CURRENT_YEAR_SO_FAR = 'CURRENT_YEAR_SO_FAR' } @@ -161,6 +167,8 @@ export const QuickTimeIntervalTranslationMap = new Map { tenantId: TenantId; customerId: CustomerId; email: string; + phone: string; authority: Authority; firstName: string; lastName: string; @@ -54,3 +55,10 @@ export interface AuthUser { isPublic: boolean; authority: Authority; } + +export interface UserEmailInfo { + id: UserId; + email: string; + firstName: string; + lastName: string; +} diff --git a/ui-ngx/src/app/shared/models/vc.models.ts b/ui-ngx/src/app/shared/models/vc.models.ts index d92dd26f17..155a9c8633 100644 --- a/ui-ngx/src/app/shared/models/vc.models.ts +++ b/ui-ngx/src/app/shared/models/vc.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/websocket/notification-ws.models.ts b/ui-ngx/src/app/shared/models/websocket/notification-ws.models.ts new file mode 100644 index 0000000000..0acbba1139 --- /dev/null +++ b/ui-ngx/src/app/shared/models/websocket/notification-ws.models.ts @@ -0,0 +1,240 @@ +/// +/// Copyright © 2016-2023 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 { BehaviorSubject, ReplaySubject } from 'rxjs'; +import { CmdUpdate, CmdUpdateMsg, CmdUpdateType, WebsocketCmd } from '@shared/models/telemetry/telemetry.models'; +import { map } from 'rxjs/operators'; +import { NgZone } from '@angular/core'; +import { isDefinedAndNotNull } from '@core/utils'; +import { Notification } from '@shared/models/notification.models'; +import { CmdWrapper, WsSubscriber } from '@shared/models/websocket/websocket.models'; +import { NotificationWebsocketService } from '@core/ws/notification-websocket.service'; + +export class NotificationCountUpdate extends CmdUpdate { + totalUnreadCount: number; + + constructor(msg: NotificationCountUpdateMsg) { + super(msg); + this.totalUnreadCount = msg.totalUnreadCount; + } +} + +export class NotificationsUpdate extends CmdUpdate { + totalUnreadCount: number; + update?: Notification; + notifications?: Notification[]; + + constructor(msg: NotificationsUpdateMsg) { + super(msg); + this.totalUnreadCount = msg.totalUnreadCount; + this.update = msg.update; + this.notifications = msg.notifications; + } +} + +export class NotificationSubscriber extends WsSubscriber { + private notificationCountSubject = new ReplaySubject(1); + private notificationsSubject = new BehaviorSubject({ + cmdId: 0, + cmdUpdateType: undefined, + errorCode: 0, + errorMsg: '', + notifications: [], + totalUnreadCount: 0 + }); + + public messageLimit = 10; + + public notificationCount$ = this.notificationCountSubject.asObservable().pipe(map(msg => msg.totalUnreadCount)); + public notifications$ = this.notificationsSubject.asObservable().pipe(map(msg => msg.notifications )); + + public static createNotificationCountSubscription(notificationWsService: NotificationWebsocketService, + zone: NgZone): NotificationSubscriber { + const subscriptionCommand = new UnreadCountSubCmd(); + const subscriber = new NotificationSubscriber(notificationWsService, zone); + subscriber.subscriptionCommands.push(subscriptionCommand); + return subscriber; + } + + public static createNotificationsSubscription(notificationWsService: NotificationWebsocketService, + zone: NgZone, limit = 10): NotificationSubscriber { + const subscriptionCommand = new UnreadSubCmd(limit); + const subscriber = new NotificationSubscriber(notificationWsService, zone); + subscriber.messageLimit = limit; + subscriber.subscriptionCommands.push(subscriptionCommand); + return subscriber; + } + + public static createMarkAsReadCommand(notificationWsService: NotificationWebsocketService, + ids: string[]): NotificationSubscriber { + const subscriptionCommand = new MarkAsReadCmd(ids); + const subscriber = new NotificationSubscriber(notificationWsService); + subscriber.subscriptionCommands.push(subscriptionCommand); + return subscriber; + } + + public static createMarkAllAsReadCommand(notificationWsService: NotificationWebsocketService): NotificationSubscriber { + const subscriptionCommand = new MarkAllAsReadCmd(); + const subscriber = new NotificationSubscriber(notificationWsService); + subscriber.subscriptionCommands.push(subscriptionCommand); + return subscriber; + } + + constructor(private notificationWsService: NotificationWebsocketService, protected zone?: NgZone) { + super(notificationWsService, zone); + } + + onNotificationCountUpdate(message: NotificationCountUpdate) { + if (this.zone) { + this.zone.run( + () => { + this.notificationCountSubject.next(message); + } + ); + } else { + this.notificationCountSubject.next(message); + } + } + + public complete() { + this.notificationCountSubject.complete(); + this.notificationsSubject.complete(); + super.complete(); + } + + onNotificationsUpdate(message: NotificationsUpdate) { + const currentNotifications = this.notificationsSubject.value; + let processMessage = message; + if (isDefinedAndNotNull(currentNotifications) && message.update) { + currentNotifications.notifications.unshift(message.update); + if (currentNotifications.notifications.length > this.messageLimit) { + currentNotifications.notifications.pop(); + } + processMessage = currentNotifications; + processMessage.totalUnreadCount = message.totalUnreadCount; + } + if (this.zone) { + this.zone.run( + () => { + this.notificationsSubject.next(processMessage); + this.notificationCountSubject.next(processMessage); + } + ); + } else { + this.notificationsSubject.next(processMessage); + this.notificationCountSubject.next(processMessage); + } + } +} + +export class UnreadCountSubCmd implements WebsocketCmd { + cmdId: number; +} + +export class UnreadSubCmd implements WebsocketCmd { + limit: number; + cmdId: number; + + constructor(limit = 10) { + this.limit = limit; + } +} + +export class UnsubscribeCmd implements WebsocketCmd { + cmdId: number; +} + +export class MarkAsReadCmd implements WebsocketCmd { + + cmdId: number; + notifications: string[]; + + constructor(ids: string[]) { + this.notifications = ids; + } +} + +export class MarkAllAsReadCmd implements WebsocketCmd { + cmdId: number; +} + +export interface NotificationCountUpdateMsg extends CmdUpdateMsg { + cmdUpdateType: CmdUpdateType.NOTIFICATIONS_COUNT; + totalUnreadCount: number; +} + +export interface NotificationsUpdateMsg extends CmdUpdateMsg { + cmdUpdateType: CmdUpdateType.NOTIFICATIONS; + totalUnreadCount: number; + update?: Notification; + notifications?: Notification[]; +} + +export type WebsocketNotificationMsg = NotificationCountUpdateMsg | NotificationsUpdateMsg; + +export const isNotificationCountUpdateMsg = (message: WebsocketNotificationMsg): message is NotificationCountUpdateMsg => { + const updateMsg = (message as CmdUpdateMsg); + return updateMsg.cmdId !== undefined && updateMsg.cmdUpdateType === CmdUpdateType.NOTIFICATIONS_COUNT; +}; + +export const isNotificationsUpdateMsg = (message: WebsocketNotificationMsg): message is NotificationsUpdateMsg => { + const updateMsg = (message as CmdUpdateMsg); + return updateMsg.cmdId !== undefined && updateMsg.cmdUpdateType === CmdUpdateType.NOTIFICATIONS; +}; + +export class NotificationPluginCmdWrapper implements CmdWrapper { + + constructor() { + this.unreadCountSubCmd = null; + this.unreadSubCmd = null; + this.unsubCmd = null; + this.markAsReadCmd = null; + this.markAllAsReadCmd = null; + } + + unreadCountSubCmd: UnreadCountSubCmd; + unreadSubCmd: UnreadSubCmd; + unsubCmd: UnsubscribeCmd; + markAsReadCmd: MarkAsReadCmd; + markAllAsReadCmd: MarkAllAsReadCmd; + + public hasCommands(): boolean { + return isDefinedAndNotNull(this.unreadCountSubCmd) || + isDefinedAndNotNull(this.unreadSubCmd) || + isDefinedAndNotNull(this.unsubCmd) || + isDefinedAndNotNull(this.markAsReadCmd) || + isDefinedAndNotNull(this.markAllAsReadCmd); + } + + public clear() { + this.unreadCountSubCmd = null; + this.unreadSubCmd = null; + this.unsubCmd = null; + this.markAsReadCmd = null; + this.markAllAsReadCmd = null; + } + + public preparePublishCommands(): NotificationPluginCmdWrapper { + const preparedWrapper = new NotificationPluginCmdWrapper(); + preparedWrapper.unreadCountSubCmd = this.unreadCountSubCmd || undefined; + preparedWrapper.unreadSubCmd = this.unreadSubCmd || undefined; + preparedWrapper.unsubCmd = this.unsubCmd || undefined; + preparedWrapper.markAsReadCmd = this.markAsReadCmd || undefined; + preparedWrapper.markAllAsReadCmd = this.markAllAsReadCmd || undefined; + this.clear(); + return preparedWrapper; + } +} diff --git a/ui-ngx/src/app/shared/models/websocket/websocket.models.ts b/ui-ngx/src/app/shared/models/websocket/websocket.models.ts new file mode 100644 index 0000000000..fc7fc3d15c --- /dev/null +++ b/ui-ngx/src/app/shared/models/websocket/websocket.models.ts @@ -0,0 +1,67 @@ +/// +/// Copyright © 2016-2023 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 { NgZone } from '@angular/core'; +import { WebsocketCmd } from '@shared/models/telemetry/telemetry.models'; +import { Subject } from 'rxjs'; + +export interface WsService { + subscribe(subscriber: T); + update(subscriber: T); + unsubscribe(subscriber: T); +} + +export abstract class CmdWrapper { + abstract hasCommands(): boolean; + abstract clear(): void; + abstract preparePublishCommands(maxCommands: number): CmdWrapper; + + [key: string]: WebsocketCmd | any; +} + +export abstract class WsSubscriber { + + protected reconnectSubject = new Subject(); + + subscriptionCommands: Array; + + reconnect$ = this.reconnectSubject.asObservable(); + + protected constructor(protected wsService: WsService, protected zone?: NgZone) { + this.subscriptionCommands = []; + } + + public subscribe() { + this.wsService.subscribe(this); + } + + public update() { + this.wsService.update(this); + } + + public unsubscribe() { + this.wsService.unsubscribe(this); + this.complete(); + } + + public complete() { + this.reconnectSubject.complete(); + } + + public onReconnected() { + this.reconnectSubject.next(); + } +} diff --git a/ui-ngx/src/app/shared/models/widget.models.ts b/ui-ngx/src/app/shared/models/widget.models.ts index 72d11626cb..bd7e051b58 100644 --- a/ui-ngx/src/app/shared/models/widget.models.ts +++ b/ui-ngx/src/app/shared/models/widget.models.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -29,7 +29,7 @@ import { PageComponent } from '@shared/components/page.component'; import { AfterViewInit, Directive, EventEmitter, Inject, OnInit } from '@angular/core'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; -import { AbstractControl, FormGroup } from '@angular/forms'; +import { AbstractControl, UntypedFormGroup } from '@angular/forms'; import { Observable } from 'rxjs'; import { Dashboard } from '@shared/models/dashboard.models'; import { IAliasController } from '@core/api/widget-api.models'; @@ -706,7 +706,7 @@ function removeEmptyWidgetSettings(settings: WidgetSettings): WidgetSettings { } @Directive() -// tslint:disable-next-line:directive-class-suffix +// eslint-disable-next-line @angular-eslint/directive-class-suffix export abstract class WidgetSettingsComponent extends PageComponent implements IWidgetSettingsComponent, OnInit, AfterViewInit { @@ -802,7 +802,7 @@ export abstract class WidgetSettingsComponent extends PageComponent implements } } - protected doUpdateSettings(settingsForm: FormGroup, settings: WidgetSettings) { + protected doUpdateSettings(settingsForm: UntypedFormGroup, settings: WidgetSettings) { } protected prepareInputSettings(settings: WidgetSettings): WidgetSettings { @@ -819,7 +819,7 @@ export abstract class WidgetSettingsComponent extends PageComponent implements protected onValidate() {} - protected abstract settingsForm(): FormGroup; + protected abstract settingsForm(): UntypedFormGroup; protected abstract onSettingsSet(settings: WidgetSettings); diff --git a/ui-ngx/src/app/shared/models/widgets-bundle.model.ts b/ui-ngx/src/app/shared/models/widgets-bundle.model.ts index 235190ead2..c7eb999221 100644 --- a/ui-ngx/src/app/shared/models/widgets-bundle.model.ts +++ b/ui-ngx/src/app/shared/models/widgets-bundle.model.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/models/window-message.model.ts b/ui-ngx/src/app/shared/models/window-message.model.ts index dff6898958..f720f2692f 100644 --- a/ui-ngx/src/app/shared/models/window-message.model.ts +++ b/ui-ngx/src/app/shared/models/window-message.model.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/pipe/date-ago.pipe.ts b/ui-ngx/src/app/shared/pipe/date-ago.pipe.ts new file mode 100644 index 0000000000..c0f4b5c026 --- /dev/null +++ b/ui-ngx/src/app/shared/pipe/date-ago.pipe.ts @@ -0,0 +1,58 @@ +/// +/// Copyright © 2016-2023 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 { Inject, Pipe, PipeTransform } from '@angular/core'; +import { DAY, HOUR, MINUTE, SECOND, WEEK, YEAR } from '@shared/models/time/time.models'; +import { TranslateService } from '@ngx-translate/core'; + +const intervals = { + years: YEAR, + months: DAY * 30, + weeks: WEEK, + days: DAY, + hr: HOUR, + min: MINUTE, + sec: SECOND +}; + +@Pipe({ + name: 'dateAgo' +}) +export class DateAgoPipe implements PipeTransform { + + constructor(@Inject(TranslateService) private translate: TranslateService) { + + } + + transform(value: number): string { + if (value) { + const ms = Math.floor((+new Date() - +new Date(value))); + if (ms < 29 * SECOND) { // less than 30 seconds ago will show as 'Just now' + return this.translate.instant('timewindow.just-now'); + } + let counter; + // tslint:disable-next-line:forin + for (const i in intervals) { + counter = Math.floor(ms / intervals[i]); + if (counter > 0) { + return this.translate.instant(`timewindow.${i}`, {[i]: counter}); + } + } + } + return ''; + } + +} diff --git a/ui-ngx/src/app/shared/pipe/enum-to-array.pipe.ts b/ui-ngx/src/app/shared/pipe/enum-to-array.pipe.ts index e879727dd5..befd9a7b56 100644 --- a/ui-ngx/src/app/shared/pipe/enum-to-array.pipe.ts +++ b/ui-ngx/src/app/shared/pipe/enum-to-array.pipe.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/pipe/file-size.pipe.ts b/ui-ngx/src/app/shared/pipe/file-size.pipe.ts index bfab9ee5c3..dc67cd216d 100644 --- a/ui-ngx/src/app/shared/pipe/file-size.pipe.ts +++ b/ui-ngx/src/app/shared/pipe/file-size.pipe.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/pipe/highlight.pipe.ts b/ui-ngx/src/app/shared/pipe/highlight.pipe.ts index 5a676cc96b..0f8595fc3b 100644 --- a/ui-ngx/src/app/shared/pipe/highlight.pipe.ts +++ b/ui-ngx/src/app/shared/pipe/highlight.pipe.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/pipe/keyboard-shortcut.pipe.ts b/ui-ngx/src/app/shared/pipe/keyboard-shortcut.pipe.ts index b11c518d40..12104356d0 100644 --- a/ui-ngx/src/app/shared/pipe/keyboard-shortcut.pipe.ts +++ b/ui-ngx/src/app/shared/pipe/keyboard-shortcut.pipe.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/pipe/milliseconds-to-time-string.pipe.ts b/ui-ngx/src/app/shared/pipe/milliseconds-to-time-string.pipe.ts index 6722f2f72e..df185fe8d0 100644 --- a/ui-ngx/src/app/shared/pipe/milliseconds-to-time-string.pipe.ts +++ b/ui-ngx/src/app/shared/pipe/milliseconds-to-time-string.pipe.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -25,33 +25,51 @@ export class MillisecondsToTimeStringPipe implements PipeTransform { constructor(private translate: TranslateService) { } - transform(millseconds: number, args?: any): string { + transform(millseconds: number, shortFormat = false): string { let seconds = Math.floor(millseconds / 1000); const days = Math.floor(seconds / 86400); let hours = Math.floor((seconds % 86400) / 3600); let minutes = Math.floor(((seconds % 86400) % 3600) / 60); seconds = seconds % 60; let timeString = ''; - if (days > 0) { - timeString += this.translate.instant('timewindow.days', {days}); - } - if (hours > 0) { - if (timeString.length === 0 && hours === 1) { - hours = 0; + if (shortFormat) { + if (days > 0) { + timeString += this.translate.instant('timewindow.short.days', {days}); } - timeString += this.translate.instant('timewindow.hours', {hours}); - } - if (minutes > 0) { - if (timeString.length === 0 && minutes === 1) { - minutes = 0; + if (hours > 0) { + timeString += this.translate.instant('timewindow.short.hours', {hours}); } - timeString += this.translate.instant('timewindow.minutes', {minutes}); - } - if (seconds > 0) { - if (timeString.length === 0 && seconds === 1) { - seconds = 0; + if (minutes > 0) { + timeString += this.translate.instant('timewindow.short.minutes', {minutes}); + } + if (seconds > 0) { + timeString += this.translate.instant('timewindow.short.seconds', {seconds}); + } + if (!timeString.length) { + timeString += this.translate.instant('timewindow.short.seconds', {seconds: 0}); + } + } else { + if (days > 0) { + timeString += this.translate.instant('timewindow.days', {days}); + } + if (hours > 0) { + if (timeString.length === 0 && hours === 1) { + hours = 0; + } + timeString += this.translate.instant('timewindow.hours', {hours}); + } + if (minutes > 0) { + if (timeString.length === 0 && minutes === 1) { + minutes = 0; + } + timeString += this.translate.instant('timewindow.minutes', {minutes}); + } + if (seconds > 0) { + if (timeString.length === 0 && seconds === 1) { + seconds = 0; + } + timeString += this.translate.instant('timewindow.seconds', {seconds}); } - timeString += this.translate.instant('timewindow.seconds', {seconds}); } return timeString; } diff --git a/ui-ngx/src/app/shared/pipe/nospace.pipe.ts b/ui-ngx/src/app/shared/pipe/nospace.pipe.ts index d702464d3b..155f648ae0 100644 --- a/ui-ngx/src/app/shared/pipe/nospace.pipe.ts +++ b/ui-ngx/src/app/shared/pipe/nospace.pipe.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/pipe/public-api.ts b/ui-ngx/src/app/shared/pipe/public-api.ts index dc810aaaf1..9ba3be3610 100644 --- a/ui-ngx/src/app/shared/pipe/public-api.ts +++ b/ui-ngx/src/app/shared/pipe/public-api.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -14,6 +14,7 @@ /// limitations under the License. /// +export * from './date-ago.pipe'; export * from './enum-to-array.pipe'; export * from './highlight.pipe'; export * from './keyboard-shortcut.pipe'; diff --git a/ui-ngx/src/app/shared/pipe/safe.pipe.ts b/ui-ngx/src/app/shared/pipe/safe.pipe.ts index 67243802f5..22cf9e4a50 100644 --- a/ui-ngx/src/app/shared/pipe/safe.pipe.ts +++ b/ui-ngx/src/app/shared/pipe/safe.pipe.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/pipe/selectable-columns.pipe.ts b/ui-ngx/src/app/shared/pipe/selectable-columns.pipe.ts index 9ee424bd68..e71ba26787 100644 --- a/ui-ngx/src/app/shared/pipe/selectable-columns.pipe.ts +++ b/ui-ngx/src/app/shared/pipe/selectable-columns.pipe.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/pipe/tbJson.pipe.ts b/ui-ngx/src/app/shared/pipe/tbJson.pipe.ts index 41385c2f1a..edf55e1f0c 100644 --- a/ui-ngx/src/app/shared/pipe/tbJson.pipe.ts +++ b/ui-ngx/src/app/shared/pipe/tbJson.pipe.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/pipe/truncate.pipe.ts b/ui-ngx/src/app/shared/pipe/truncate.pipe.ts index 5e7c8d6f69..db2d0ccb09 100644 --- a/ui-ngx/src/app/shared/pipe/truncate.pipe.ts +++ b/ui-ngx/src/app/shared/pipe/truncate.pipe.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/public-api.ts b/ui-ngx/src/app/shared/public-api.ts index e289cc9339..f63b26e18c 100644 --- a/ui-ngx/src/app/shared/public-api.ts +++ b/ui-ngx/src/app/shared/public-api.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/services/custom-paginator-intl.ts b/ui-ngx/src/app/shared/services/custom-paginator-intl.ts index ef2ab076d5..fa52a99f3b 100644 --- a/ui-ngx/src/app/shared/services/custom-paginator-intl.ts +++ b/ui-ngx/src/app/shared/services/custom-paginator-intl.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts index 720f74d2a6..c82ae61644 100644 --- a/ui-ngx/src/app/shared/shared.module.ts +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2022 The Thingsboard Authors +/// Copyright © 2016-2023 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. @@ -24,7 +24,8 @@ import { FlowInjectionToken, NgxFlowModule } from '@flowjs/ngx-flow'; import { NgxFlowchartModule } from 'ngx-flowchart'; import Flow from '@flowjs/flow.js'; -import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MAT_AUTOCOMPLETE_DEFAULT_OPTIONS, MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MatBadgeModule } from '@angular/material/badge'; import { MatButtonModule } from '@angular/material/button'; import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatCardModule } from '@angular/material/card'; @@ -42,7 +43,7 @@ import { MatPaginatorIntl, MatPaginatorModule } from '@angular/material/paginato import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatRadioModule } from '@angular/material/radio'; -import { MatSelectModule } from '@angular/material/select'; +import { MAT_SELECT_CONFIG, MatSelectModule } from '@angular/material/select'; import { MatSidenavModule } from '@angular/material/sidenav'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatSliderModule } from '@angular/material/slider'; @@ -64,6 +65,7 @@ import { ShareModule as ShareButtonsModule } from 'ngx-sharebuttons'; import { HotkeyModule } from 'angular2-hotkeys'; import { ColorPickerModule } from 'ngx-color-picker'; import { NgxHmCarouselModule } from 'ngx-hm-carousel'; +import { EditorModule, TINYMCE_SCRIPT_SRC } from '@tinymce/tinymce-angular'; import { UserMenuComponent } from '@shared/components/user-menu.component'; import { NospacePipe } from '@shared/pipe/nospace.pipe'; import { TranslateModule } from '@ngx-translate/core'; @@ -84,6 +86,7 @@ import { MarkdownEditorComponent } from '@shared/components/markdown-editor.comp import { FullscreenDirective } from '@shared/components/fullscreen.directive'; import { HighlightPipe } from '@shared/pipe/highlight.pipe'; import { DashboardAutocompleteComponent } from '@shared/components/dashboard-autocomplete.component'; +import { DashboardStateAutocompleteComponent } from '@shared/components/dashboard-state-autocomplete.component'; import { EntitySubTypeAutocompleteComponent } from '@shared/components/entity/entity-subtype-autocomplete.component'; import { EntitySubTypeSelectComponent } from '@shared/components/entity/entity-subtype-select.component'; import { EntityAutocompleteComponent } from '@shared/components/entity/entity-autocomplete.component'; @@ -93,6 +96,7 @@ import { EntitySelectComponent } from '@shared/components/entity/entity-select.c import { DatetimeComponent } from '@shared/components/time/datetime.component'; import { EntityKeysListComponent } from '@shared/components/entity/entity-keys-list.component'; import { SocialSharePanelComponent } from '@shared/components/socialshare-panel.component'; +import { StringItemsListComponent } from '@shared/components/string-items-list.component'; import { RelationTypeAutocompleteComponent } from '@shared/components/relation/relation-type-autocomplete.component'; import { EntityListSelectComponent } from '@shared/components/entity/entity-list-select.component'; import { JsonObjectEditComponent } from '@shared/components/json-object-edit.component'; @@ -114,7 +118,6 @@ import { EntitySubTypeListComponent } from '@shared/components/entity/entity-sub import { TruncatePipe } from '@shared/pipe/truncate.pipe'; import { TbJsonPipe } from '@shared/pipe/tbJson.pipe'; import { ColorPickerDialogComponent } from '@shared/components/dialog/color-picker-dialog.component'; -import { MatChipDraggableDirective } from '@shared/components/mat-chip-draggable.directive'; import { ColorInputComponent } from '@shared/components/color-input.component'; import { JsFuncComponent } from '@shared/components/js-func.component'; import { JsonFormComponent } from '@shared/components/json-form/json-form.component'; @@ -169,6 +172,10 @@ import { PhoneInputComponent } from '@shared/components/phone-input.component'; import { CustomDateAdapter } from '@shared/adapter/custom-datatime-adapter'; import { CustomPaginatorIntl } from '@shared/services/custom-paginator-intl'; import { TbScriptLangComponent } from '@shared/components/script-lang.component'; +import { NotificationComponent } from '@shared/components/notification/notification.component'; +import { TemplateAutocompleteComponent } from '@shared/components/notification/template-autocomplete.component'; +import { SlackConversationAutocompleteComponent } from '@shared/components/slack-conversation-autocomplete.component'; +import { DateAgoPipe } from '@shared/pipe/date-ago.pipe'; export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) { return markedOptionsService; @@ -183,11 +190,17 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) TruncatePipe, TbJsonPipe, FileSizePipe, + DateAgoPipe, SafePipe, + DateAgoPipe, { provide: FlowInjectionToken, useValue: Flow }, + { + provide: TINYMCE_SCRIPT_SRC, + useValue: 'assets/tinymce/tinymce.min.js' + }, { provide: MAT_DATE_LOCALE, useValue: 'en-GB' @@ -196,7 +209,20 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) { provide: HELP_MARKDOWN_COMPONENT_TOKEN, useValue: HelpMarkdownComponent }, { provide: SHARED_MODULE_TOKEN, useValue: SharedModule }, { provide: MatPaginatorIntl, useClass: CustomPaginatorIntl }, - TbPopoverService + TbPopoverService, + { + provide: MAT_SELECT_CONFIG, + useValue: { + overlayPanelClass: 'tb-select-overlay', + hideSingleSelectionIndicator: true + } + }, + { + provide: MAT_AUTOCOMPLETE_DEFAULT_OPTIONS, + useValue: { + hideSingleSelectionIndicator: true + } + } ], declarations: [ FooterComponent, @@ -205,7 +231,6 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) ToastDirective, FullscreenDirective, CircularProgressDirective, - MatChipDraggableDirective, TbHotkeysDirective, TbAnchorComponent, TbPopoverComponent, @@ -233,6 +258,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) TimezoneSelectComponent, ValueInputComponent, DashboardAutocompleteComponent, + DashboardStateAutocompleteComponent, EntitySubTypeAutocompleteComponent, EntitySubTypeSelectComponent, EntitySubTypeListComponent, @@ -246,6 +272,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) QueueAutocompleteComponent, RelationTypeAutocompleteComponent, SocialSharePanelComponent, + StringItemsListComponent, JsonObjectEditComponent, JsonObjectViewComponent, JsonContentComponent, @@ -280,6 +307,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) TruncatePipe, TbJsonPipe, FileSizePipe, + DateAgoPipe, SafePipe, SelectableColumnsPipe, KeyboardShortcutPipe, @@ -295,12 +323,17 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) ProtobufContentComponent, BranchAutocompleteComponent, PhoneInputComponent, - TbScriptLangComponent + TbScriptLangComponent, + NotificationComponent, + TemplateAutocompleteComponent, + SlackConversationAutocompleteComponent, + DateAgoPipe ], imports: [ CommonModule, RouterModule, TranslateModule, + MatBadgeModule, MatButtonModule, MatButtonToggleModule, MatCheckboxModule, @@ -365,7 +398,6 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) ToastDirective, FullscreenDirective, CircularProgressDirective, - MatChipDraggableDirective, TbHotkeysDirective, TbAnchorComponent, TbStringTemplateOutletDirective, @@ -389,6 +421,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) DatetimeComponent, TimezoneSelectComponent, DashboardAutocompleteComponent, + DashboardStateAutocompleteComponent, EntitySubTypeAutocompleteComponent, EntitySubTypeSelectComponent, EntitySubTypeListComponent, @@ -402,6 +435,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) QueueAutocompleteComponent, RelationTypeAutocompleteComponent, SocialSharePanelComponent, + StringItemsListComponent, JsonObjectEditComponent, JsonObjectViewComponent, JsonContentComponent, @@ -413,6 +447,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) FabToolbarComponent, WidgetsBundleSelectComponent, ValueInputComponent, + MatBadgeModule, MatButtonModule, MatButtonToggleModule, MatCheckboxModule, @@ -457,6 +492,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) HotkeyModule, ColorPickerModule, NgxHmCarouselModule, + EditorModule, DndModule, NgxFlowchartModule, MarkdownModule, @@ -485,6 +521,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) TbJsonPipe, KeyboardShortcutPipe, FileSizePipe, + DateAgoPipe, SafePipe, SelectableColumnsPipe, RouterModule, @@ -500,7 +537,11 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) ProtobufContentComponent, BranchAutocompleteComponent, PhoneInputComponent, - TbScriptLangComponent + TbScriptLangComponent, + NotificationComponent, + TemplateAutocompleteComponent, + SlackConversationAutocompleteComponent, + DateAgoPipe ] }) export class SharedModule { } diff --git a/ui-ngx/src/assets/fonts/material-icons.css b/ui-ngx/src/assets/fonts/material-icons.css index f790ddaf43..3809ea3696 100644 --- a/ui-ngx/src/assets/fonts/material-icons.css +++ b/ui-ngx/src/assets/fonts/material-icons.css @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2022 The Thingsboard Authors + * Copyright © 2016-2023 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. diff --git a/ui-ngx/src/assets/help/en_US/notification/alarm.md b/ui-ngx/src/assets/help/en_US/notification/alarm.md new file mode 100644 index 0000000000..671f617bab --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/notification/alarm.md @@ -0,0 +1,63 @@ +#### Alarm notification templatization + +
    +
    + +Notification subject and message fields support templatization. The list of available templatization parameters depends on the template type. +See the available types and parameters below: + +Available template parameters: + + * *recipientEmail* - email of the recipient; + * *recipientFirstName* - first name of the recipient; + * *recipientLastName* - last name of the recipient; + * *alarmType* - alarm type; + * *action* - one of: 'created', 'severity changed', 'acknowledged', 'cleared', 'deleted'; + * *alarmId* - the alarm id as uuid string; + * *alarmSeverity* - alarm severity (lower case); + * *alarmStatus* - the alarm status; + * *alarmOriginatorEntityType* - the entity type of the alarm originator, e.g. 'Device'; + * *alarmOriginatorName* - the name of the alarm originator, e.g. 'Sensor T1'; + * *alarmOriginatorId* - the alarm originator entity id as uuid string; + +Parameter names must be wrapped using `${...}`. For example: `${action}`. +You may also modify the value of the parameter with one of the suffixes: + + * `upperCase`, for example - `${recipientFirstName:upperCase}` + * `lowerCase`, for example - `${recipientFirstName:lowerCase}` + * `capitalize`, for example - `${recipientFirstName:capitalize}` + +
    + +##### Examples + + * Let's assume the notification about new alarm with type 'High Temperature' for device 'Sensor A'. The following template: + +```text +Alarm '${alarmType}' - ${action:upperCase} +{:copy-code} +``` + +will be transformed to: + +```text +Alarm 'High Temperature' - CREATED +{:copy-code} +``` + +The following template: + +```text +${alarmOriginatorEntityType:capitalize} '${alarmOriginatorName}' +{:copy-code} +``` + +will be transformed to: + +```text +Device - Sensor A +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/notification/alarm_assignment.md b/ui-ngx/src/assets/help/en_US/notification/alarm_assignment.md new file mode 100644 index 0000000000..c4ff27d333 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/notification/alarm_assignment.md @@ -0,0 +1,70 @@ +#### Alarm assignment notification templatization + +
    +
    + +Notification subject and message fields support templatization. The list of available templatization parameters depends on the template type. +See the available types and parameters below: + +Available template parameters: + + * *recipientEmail* - email of the recipient; + * *recipientFirstName* - first name of the recipient; + * *recipientLastName* - last name of the recipient; + * *alarmType* - alarm type; + * *alarmId* - the alarm id as uuid string; + * *alarmSeverity* - alarm severity (lower case); + * *alarmStatus* - the alarm status; + * *alarmOriginatorEntityType* - the entity type of the alarm originator, e.g. 'Device'; + * *alarmOriginatorName* - the name of the alarm originator, e.g. 'Sensor T1'; + * *alarmOriginatorId* - the alarm originator entity id as uuid string; + * *assigneeEmail* - email of the assignee; + * *assigneeFirstName* - first name of the assignee; + * *assigneeLastName* - last name of the assignee; + * *assigneeId* - the id of the assignee as uuid string; + * *userEmail* - email of the user who made the action; + * *userFirstName* - first name of the user who made the action; + * *userLastName* - last name of the user who made the action; + * *action* - one of: 'assigned', 'unassigned'; + +Parameter names must be wrapped using `${...}`. For example: `${action}`. +You may also modify the value of the parameter with one of the suffixes: + + * `upperCase`, for example - `${recipientFirstName:upperCase}` + * `lowerCase`, for example - `${recipientFirstName:lowerCase}` + * `capitalize`, for example - `${recipientFirstName:capitalize}` + +
    + +##### Examples + + * Let's assume the notification about alarm with type 'High Temperature' for device 'Sensor A' was assigned to user 'John Doe'. The following template: + +```text +Alarm '${alarmType}' - ${action:upperCase} +{:copy-code} +``` + +will be transformed to: + +```text +Alarm 'High Temperature' - ASSIGNED +{:copy-code} +``` + +The following template: + +```text +Alarm '${alarmType}' (${alarmSeverity:capitalize}) was assigned to user +{:copy-code} +``` + +will be transformed to: + +```text +Alarm 'High Temperature' (Critical) was assigned to user +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/notification/alarm_comment.md b/ui-ngx/src/assets/help/en_US/notification/alarm_comment.md new file mode 100644 index 0000000000..8a008ecf27 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/notification/alarm_comment.md @@ -0,0 +1,68 @@ +#### Alarm comment notification templatization + +
    +
    + +Notification subject and message fields support templatization. The list of available templatization parameters depends on the template type. +See the available types and parameters below: + +Available template parameters: + + * *recipientEmail* - email of the recipient; + * *recipientFirstName* - first name of the recipient; + * *recipientLastName* - last name of the recipient; + * *alarmType* - alarm type; + * *alarmId* - the alarm id as uuid string; + * *alarmSeverity* - alarm severity (lower case); + * *alarmStatus* - the alarm status; + * *alarmOriginatorEntityType* - the entity type of the alarm originator, e.g. 'Device'; + * *alarmOriginatorName* - the name of the alarm originator, e.g. 'Sensor T1'; + * *alarmOriginatorId* - the alarm originator entity id as uuid string; + * *comment* - text of the comment; + * *action* - one of: 'added', 'updated'; + * *userEmail* - email of the user who made the action; + * *userFirstName* - first name of the user who made the action; + * *userLastName* - last name of the user who made the action; + +Parameter names must be wrapped using `${...}`. For example: `${action}`. +You may also modify the value of the parameter with one of the suffixes: + + * `upperCase`, for example - `${recipientFirstName:upperCase}` + * `lowerCase`, for example - `${recipientFirstName:lowerCase}` + * `capitalize`, for example - `${recipientFirstName:capitalize}` + +
    + +##### Examples + + * Let's assume the notification about alarm with type 'High Temperature' for device 'Sensor A' was assigned to user 'John Doe'. + The following template: + +```text +Alarm '${alarmType}' - comment ${action} +{:copy-code} +``` + +will be transformed to: + +```text +Alarm 'High Temperature' - comment added +{:copy-code} +``` + +The following template: + +```text +Alarm '${alarmType}' (${alarmSeverity:capitalize}) was commented +{:copy-code} +``` + +will be transformed to: + +```text +Alarm 'High Temperature' (Critical) was commented +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/notification/api_usage_limit.md b/ui-ngx/src/assets/help/en_US/notification/api_usage_limit.md new file mode 100644 index 0000000000..29bd757067 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/notification/api_usage_limit.md @@ -0,0 +1,48 @@ +#### API usage limit notification templatization + +
    +
    + +Notification subject and message fields support templatization. The list of available templatization parameters depends on the template type. +See the available types and parameters below: + +Available template parameters: + + * *recipientEmail* - email of the recipient; + * *recipientFirstName* - first name of the recipient; + * *recipientLastName* - last name of the recipient; + * *feature* - API feature for which the limit is applied; one of: 'Device API', 'Telemetry persistence', 'Rule Engine execution', 'JavaScript functions execution', 'Email messages', 'SMS messages', 'Alarms'; + * *status* - one of: 'enabled', 'warning', 'disabled'; + * *unitLabel* - name of the limited unit; one of: 'message', 'data point', 'Rule Engine execution', 'JavaScript execution', 'email message', 'SMS message', 'alarm'; + * *limit* - the limit on used feature units; + * *currentValue* - current number of used units; + * *tenantId* - id of the tenant; + * *tenantName* - name of the tenant; + +Parameter names must be wrapped using `${...}`. For example: `${recipientFirstName}`. +You may also modify the value of the parameter with one of the suffixes: + + * `upperCase`, for example - `${recipientFirstName:upperCase}` + * `lowerCase`, for example - `${recipientFirstName:lowerCase}` + * `capitalize`, for example - `${recipientFirstName:capitalize}` + +
    + +##### Examples + +Let's assume tenant's devices pushed 8K messages with the max allowed number of 10K and warn threshold in tenant profile set to 0.8 (80%). The following template: + +```text +${feature} feature - ${status:upperCase} (usage: ${currentValue} out of ${limit} ${unitLabel}s) +{:copy-code} +``` + +will be transformed to: + +```text +Device API feature - WARNING (usage: 8000 out of 10000 messages) +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/notification/device_activity.md b/ui-ngx/src/assets/help/en_US/notification/device_activity.md new file mode 100644 index 0000000000..5e69a0c634 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/notification/device_activity.md @@ -0,0 +1,64 @@ +#### Device activity notification templatization + +
    +
    + +Notification subject and message fields support templatization. The list of available templatization parameters depends on the template type. +See the available types and parameters below: + +Available template parameters: + + * *recipientEmail* - email of the recipient; + * *recipientFirstName* - first name of the recipient; + * *recipientLastName* - last name of the recipient; + * *deviceId* - the device id as uuid string; + * *deviceName* - the device name; + * *deviceLabel* - the device label; + * *deviceType* - the device type; + * *eventType* - one of: 'inactive', 'active'; + +Parameter names must be wrapped using `${...}`. For example: `${recipientFirstName}`. +You may also modify the value of the parameter with one of the suffixes: + + * `upperCase`, for example - `${recipientFirstName:upperCase}` + * `lowerCase`, for example - `${recipientFirstName:lowerCase}` + * `capitalize`, for example - `${recipientFirstName:capitalize}` + +
    + +##### Examples + +Let's assume the notification about inactive thermometer device 'Sensor T1'. +The following template: + +Template message: `` + +```text +Device '${deviceName}' inactive +{:copy-code} +``` + +will be transformed to: + +```text +Device 'Sensor T1' inactive +{:copy-code} +``` + + +
    +The following template: + +```text +${deviceType:capitalize} '${deviceName}' became ${eventType} +{:copy-code} +``` + +will be transformed to: + +```text +Thermometer 'Sensor T1' became inactive +{:copy-code} +``` +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/notification/entities_limit.md b/ui-ngx/src/assets/help/en_US/notification/entities_limit.md new file mode 100644 index 0000000000..da351ad30f --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/notification/entities_limit.md @@ -0,0 +1,47 @@ +#### Entity count limit notification templatization + +
    +
    + +Notification subject and message fields support templatization. The list of available templatization parameters depends on the template type. +See the available types and parameters below: + +Available template parameters: + + * *recipientEmail* - email of the recipient; + * *recipientFirstName* - first name of the recipient; + * *recipientLastName* - last name of the recipient; + * *entityType* - one of: 'Device', 'Asset', 'User', etc.; + * *currentCount* - the current count of entities; + * *limit* - the limit on number of entities; + * *percents* - the percent from the notification rule configuration; + * *tenantId* - id of the tenant; + * *tenantName* - name of the tenant; + +Parameter names must be wrapped using `${...}`. For example: `${recipientFirstName}`. +You may also modify the value of the parameter with one of the suffixes: + + * `upperCase`, for example - `${recipientFirstName:upperCase}` + * `lowerCase`, for example - `${recipientFirstName:lowerCase}` + * `capitalize`, for example - `${recipientFirstName:capitalize}` + +
    + +##### Examples + +Let's assume the tenant created 400 devices with the max allowed number is 500 and rule threshold 0.8 (80%). The following template: + +```text +${entityType:capitalize}s usage: ${currentCount}/${limit} (${percents}%) +{:copy-code} +``` + +will be transformed to: + +```text +Devices usage: 400/500 (80%) +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/notification/entity_action.md b/ui-ngx/src/assets/help/en_US/notification/entity_action.md new file mode 100644 index 0000000000..06e3fe2d4e --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/notification/entity_action.md @@ -0,0 +1,65 @@ +#### Entity action notification templatization + +
    +
    + +Notification subject and message fields support templatization. The list of available templatization parameters depends on the template type. +See the available types and parameters below: + +Available template parameters: + + * *recipientEmail* - email of the recipient; + * *recipientFirstName* - first name of the recipient; + * *recipientLastName* - last name of the recipient; + * *entityType* - the entity type, e.g. 'Device'; + * *entityId* - the entity id as uuid string; + * *entityName* - the name of the entity; + * *actionType* - one of: 'added', 'updated', 'deleted'; + * *userId* - id of the user who made the action; + * *userEmail* - email of the user who made the action; + * *userFirstName* - first name of the user who made the action; + * *userLastName* - last name of the user who made the action; + +Parameter names must be wrapped using `${...}`. For example: `${recipientFirstName}`. +You may also modify the value of the parameter with one of the suffixes: + + * `upperCase`, for example - `${recipientFirstName:upperCase}` + * `lowerCase`, for example - `${recipientFirstName:lowerCase}` + * `capitalize`, for example - `${recipientFirstName:capitalize}` + +
    + +##### Examples + +Let's assume the notification about device 'T1' was added by user 'john.doe@gmail.com'. +The following template: + +```text +${entityType:capitalize} was ${actionType}! +{:copy-code} +``` + +will be transformed to: + +```text +Device was added! +{:copy-code} +``` + +
    +The following template: + +```text +${entityType} '${entityName}' was ${actionType} by user '${originatorUserName}'! +{:copy-code} +``` + +will be transformed to: + +```text +Device 'T1' was added by user 'john.doe@gmail.com'! +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/notification/general.md b/ui-ngx/src/assets/help/en_US/notification/general.md new file mode 100644 index 0000000000..44c8e6aafb --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/notification/general.md @@ -0,0 +1,41 @@ +#### General notification templatization + +
    +
    + +Notification subject and message fields support templatization. The list of available templatization parameters depends on the template type. +See the available types and parameters below: + +Available template parameters: + + * *recipientEmail* - email of the recipient; + * *recipientFirstName* - first name of the recipient; + * *recipientLastName* - last name of the recipient; + +Parameter names must be wrapped using `${...}`. For example: `${recipientFirstName}`. +You may also modify the value of the parameter with one of the suffixes: + + * `upperCase`, for example - `${recipientFirstName:upperCase}` + * `lowerCase`, for example - `${recipientFirstName:lowerCase}` + * `capitalize`, for example - `${recipientFirstName:capitalize}` + +
    + +##### Examples + +Let's assume the notification recipient user `John Doe`. The following template: + +```text +Hi, ${recipientFirstName}! +{:copy-code} +``` + +will be transformed to: + +```text +Hi, John! +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/notification/rule_engine.md b/ui-ngx/src/assets/help/en_US/notification/rule_engine.md new file mode 100644 index 0000000000..30dfa8797d --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/notification/rule_engine.md @@ -0,0 +1,54 @@ +#### Rule engine notification templatization + +
    +
    + +Notification subject and message fields support templatization. The list of available templatization parameters depends on the template type. +See the available types and parameters below: + +Available template parameters: + + * values from the incoming message metadata; + * values from the incoming message data; + * *originatorType* - type of the originator, e.g. 'Device'; + * *originatorId* - id of the originator + * *msgType* - type of the message + * *recipientEmail* - email of the recipient; + * *recipientFirstName* - first name of the recipient; + * *recipientLastName* - last name of the recipient; + +Parameter names must be wrapped using `${...}`. For example: `${recipientFirstName}`. +You may also modify the value of the parameter with one of the suffixes: + + * `upperCase`, for example - `${recipientFirstName:upperCase}` + * `lowerCase`, for example - `${recipientFirstName:lowerCase}` + * `capitalize`, for example - `${recipientFirstName:capitalize}` + +
    + +##### Examples + +Let's assume the incoming message to Rule node has the following data: +```json +{ + "building_1": { + "temperature": 24 + } +} +``` + +The following template: + +```text +Building 1: temperature is ${building_1.temperature} +{:copy-code} +``` + +will be transformed to: + +```text +Building 1: temperature is 24 +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/notification/rule_engine_lifecycle_event.md b/ui-ngx/src/assets/help/en_US/notification/rule_engine_lifecycle_event.md new file mode 100644 index 0000000000..1d22847bed --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/notification/rule_engine_lifecycle_event.md @@ -0,0 +1,49 @@ +#### Rule engine lifecycle notification templatization + +
    +
    + +Notification subject and message fields support templatization. The list of available templatization parameters depends on the template type. +See the available types and parameters below: + +Available template parameters: + + * *recipientEmail* - email of the recipient; + * *recipientFirstName* - first name of the recipient; + * *recipientLastName* - last name of the recipient; + * *componentType* - one of: 'Rule chain', 'Rule node'; + * *componentId* - the component id as uuid string; + * *componentName* - the rule chain or rule node name; + * *ruleChainId* - the rule chain id as uuid string; + * *ruleChainName* - the rule chain name; + * *eventType* - one of: 'started', 'updated', 'stopped'; + * *action* - one of: 'start', 'update', 'stop'; + * *error* - the error text; + +Parameter names must be wrapped using `${...}`. For example: `${recipientFirstName}`. +You may also modify the value of the parameter with one of the suffixes: + + * `upperCase`, for example - `${recipientFirstName:upperCase}` + * `lowerCase`, for example - `${recipientFirstName:lowerCase}` + * `capitalize`, for example - `${recipientFirstName:capitalize}` + +
    + +##### Examples + +Let's assume the notification about misconfigured Kafka rule node. The following template: + +```text +Rule node '${componentName}' - ${action} failure:
    ${error} +{:copy-code} +``` + +will be transformed to: + +```text +Rule node 'Export to Kafka' - start failure:
    Connection refused! +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/action/custom_additional_params.md b/ui-ngx/src/assets/help/en_US/widget/action/custom_additional_params.md index d13af3fc26..35ede5703a 100644 --- a/ui-ngx/src/assets/help/en_US/widget/action/custom_additional_params.md +++ b/ui-ngx/src/assets/help/en_US/widget/action/custom_additional_params.md @@ -1,4 +1,4 @@ -#### Additional params object +#### Additional params object

    @@ -16,10 +16,10 @@ An optional key/value object holding additional entity parameters depending on w
-
  • Alarms table widget (On row click or Action cell button) - additionalParams: { alarm: AlarmDataInfo }: +
  • Alarms table widget (On row click or Action cell button) - additionalParams: { alarm: AlarmDataInfo }:
      -
    • alarm: AlarmDataInfo - An - AlarmDataInfo object +
    • alarm: AlarmDataInfo - An + AlarmDataInfo object presenting basic alarm properties (ex. type, severity, originator, etc.) and
      provides access to other alarm or originator entity fields/attributes/timeseries declared in widget datasource configuration.
    diff --git a/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_create_user_js.md b/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_create_user_js.md index 22738c53c1..130426c6cf 100644 --- a/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_create_user_js.md +++ b/ui-ngx/src/assets/help/en_US/widget/action/examples_custom_pretty/custom_pretty_create_user_js.md @@ -94,7 +94,7 @@ let activationLinkDialogTemplate = `

    user.activation-link

    -
  • - - - - {{type}} - - - - - - - - - - - - - - - - - +
    +
    + + + + + +
    +
    + +
    +
    - - diff --git a/ui-ngx/src/app/shared/components/color-picker/color-picker.component.ts b/ui-ngx/src/app/shared/components/color-picker/color-picker.component.ts index f2140e02a5..ab8fb847dd 100644 --- a/ui-ngx/src/app/shared/components/color-picker/color-picker.component.ts +++ b/ui-ngx/src/app/shared/components/color-picker/color-picker.component.ts @@ -23,10 +23,12 @@ import { OnChanges, OnDestroy, OnInit, - Output, SimpleChanges + Output, + SimpleChanges } from '@angular/core'; import { Color, ColorPickerControl } from '@iplab/ngx-color-picker'; import { Subscription } from 'rxjs'; + export enum ColorType { hex = 'hex', hexa = 'hexa', @@ -46,7 +48,7 @@ export enum ColorType { export class ColorPickerComponent implements OnInit, OnChanges, OnDestroy { public selectedPresentation = 0; - public presentations = [ColorType.hex, ColorType.hexa, ColorType.rgb, ColorType.rgba, ColorType.hsl, ColorType.hsla]; + public presentations = [ColorType.hex, ColorType.rgb, ColorType.rgba, ColorType.hsla, ColorType.hsl]; @Input() public color: string; @@ -67,6 +69,12 @@ export class ColorPickerComponent implements OnInit, OnChanges, OnDestroy { this.control = new ColorPickerControl(); } + if (this.control.initType === ColorType.hexa) { + this.control.initType = ColorType.hex; + } + + this.selectedPresentation = this.presentations.indexOf(this.control.initType); + if (this.color) { this.control.setValueFrom(this.color); } @@ -74,13 +82,13 @@ export class ColorPickerComponent implements OnInit, OnChanges, OnDestroy { this.subscriptions.push( this.control.valueChanges.subscribe((value) => { this.cdr.markForCheck(); - this.colorChange.emit(this.getValueByType(value, this.control.initType)); + this.colorChange.emit(this.getValueByType(value, this.presentations[this.selectedPresentation])); }) ); } changeColorFormat(event: Event) { - this.colorChange.emit(this.getValueByType(this.control.value, this.control.initType)); + this.colorChange.emit(this.getValueByType(this.control.value, this.presentations[this.selectedPresentation])); } public ngOnDestroy(): void { @@ -90,7 +98,8 @@ export class ColorPickerComponent implements OnInit, OnChanges, OnDestroy { } public ngOnChanges(changes: SimpleChanges): void { - if (this.color && this.control && this.getValueByType(this.control.value, this.control.initType) !== this.color) { + if (this.color && this.control && + this.getValueByType(this.control.value, this.presentations[this.selectedPresentation]) !== this.color) { this.control.setValueFrom(this.color); } } @@ -98,14 +107,14 @@ export class ColorPickerComponent implements OnInit, OnChanges, OnDestroy { public changePresentation(): void { this.selectedPresentation = this.selectedPresentation === this.presentations.length - 1 ? 0 : this.selectedPresentation + 1; + this.colorChange.emit(this.getValueByType(this.control.value, this.presentations[this.selectedPresentation])); + this.cdr.markForCheck(); } getValueByType(color: Color, type: ColorType): string { switch (type) { - case ColorType.hex: - return color.toHexString(); - case ColorType.hexa: - return color.toHexString(true); + case ColorType.hex || ColorType.hexa: + return color.toHexString(this.control.value.getRgba().getAlpha() !== 1); case ColorType.rgb: return color.toRgbString(); case ColorType.rgba: diff --git a/ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.html b/ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.html index 8de970de65..aedeff35b2 100644 --- a/ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.html +++ b/ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.html @@ -15,14 +15,8 @@ limitations under the License. --> - +
    - - - - - -
    diff --git a/ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.ts b/ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.ts index 784da8da25..8a9d5cbdf8 100644 --- a/ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.ts +++ b/ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.ts @@ -30,8 +30,6 @@ import { import { Router } from '@angular/router'; import { DialogComponent } from '@shared/components/dialog.component'; import { ColorPickerControl } from '@iplab/ngx-color-picker'; -import { ColorType } from '@shared/components/color-picker/color-picker.component'; -import * as tinycolor_ from 'tinycolor2'; export interface ColorPickerDialogData { color: string; @@ -50,8 +48,6 @@ export class ColorPickerDialogComponent extends DialogComponent, diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts index 10dbbef4aa..e05275a7af 100644 --- a/ui-ngx/src/app/shared/shared.module.ts +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -170,7 +170,6 @@ import { CustomPaginatorIntl } from '@shared/services/custom-paginator-intl'; import { TbScriptLangComponent } from '@shared/components/script-lang.component'; import { DateAgoPipe } from '@shared/pipe/date-ago.pipe'; import { ColorPickerComponent } from '@shared/components/color-picker/color-picker.component'; -import { ColorPickerInputComponent } from '@shared/components/color-picker/color-picker-input.component'; export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) { return markedOptionsService; @@ -312,8 +311,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) PhoneInputComponent, TbScriptLangComponent, DateAgoPipe, - ColorPickerComponent, - ColorPickerInputComponent + ColorPickerComponent ], imports: [ CommonModule, From 5d84945ccd2d0375bf5fdd66718cd667aab30310 Mon Sep 17 00:00:00 2001 From: Andrii Shvaika Date: Wed, 12 Apr 2023 14:06:38 +0300 Subject: [PATCH 075/100] Review and improvements --- .../device/DeviceProvisionServiceImpl.java | 84 ++++++++++--------- .../transport/DefaultTransportApiService.java | 38 ++++----- .../dao/device/DeviceProvisionService.java | 3 + .../provision/ProvisionFailedException.java | 4 + .../validator/DeviceProfileDataValidator.java | 14 ++-- 5 files changed, 75 insertions(+), 68 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java index e75b3c4b4e..15198d210f 100644 --- a/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java @@ -99,9 +99,44 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService { this.partitionService = partitionService; } + @Override + public ProvisionResponse provisionDeviceViaX509Chain(DeviceProfile targetProfile, ProvisionRequest provisionRequest) { + if (targetProfile == null) { + throw new ProvisionFailedException("Device profile is not specified!"); + } + if (!DeviceProfileProvisionType.X509_CERTIFICATE_CHAIN.equals(targetProfile.getProfileData().getProvisionConfiguration().getType())) { + throw new ProvisionFailedException("Device profile provision strategy is not X509_CERTIFICATE_CHAIN!"); + } + X509CertificateChainProvisionConfiguration configuration = (X509CertificateChainProvisionConfiguration) targetProfile.getProfileData().getProvisionConfiguration(); + String certificateValue = provisionRequest.getCredentialsData().getX509CertHash(); + String certificateRegEx = configuration.getCertificateRegExPattern(); + String deviceName = extractDeviceNameFromCertificateCNByRegEx(targetProfile, certificateValue, certificateRegEx); + if (StringUtils.isBlank(deviceName)) { + log.warn("Device name cannot be extracted using regex [{}] for certificate [{}]", certificateRegEx, certificateValue); + throw new ProvisionFailedException(ProvisionResponseStatus.FAILURE.name()); + } + provisionRequest.setDeviceName(deviceName); + Device targetDevice = deviceService.findDeviceByTenantIdAndName(targetProfile.getTenantId(), provisionRequest.getDeviceName()); + X509CertificateChainProvisionConfiguration x509Configuration = (X509CertificateChainProvisionConfiguration) targetProfile.getProfileData().getProvisionConfiguration(); + if (targetDevice != null && targetDevice.getDeviceProfileId().equals(targetProfile.getId())) { + DeviceCredentials deviceCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(targetDevice.getTenantId(), targetDevice.getId()); + if (deviceCredentials.getCredentialsType() == DeviceCredentialsType.X509_CERTIFICATE) { + String updatedDeviceCertificateValue = provisionRequest.getCredentialsData().getX509CertHash(); + deviceCredentials = updateDeviceCredentials(targetDevice.getTenantId(), deviceCredentials, + updatedDeviceCertificateValue, DeviceCredentialsType.X509_CERTIFICATE); + } + return new ProvisionResponse(deviceCredentials, ProvisionResponseStatus.SUCCESS); + } else if (x509Configuration.isAllowCreateNewDevicesByX509Certificate()) { + return createDevice(provisionRequest, targetProfile); + } else { + log.warn("[{}][{}] Device with name {} doesn't exist and cannot be created due incorrect configuration for X509CertificateChainProvisionConfiguration", + targetProfile.getTenantId(), targetProfile.getId(), provisionRequest.getDeviceName()); + throw new ProvisionFailedException(ProvisionResponseStatus.FAILURE.name()); + } + } + @Override public ProvisionResponse provisionDevice(ProvisionRequest provisionRequest) { - fetchAndApplyDeviceNameForX509ProvisionRequestWithRegEx(provisionRequest); String provisionRequestKey = provisionRequest.getCredentials().getProvisionDeviceKey(); String provisionRequestSecret = provisionRequest.getCredentials().getProvisionDeviceSecret(); if (!StringUtils.isEmpty(provisionRequest.getDeviceName())) { @@ -148,24 +183,7 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService { } break; case X509_CERTIFICATE_CHAIN: - if (targetProfile.getProfileData().getProvisionConfiguration().getProvisionDeviceSecret().equals(provisionRequestSecret)) { - X509CertificateChainProvisionConfiguration x509Configuration = (X509CertificateChainProvisionConfiguration) targetProfile.getProfileData().getProvisionConfiguration(); - if (targetDevice != null && targetDevice.getDeviceProfileId().equals(targetProfile.getId())) { - DeviceCredentials deviceCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(targetDevice.getTenantId(), targetDevice.getId()); - if (deviceCredentials.getCredentialsType() == DeviceCredentialsType.X509_CERTIFICATE) { - String updatedDeviceCertificateValue = provisionRequest.getCredentialsData().getX509CertHash(); - deviceCredentials = updateDeviceCredentials(targetDevice.getTenantId(), deviceCredentials, - updatedDeviceCertificateValue, DeviceCredentialsType.X509_CERTIFICATE); - } - return new ProvisionResponse(deviceCredentials, ProvisionResponseStatus.SUCCESS); - } else if (x509Configuration.isAllowCreateNewDevicesByX509Certificate()) { - return createDevice(provisionRequest, targetProfile); - } else { - log.warn("Device with name {} doesn't exist and cannot be created due incorrect configuration for X509CertificateChainProvisionConfiguration", provisionRequest.getDeviceName()); - throw new ProvisionFailedException(ProvisionResponseStatus.FAILURE.name()); - } - } - break; + throw new ProvisionFailedException("Invalid provision strategy type!"); } throw new ProvisionFailedException(ProvisionResponseStatus.NOT_FOUND.name()); } @@ -277,31 +295,21 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService { auditLogService.logEntityAction(tenantId, customerId, new UserId(UserId.NULL_UUID), device.getName(), device.getId(), device, actionType, null, provisionRequest); } - private void fetchAndApplyDeviceNameForX509ProvisionRequestWithRegEx(ProvisionRequest provisionRequest) { - DeviceProfile deviceProfile = deviceProfileService.findDeviceProfileByProvisionDeviceKey(provisionRequest.getCredentials().getProvisionDeviceKey()); - if (deviceProfile != null && DeviceProfileProvisionType.X509_CERTIFICATE_CHAIN.equals(deviceProfile.getProfileData().getProvisionConfiguration().getType())) { - X509CertificateChainProvisionConfiguration configuration = (X509CertificateChainProvisionConfiguration) deviceProfile.getProfileData().getProvisionConfiguration(); - String certificateValue = provisionRequest.getCredentialsData().getX509CertHash(); - String certificateRegEx = configuration.getCertificateRegExPattern(); - String deviceName = extractDeviceNameFromCertificateCNByRegEx(certificateValue, certificateRegEx); - if (deviceName == null) { - log.warn("Device name cannot be extracted using regex [{}] for certificate [{}]",certificateRegEx, certificateValue); - throw new ProvisionFailedException(ProvisionResponseStatus.FAILURE.name()); - } - provisionRequest.setDeviceName(deviceName); - } - } - - private String extractDeviceNameFromCertificateCNByRegEx(String x509Value, String regex) { + private String extractDeviceNameFromCertificateCNByRegEx(DeviceProfile profile, String x509Value, String regex) { try { String commonName = SslUtil.parseCommonName(SslUtil.readCertFile(x509Value)); log.trace("Extract CN [{}] by regex pattern [{}]", commonName, regex); Pattern pattern = Pattern.compile(regex); Matcher matcher = pattern.matcher(commonName); if (matcher.find()) { - return matcher.group(0); + return matcher.group(1); + } else { + return null; } - } catch (Exception ignored) {} - return null; + } catch (Exception ignored) { + log.trace("[{}][{}] Failed to extract device name using [{}] and certificate: [{}]", profile.getTenantId(), profile.getId(), regex, x509Value); + return null; + } } + } diff --git a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java index 27f20bb5fb..b99c9c2509 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java @@ -242,17 +242,20 @@ public class DefaultTransportApiService implements TransportApiService { for (String certificateValue : chain) { String certificateHash = EncryptionUtil.getSha3Hash(certificateValue); DeviceCredentials credentials = deviceCredentialsService.findDeviceCredentialsByCredentialsId(certificateHash); - if (credentials != null && credentials.getCredentialsType() == DeviceCredentialsType.X509_CERTIFICATE) { + if (credentials != null && DeviceCredentialsType.X509_CERTIFICATE.equals(credentials.getCredentialsType())) { return getDeviceInfo(credentials); } DeviceProfile deviceProfile = deviceProfileService.findDeviceProfileByProvisionDeviceKey(certificateHash); - if (deviceProfile != null && deviceProfile.getProvisionType() == X509_CERTIFICATE_CHAIN) { + if (deviceProfile != null && X509_CERTIFICATE_CHAIN.equals(deviceProfile.getProvisionType())) { String updatedDeviceProvisionSecret = chain.get(0); - ProvisionRequest provisionRequest = createProvisionRequest(deviceProfile, updatedDeviceProvisionSecret); - ProvisionResponse provisionResponse = provisionDeviceRequestAndGetResponse(provisionRequest); - if (provisionResponse != null && ProvisionResponseStatus.SUCCESS.equals(provisionResponse.getResponseStatus())) { - return getDeviceInfo(provisionResponse.getDeviceCredentials()); - } else { + ProvisionRequest provisionRequest = createProvisionRequest(updatedDeviceProvisionSecret); + try { + ProvisionResponse provisionResponse = deviceProvisionService.provisionDeviceViaX509Chain(deviceProfile, provisionRequest); + if (ProvisionResponseStatus.SUCCESS.equals(provisionResponse.getResponseStatus())) { + return getDeviceInfo(provisionResponse.getDeviceCredentials()); + } + } catch (ProvisionFailedException e) { + log.debug("[{}][{}] Failed to provision device with cert chain: {}", deviceProfile.getTenantId(), deviceProfile.getId(), provisionRequest, e); return getEmptyTransportApiResponseFuture(); } } else if (deviceProfile != null) { @@ -702,23 +705,10 @@ public class DefaultTransportApiService implements TransportApiService { return l != null ? l : 0; } - private ProvisionRequest createProvisionRequest(DeviceProfile deviceProfile, String certificateValue) { - ProvisionDeviceProfileCredentials provisionDeviceProfileCredentials = new ProvisionDeviceProfileCredentials( - deviceProfile.getProvisionDeviceKey(), - deviceProfile.getProfileData().getProvisionConfiguration().getProvisionDeviceSecret() - ); - ProvisionDeviceCredentialsData provisionDeviceCredentialsData = new ProvisionDeviceCredentialsData(null, null, null, null, certificateValue); - - return new ProvisionRequest(null, DeviceCredentialsType.X509_CERTIFICATE, provisionDeviceCredentialsData, provisionDeviceProfileCredentials); - } - - private ProvisionResponse provisionDeviceRequestAndGetResponse(ProvisionRequest provisionRequest) { - try { - return deviceProvisionService.provisionDevice(provisionRequest); - } catch (ProvisionFailedException e) { - log.error(e.getMessage()); - } - return null; + private ProvisionRequest createProvisionRequest(String certificateValue) { + return new ProvisionRequest(null, DeviceCredentialsType.X509_CERTIFICATE, + new ProvisionDeviceCredentialsData(null, null, null, null, certificateValue), + null); } } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProvisionService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProvisionService.java index 77a8d3edbc..fa47ed0694 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProvisionService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProvisionService.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.device; +import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.dao.device.provision.ProvisionFailedException; import org.thingsboard.server.dao.device.provision.ProvisionRequest; import org.thingsboard.server.dao.device.provision.ProvisionResponse; @@ -22,4 +23,6 @@ import org.thingsboard.server.dao.device.provision.ProvisionResponse; public interface DeviceProvisionService { ProvisionResponse provisionDevice(ProvisionRequest provisionRequest) throws ProvisionFailedException; + + ProvisionResponse provisionDeviceViaX509Chain(DeviceProfile deviceProfile, ProvisionRequest provisionRequest); } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/provision/ProvisionFailedException.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/provision/ProvisionFailedException.java index 2579b1f9cf..f3fc6f97fc 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/provision/ProvisionFailedException.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/provision/ProvisionFailedException.java @@ -16,7 +16,11 @@ package org.thingsboard.server.dao.device.provision; public class ProvisionFailedException extends RuntimeException { + + private static final long serialVersionUID = 1673991117668477401L; + public ProvisionFailedException(String errorMsg) { super(errorMsg); } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java index 7b142d08e3..d092c34861 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java @@ -17,6 +17,7 @@ package org.thingsboard.server.dao.service.validator; import com.google.protobuf.Descriptors; import com.google.protobuf.DynamicMessage; +import lombok.extern.slf4j.Slf4j; import org.eclipse.leshan.core.util.SecurityUtil; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; @@ -72,6 +73,7 @@ import java.util.HashSet; import java.util.List; import java.util.Set; +@Slf4j @Component public class DeviceProfileDataValidator extends AbstractHasOtaPackageValidator { @@ -98,10 +100,10 @@ public class DeviceProfileDataValidator extends AbstractHasOtaPackageValidator Date: Wed, 12 Apr 2023 15:14:38 +0300 Subject: [PATCH 076/100] Notification preview for first recipient; Slack notification preview; increase timeouts for testDelayedNotificationRequest --- .../controller/NotificationController.java | 53 ++++++----- .../NotificationTargetController.java | 2 +- .../DefaultNotificationCenter.java | 2 +- .../notification/NotificationApiTest.java | 94 ++++++++++--------- .../targets/NotificationTargetType.java | 7 ++ 5 files changed, 84 insertions(+), 74 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/NotificationController.java b/application/src/main/java/org/thingsboard/server/controller/NotificationController.java index 6c27887e1a..d36eb9913d 100644 --- a/application/src/main/java/org/thingsboard/server/controller/NotificationController.java +++ b/application/src/main/java/org/thingsboard/server/controller/NotificationController.java @@ -64,7 +64,9 @@ import org.thingsboard.server.service.security.permission.Operation; import org.thingsboard.server.service.security.permission.Resource; import javax.validation.Valid; +import java.util.Comparator; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -201,9 +203,6 @@ public class NotificationController extends BaseController { public NotificationRequestPreview getNotificationRequestPreview(@RequestBody @Valid NotificationRequest request, @RequestParam(defaultValue = "20") int recipientsPreviewSize, @AuthenticationPrincipal SecurityUser user) throws ThingsboardException { - NotificationRequestPreview preview = new NotificationRequestPreview(); - - request.setOriginatorEntityId(user.getId()); NotificationTemplate template; if (request.getTemplateId() != null) { template = checkEntityId(request.getTemplateId(), notificationTemplateService::findNotificationTemplateById, Operation.READ); @@ -213,33 +212,23 @@ public class NotificationController extends BaseController { if (template == null) { throw new IllegalArgumentException("Template is missing"); } - NotificationProcessingContext tmpProcessingCtx = NotificationProcessingContext.builder() - .tenantId(user.getTenantId()) - .request(request) - .template(template) - .settings(null) - .build(); + request.setOriginatorEntityId(user.getId()); + List targets = request.getTargets().stream() + .map(NotificationTargetId::new) + .map(targetId -> notificationTargetService.findNotificationTargetById(user.getTenantId(), targetId)) + .sorted(Comparator.comparing(target -> target.getConfiguration().getType())) + .collect(Collectors.toList()); - Map processedTemplates = tmpProcessingCtx.getDeliveryMethods().stream() - .collect(Collectors.toMap(m -> m, deliveryMethod -> { - NotificationRecipient recipient = null; - if (NotificationTargetType.PLATFORM_USERS.getSupportedDeliveryMethods().contains(deliveryMethod)) { - recipient = userService.findUserById(user.getTenantId(), user.getId()); - } - return tmpProcessingCtx.getProcessedTemplate(deliveryMethod, recipient); - })); - preview.setProcessedTemplates(processedTemplates); + NotificationRequestPreview preview = new NotificationRequestPreview(); - // generic permission Set recipientsPreview = new LinkedHashSet<>(); - Map recipientsCountByTarget = new HashMap<>(); - - List targets = notificationTargetService.findNotificationTargetsByTenantIdAndIds(user.getTenantId(), - request.getTargets().stream().map(NotificationTargetId::new).collect(Collectors.toList())); + Map recipientsCountByTarget = new LinkedHashMap<>(); + Map firstRecipient = new HashMap<>(); for (NotificationTarget target : targets) { int recipientsCount; List recipientsPart; - if (target.getConfiguration().getType() == NotificationTargetType.PLATFORM_USERS) { + NotificationTargetType targetType = target.getConfiguration().getType(); + if (targetType == NotificationTargetType.PLATFORM_USERS) { PageData recipients = notificationTargetService.findRecipientsForNotificationTargetConfig(user.getTenantId(), (PlatformUsersNotificationTargetConfig) target.getConfiguration(), new PageLink(recipientsPreviewSize)); recipientsCount = (int) recipients.getTotalElements(); @@ -248,7 +237,7 @@ public class NotificationController extends BaseController { recipientsCount = 1; recipientsPart = List.of(((SlackNotificationTargetConfig) target.getConfiguration()).getConversation()); } - + firstRecipient.putIfAbsent(targetType, !recipientsPart.isEmpty() ? recipientsPart.get(0) : null); for (NotificationRecipient recipient : recipientsPart) { if (recipientsPreview.size() < recipientsPreviewSize) { recipientsPreview.add(recipient.getTitle()); @@ -258,11 +247,23 @@ public class NotificationController extends BaseController { } recipientsCountByTarget.put(target.getName(), recipientsCount); } - preview.setRecipientsPreview(recipientsPreview); preview.setRecipientsCountByTarget(recipientsCountByTarget); preview.setTotalRecipientsCount(recipientsCountByTarget.values().stream().mapToInt(Integer::intValue).sum()); + NotificationProcessingContext ctx = NotificationProcessingContext.builder() + .tenantId(user.getTenantId()) + .request(request) + .template(template) + .settings(null) + .build(); + Map processedTemplates = ctx.getDeliveryMethods().stream() + .collect(Collectors.toMap(m -> m, deliveryMethod -> { + NotificationTargetType targetType = NotificationTargetType.forDeliveryMethod(deliveryMethod); + return ctx.getProcessedTemplate(deliveryMethod, firstRecipient.get(targetType)); + })); + preview.setProcessedTemplates(processedTemplates); + return preview; } diff --git a/application/src/main/java/org/thingsboard/server/controller/NotificationTargetController.java b/application/src/main/java/org/thingsboard/server/controller/NotificationTargetController.java index 4fd6b93022..6374b65710 100644 --- a/application/src/main/java/org/thingsboard/server/controller/NotificationTargetController.java +++ b/application/src/main/java/org/thingsboard/server/controller/NotificationTargetController.java @@ -71,7 +71,7 @@ public class NotificationTargetController extends BaseController { private final NotificationTargetService notificationTargetService; @ApiOperation(value = "Save notification target (saveNotificationTarget)", - notes = "Create or update notification target.\n\n" + + notes = "Create or update notification target." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PostMapping("/target") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") diff --git a/application/src/main/java/org/thingsboard/server/service/notification/DefaultNotificationCenter.java b/application/src/main/java/org/thingsboard/server/service/notification/DefaultNotificationCenter.java index b4fba04212..fa7e973843 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/DefaultNotificationCenter.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/DefaultNotificationCenter.java @@ -130,7 +130,7 @@ public class DefaultNotificationCenter extends AbstractSubscriptionService imple } if (ruleId == null) { if (targets.stream().noneMatch(target -> target.getConfiguration().getType().getSupportedDeliveryMethods().contains(deliveryMethod))) { - throw new IllegalArgumentException("Target for " + deliveryMethod.getName() + " delivery method is missing"); + throw new IllegalArgumentException("Recipients for " + deliveryMethod.getName() + " delivery method not chosen"); } } }); diff --git a/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java b/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java index 9dd34296ef..f6a296ab7e 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java @@ -40,6 +40,7 @@ import org.thingsboard.server.common.data.notification.targets.platform.Customer import org.thingsboard.server.common.data.notification.targets.platform.PlatformUsersNotificationTargetConfig; import org.thingsboard.server.common.data.notification.targets.platform.UserListFilter; import org.thingsboard.server.common.data.notification.targets.slack.SlackConversation; +import org.thingsboard.server.common.data.notification.targets.slack.SlackConversationType; import org.thingsboard.server.common.data.notification.targets.slack.SlackNotificationTargetConfig; import org.thingsboard.server.common.data.notification.template.DeliveryMethodNotificationTemplate; import org.thingsboard.server.common.data.notification.template.EmailDeliveryMethodNotificationTemplate; @@ -49,7 +50,6 @@ import org.thingsboard.server.common.data.notification.template.SlackDeliveryMet import org.thingsboard.server.common.data.notification.template.SmsDeliveryMethodNotificationTemplate; import org.thingsboard.server.common.data.notification.template.WebDeliveryMethodNotificationTemplate; import org.thingsboard.server.common.data.security.Authority; -import org.thingsboard.server.dao.DaoUtil; import org.thingsboard.server.dao.notification.NotificationDao; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.service.executors.DbCallbackExecutorService; @@ -226,13 +226,13 @@ public class NotificationApiTest extends AbstractNotificationApiTest { NotificationRequest notificationRequest = submitNotificationRequest(notificationTarget.getId(), notificationText, 5); assertThat(notificationRequest.getStatus()).isEqualTo(NotificationRequestStatus.SCHEDULED); await().atLeast(4, TimeUnit.SECONDS) - .atMost(6, TimeUnit.SECONDS) + .atMost(15, TimeUnit.SECONDS) .until(() -> wsClient.getLastMsg() != null); Notification delayedNotification = wsClient.getLastDataUpdate().getUpdate(); assertThat(delayedNotification).extracting(Notification::getText).isEqualTo(notificationText); assertThat(delayedNotification.getCreatedTime() - notificationRequest.getCreatedTime()) - .isCloseTo(TimeUnit.SECONDS.toMillis(5), Offset.offset(500L)); + .isCloseTo(TimeUnit.SECONDS.toMillis(5), Offset.offset(10000L)); assertThat(findNotificationRequest(notificationRequest.getId()).getStatus()).isEqualTo(NotificationRequestStatus.SENT); } @@ -324,16 +324,17 @@ public class NotificationApiTest extends AbstractNotificationApiTest { @Test public void testNotificationRequestPreview() throws Exception { - NotificationTarget target1 = new NotificationTarget(); - target1.setName("Me"); - PlatformUsersNotificationTargetConfig target1Config = new PlatformUsersNotificationTargetConfig(); + NotificationTarget tenantAdminTarget = new NotificationTarget(); + tenantAdminTarget.setName("Me"); + PlatformUsersNotificationTargetConfig tenantAdminTargetConfig = new PlatformUsersNotificationTargetConfig(); UserListFilter userListFilter = new UserListFilter(); - userListFilter.setUsersIds(DaoUtil.toUUIDs(List.of(tenantAdminUserId))); - target1Config.setUsersFilter(userListFilter); - target1.setConfiguration(target1Config); - target1 = saveNotificationTarget(target1); + userListFilter.setUsersIds(List.of(tenantAdminUserId.getId())); + tenantAdminTargetConfig.setUsersFilter(userListFilter); + tenantAdminTarget.setConfiguration(tenantAdminTargetConfig); + tenantAdminTarget = saveNotificationTarget(tenantAdminTarget); List recipients = new ArrayList<>(); recipients.add(TENANT_ADMIN_EMAIL); + String firstRecipientEmail = TENANT_ADMIN_EMAIL; createDifferentCustomer(); loginTenantAdmin(); @@ -347,21 +348,32 @@ public class NotificationApiTest extends AbstractNotificationApiTest { customerUser = createUser(customerUser, "12345678"); recipients.add(customerUser.getEmail()); } - NotificationTarget target2 = new NotificationTarget(); - target2.setName("Other customer users"); - PlatformUsersNotificationTargetConfig target2Config = new PlatformUsersNotificationTargetConfig(); + NotificationTarget customerUsersTarget = new NotificationTarget(); + customerUsersTarget.setName("Other customer users"); + PlatformUsersNotificationTargetConfig customerUsersTargetConfig = new PlatformUsersNotificationTargetConfig(); CustomerUsersFilter customerUsersFilter = new CustomerUsersFilter(); customerUsersFilter.setCustomerId(differentCustomerId.getId()); - target2Config.setUsersFilter(customerUsersFilter); - target2.setConfiguration(target2Config); - target2 = saveNotificationTarget(target2); - + customerUsersTargetConfig.setUsersFilter(customerUsersFilter); + customerUsersTarget.setConfiguration(customerUsersTargetConfig); + customerUsersTarget = saveNotificationTarget(customerUsersTarget); + + NotificationTarget slackTarget = new NotificationTarget(); + slackTarget.setName("Slack user"); + SlackNotificationTargetConfig slackTargetConfig = new SlackNotificationTargetConfig(); + slackTargetConfig.setConversationType(SlackConversationType.DIRECT); + SlackConversation slackConversation = new SlackConversation(); + slackConversation.setId("U1234567"); + slackConversation.setTitle("@jdoe (John Doe)"); + slackConversation.setWholeName("John Doe"); + slackTargetConfig.setConversation(slackConversation); + slackTarget.setConfiguration(slackTargetConfig); + slackTarget = saveNotificationTarget(slackTarget); + recipients.add(slackConversation.getTitle()); NotificationTemplate notificationTemplate = new NotificationTemplate(); notificationTemplate.setNotificationType(NotificationType.GENERAL); notificationTemplate.setName("Test template"); - String requestorEmail = TENANT_ADMIN_EMAIL; NotificationTemplateConfig templateConfig = new NotificationTemplateConfig(); HashMap templates = new HashMap<>(); templateConfig.setDeliveryMethodsTemplates(templates); @@ -369,69 +381,59 @@ public class NotificationApiTest extends AbstractNotificationApiTest { WebDeliveryMethodNotificationTemplate webNotificationTemplate = new WebDeliveryMethodNotificationTemplate(); webNotificationTemplate.setEnabled(true); - webNotificationTemplate.setBody("Message for WEB: ${recipientEmail} ${unknownParam}"); - webNotificationTemplate.setSubject("Subject for WEB: ${recipientEmail}"); + webNotificationTemplate.setSubject("WEB SUBJECT: ${recipientEmail}"); + webNotificationTemplate.setBody("WEB: ${recipientEmail} ${unknownParam}"); templates.put(NotificationDeliveryMethod.WEB, webNotificationTemplate); SmsDeliveryMethodNotificationTemplate smsNotificationTemplate = new SmsDeliveryMethodNotificationTemplate(); smsNotificationTemplate.setEnabled(true); - smsNotificationTemplate.setBody("Message for SMS: ${recipientEmail}"); + smsNotificationTemplate.setBody("SMS: ${recipientEmail}"); templates.put(NotificationDeliveryMethod.SMS, smsNotificationTemplate); EmailDeliveryMethodNotificationTemplate emailNotificationTemplate = new EmailDeliveryMethodNotificationTemplate(); emailNotificationTemplate.setEnabled(true); - emailNotificationTemplate.setSubject("Subject for EMAIL: ${recipientEmail}"); - emailNotificationTemplate.setBody("Message for EMAIL: ${recipientEmail}"); + emailNotificationTemplate.setSubject("EMAIL SUBJECT: ${recipientEmail}"); + emailNotificationTemplate.setBody("EMAIL: ${recipientEmail}"); templates.put(NotificationDeliveryMethod.EMAIL, emailNotificationTemplate); SlackDeliveryMethodNotificationTemplate slackNotificationTemplate = new SlackDeliveryMethodNotificationTemplate(); slackNotificationTemplate.setEnabled(true); - slackNotificationTemplate.setBody("Message for SLACK: ${recipientEmail}"); + slackNotificationTemplate.setBody("SLACK: ${recipientFirstName} ${recipientLastName}"); templates.put(NotificationDeliveryMethod.SLACK, slackNotificationTemplate); notificationTemplate = saveNotificationTemplate(notificationTemplate); - NotificationRequest notificationRequest = new NotificationRequest(); - notificationRequest.setTargets(List.of(target1.getUuidId(), target2.getUuidId())); + notificationRequest.setTargets(List.of(tenantAdminTarget.getUuidId(), customerUsersTarget.getUuidId(), slackTarget.getUuidId())); notificationRequest.setTemplateId(notificationTemplate.getId()); notificationRequest.setAdditionalConfig(new NotificationRequestConfig()); NotificationRequestPreview preview = doPost("/api/notification/request/preview", notificationRequest, NotificationRequestPreview.class); - assertThat(preview.getRecipientsCountByTarget().get(target1.getName())).isEqualTo(1); - assertThat(preview.getRecipientsCountByTarget().get(target2.getName())).isEqualTo(customerUsersCount); - assertThat(preview.getTotalRecipientsCount()).isEqualTo(1 + customerUsersCount); + assertThat(preview.getRecipientsCountByTarget().get(tenantAdminTarget.getName())).isEqualTo(1); + assertThat(preview.getRecipientsCountByTarget().get(customerUsersTarget.getName())).isEqualTo(customerUsersCount); + assertThat(preview.getRecipientsCountByTarget().get(slackTarget.getName())).isEqualTo(1); + + assertThat(preview.getTotalRecipientsCount()).isEqualTo(2 + customerUsersCount); assertThat(preview.getRecipientsPreview()).containsAll(recipients); Map processedTemplates = preview.getProcessedTemplates(); assertThat(processedTemplates.get(NotificationDeliveryMethod.WEB)).asInstanceOf(type(WebDeliveryMethodNotificationTemplate.class)) .satisfies(template -> { - assertThat(template.getBody()) - .startsWith("Message for WEB") - .endsWith(requestorEmail + " ${unknownParam}"); - assertThat(template.getSubject()) - .startsWith("Subject for WEB") - .endsWith(requestorEmail); + assertThat(template.getSubject()).isEqualTo("WEB SUBJECT: " + firstRecipientEmail); + assertThat(template.getBody()).isEqualTo("WEB: " + firstRecipientEmail + " ${unknownParam}"); }); assertThat(processedTemplates.get(NotificationDeliveryMethod.SMS)).asInstanceOf(type(SmsDeliveryMethodNotificationTemplate.class)) .satisfies(template -> { - assertThat(template.getBody()) - .startsWith("Message for SMS") - .endsWith(requestorEmail); + assertThat(template.getBody()).isEqualTo("SMS: " + firstRecipientEmail); }); assertThat(processedTemplates.get(NotificationDeliveryMethod.EMAIL)).asInstanceOf(type(EmailDeliveryMethodNotificationTemplate.class)) .satisfies(template -> { - assertThat(template.getBody()) - .startsWith("Message for EMAIL") - .endsWith(requestorEmail); - assertThat(template.getSubject()) - .startsWith("Subject for EMAIL") - .endsWith(requestorEmail); + assertThat(template.getSubject()).isEqualTo("EMAIL SUBJECT: " + firstRecipientEmail); + assertThat(template.getBody()).isEqualTo("EMAIL: " + firstRecipientEmail); }); assertThat(processedTemplates.get(NotificationDeliveryMethod.SLACK)).asInstanceOf(type(SlackDeliveryMethodNotificationTemplate.class)) .satisfies(template -> { - assertThat(template.getBody()) - .isEqualTo("Message for SLACK: ${recipientEmail}"); // ${recipientEmail} should not be processed + assertThat(template.getBody()).isEqualTo("SLACK: John Doe"); }); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTargetType.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTargetType.java index 0c2441e37d..1254654ecf 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTargetType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTargetType.java @@ -19,6 +19,7 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; +import java.util.Arrays; import java.util.Set; @RequiredArgsConstructor @@ -30,4 +31,10 @@ public enum NotificationTargetType { @Getter private final Set supportedDeliveryMethods; + public static NotificationTargetType forDeliveryMethod(NotificationDeliveryMethod deliveryMethod) { + return Arrays.stream(values()) + .filter(targetType -> targetType.getSupportedDeliveryMethods().contains(deliveryMethod)) + .findFirst().orElse(null); + } + } From dae9a422a15e02999ca6447d3c3aa0375a3f37d8 Mon Sep 17 00:00:00 2001 From: ViacheslavKlimov Date: Wed, 12 Apr 2023 16:44:24 +0300 Subject: [PATCH 077/100] Fix entities ids replaced with tenant id in dashboard config when importing --- .../impl/BaseEntityImportService.java | 46 +++++++--------- .../impl/DashboardImportService.java | 4 +- .../DefaultEntitiesVersionControlService.java | 6 +- .../sync/ie/ExportImportServiceSqlTest.java | 55 ++++++++++++------- monitoring/src/main/conf/logback.xml | 7 ++- 5 files changed, 66 insertions(+), 52 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java index f72ac8a11f..610f1ee0b5 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java @@ -23,7 +23,6 @@ import lombok.extern.slf4j.Slf4j; import org.checkerframework.checker.nullness.qual.Nullable; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; -import org.springframework.transaction.annotation.Transactional; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.EntityType; @@ -87,7 +86,6 @@ public abstract class BaseEntityImportService importEntity(EntitiesImportCtx ctx, D exportData) throws ThingsboardException { EntityImportResult importResult = new EntityImportResult<>(); @@ -336,27 +334,37 @@ public abstract class BaseEntityImportService externalIdOpt = buildEntityId(entityType, externalUuid); - if (!externalIdOpt.isPresent()) { + Optional externalId = buildEntityId(entityType, externalUuid); + if (externalId.isEmpty()) { continue; } - EntityId internalId = ctx.getInternalId(externalIdOpt.get()); + EntityId internalId = ctx.getInternalId(externalId.get()); if (internalId != null) { return Optional.of(internalId); } } if (fetchAllUUIDs) { - for (EntityType entityType : hints) { - Optional internalId = lookupInDb(externalUuid, entityType); - if (internalId.isPresent()) return internalId; - } + Set processLast = Set.of(EntityType.TENANT); + List entityTypes = new ArrayList<>(hints); for (EntityType entityType : EntityType.values()) { - if (hints.contains(entityType)) { + if (!hints.contains(entityType) && !processLast.contains(entityType)) { + entityTypes.add(entityType); + } + } + entityTypes.addAll(processLast); + + for (EntityType entityType : entityTypes) { + Optional externalId = buildEntityId(entityType, externalUuid); + if (externalId.isEmpty() || ctx.isNotFound(externalId.get())) { continue; } - Optional internalId = lookupInDb(externalUuid, entityType); - if (internalId.isPresent()) return internalId; + EntityId internalId = getInternalId(externalId.get(), false); + if (internalId != null) { + return Optional.of(internalId); + } else { + ctx.registerNotFound(externalId.get()); + } } } @@ -364,20 +372,6 @@ public abstract class BaseEntityImportService lookupInDb(UUID externalUuid, EntityType entityType) { - Optional externalIdOpt = buildEntityId(entityType, externalUuid); - if (externalIdOpt.isEmpty() || ctx.isNotFound(externalIdOpt.get())) { - return Optional.empty(); - } - EntityId internalId = getInternalId(externalIdOpt.get(), false); - if (internalId != null) { - return Optional.of(internalId); - } else { - ctx.registerNotFound(externalIdOpt.get()); - } - return Optional.empty(); - } - private Optional buildEntityId(EntityType entityType, UUID externalUuid) { try { return Optional.of(EntityIdFactory.getByTypeAndUuid(entityType, externalUuid)); diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/DashboardImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/DashboardImportService.java index 611b536808..250026c71c 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/DashboardImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/DashboardImportService.java @@ -65,10 +65,10 @@ public class DashboardImportService extends BaseEntityImportService exportData, IdProvider idProvider) { for (JsonNode entityAlias : dashboard.getEntityAliasesConfig()) { - replaceIdsRecursively(ctx, idProvider, entityAlias, Collections.emptySet(), HINTS); + replaceIdsRecursively(ctx, idProvider, entityAlias, Set.of("id"), HINTS); } for (JsonNode widgetConfig : dashboard.getWidgetsConfig()) { - replaceIdsRecursively(ctx, idProvider, JacksonUtil.getSafely(widgetConfig, "config", "actions"), Collections.singleton("id"), HINTS); + replaceIdsRecursively(ctx, idProvider, JacksonUtil.getSafely(widgetConfig, "config", "actions"), Set.of("id"), HINTS); } return dashboard; } diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultEntitiesVersionControlService.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultEntitiesVersionControlService.java index 8ffd4796fe..0e08ee9c48 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultEntitiesVersionControlService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultEntitiesVersionControlService.java @@ -44,7 +44,6 @@ import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; -import org.thingsboard.server.common.data.util.ThrowingRunnable; import org.thingsboard.server.common.data.sync.ie.EntityExportData; import org.thingsboard.server.common.data.sync.ie.EntityExportSettings; import org.thingsboard.server.common.data.sync.ie.EntityImportResult; @@ -69,6 +68,7 @@ import org.thingsboard.server.common.data.sync.vc.request.load.EntityTypeVersion import org.thingsboard.server.common.data.sync.vc.request.load.SingleEntityVersionLoadRequest; import org.thingsboard.server.common.data.sync.vc.request.load.VersionLoadConfig; import org.thingsboard.server.common.data.sync.vc.request.load.VersionLoadRequest; +import org.thingsboard.server.common.data.util.ThrowingRunnable; import org.thingsboard.server.dao.DaoUtil; import org.thingsboard.server.dao.edge.EdgeService; import org.thingsboard.server.dao.exception.DeviceCredentialsValidationException; @@ -259,7 +259,7 @@ public class DefaultEntitiesVersionControlService implements EntitiesVersionCont return gitServiceQueue.listEntitiesAtVersion(tenantId, versionId); } - @SuppressWarnings({"UnstableApiUsage", "rawtypes"}) + @SuppressWarnings({"rawtypes"}) @Override public UUID loadEntitiesVersion(User user, VersionLoadRequest request) throws Exception { EntitiesImportCtx ctx = new EntitiesImportCtx(UUID.randomUUID(), user, request.getVersionId()); @@ -402,7 +402,7 @@ public class DefaultEntitiesVersionControlService implements EntitiesVersionCont ctx.getImportedEntities().computeIfAbsent(entityType, t -> new HashSet<>()) .add(importResult.getSavedEntity().getId()); } - log.debug("Imported {} pack for tenant {}", entityType, ctx.getTenantId()); + log.debug("Imported {} pack ({}) for tenant {}", entityType, entityDataList.size(), ctx.getTenantId()); offset += limit; } while (entityDataList.size() == limit); } diff --git a/application/src/test/java/org/thingsboard/server/service/sync/ie/ExportImportServiceSqlTest.java b/application/src/test/java/org/thingsboard/server/service/sync/ie/ExportImportServiceSqlTest.java index 26e8b3e92a..e493916b8a 100644 --- a/application/src/test/java/org/thingsboard/server/service/sync/ie/ExportImportServiceSqlTest.java +++ b/application/src/test/java/org/thingsboard/server/service/sync/ie/ExportImportServiceSqlTest.java @@ -68,7 +68,6 @@ import org.thingsboard.server.service.ota.OtaPackageStateService; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -254,21 +253,25 @@ public class ExportImportServiceSqlTest extends BaseExportImportServiceTest { Asset asset1 = createAsset(tenantId1, null, assetProfile.getId(), "Asset 1"); Asset asset2 = createAsset(tenantId1, null, assetProfile.getId(), "Asset 2"); Dashboard dashboard = createDashboard(tenantId1, null, "Dashboard 1"); + DeviceProfile existingDeviceProfile = createDeviceProfile(tenantId2, null, null, "Existing"); + String aliasId = "23c4185d-1497-9457-30b2-6d91e69a5b2c"; + String unknownUuid = "ea0dc8b0-3d85-11ed-9200-77fc04fa14fa"; String entityAliases = "{\n" + - "\t\"23c4185d-1497-9457-30b2-6d91e69a5b2c\": {\n" + - "\t\t\"alias\": \"assets\",\n" + - "\t\t\"filter\": {\n" + - "\t\t\t\"entityList\": [\n" + - "\t\t\t\t\"" + asset1.getId().toString() + "\",\n" + - "\t\t\t\t\"" + asset2.getId().toString() + "\"\n" + - "\t\t\t],\n" + - "\t\t\t\"entityType\": \"ASSET\",\n" + - "\t\t\t\"resolveMultiple\": true,\n" + - "\t\t\t\"type\": \"entityList\"\n" + - "\t\t},\n" + - "\t\t\"id\": \"23c4185d-1497-9457-30b2-6d91e69a5b2c\"\n" + - "\t}\n" + + "\"" + aliasId + "\": {\n" + + "\"alias\": \"assets\",\n" + + "\"filter\": {\n" + + "\"entityList\": [\n" + + "\"" + asset1.getId().toString() + "\",\n" + + "\"" + asset2.getId().toString() + "\",\n" + + "\"" + tenantId1.getId().toString() + "\",\n" + + "\"" + existingDeviceProfile.getId().toString() + "\",\n" + + "\"" + unknownUuid + "\"\n" + + "],\n" + + "\"resolveMultiple\": true\n" + + "},\n" + + "\"id\": \"" + aliasId + "\"\n" + + "}\n" + "}"; ObjectNode dashboardConfiguration = JacksonUtil.newObjectNode(); dashboardConfiguration.set("entityAliases", JacksonUtil.toJsonNode(entityAliases)); @@ -287,11 +290,23 @@ public class ExportImportServiceSqlTest extends BaseExportImportServiceTest { Asset importedAsset2 = importEntity(tenantAdmin2, asset2ExportData).getSavedEntity(); Dashboard importedDashboard = importEntity(tenantAdmin2, dashboardExportData).getSavedEntity(); - Set entityAliasEntitiesIds = Streams.stream(importedDashboard.getConfiguration() - .get("entityAliases").elements().next().get("filter").get("entityList").elements()) - .map(JsonNode::asText).collect(Collectors.toSet()); - assertThat(entityAliasEntitiesIds).doesNotContain(asset1.getId().toString(), asset2.getId().toString()); - assertThat(entityAliasEntitiesIds).contains(importedAsset1.getId().toString(), importedAsset2.getId().toString()); + Map.Entry entityAlias = importedDashboard.getConfiguration().get("entityAliases").fields().next(); + assertThat(entityAlias.getKey()).isEqualTo(aliasId); + assertThat(entityAlias.getValue().get("id").asText()).isEqualTo(aliasId); + + List aliasEntitiesIds = Streams.stream(entityAlias.getValue().get("filter").get("entityList").elements()) + .map(JsonNode::asText).collect(Collectors.toList()); + assertThat(aliasEntitiesIds).size().isEqualTo(5); + assertThat(aliasEntitiesIds).element(0).as("external asset 1 was replaced with imported one") + .isEqualTo(importedAsset1.getId().toString()); + assertThat(aliasEntitiesIds).element(1).as("external asset 2 was replaced with imported one") + .isEqualTo(importedAsset2.getId().toString()); + assertThat(aliasEntitiesIds).element(2).as("external tenant id was replaced with new tenant id") + .isEqualTo(tenantId2.toString()); + assertThat(aliasEntitiesIds).element(3).as("existing device profile id was left as is") + .isEqualTo(existingDeviceProfile.getId().toString()); + assertThat(aliasEntitiesIds).element(4).as("unresolved uuid was replaced with tenant id") + .isEqualTo(tenantId2.toString()); } @@ -469,7 +484,7 @@ public class ExportImportServiceSqlTest extends BaseExportImportServiceTest { Device device = createDevice(tenantId1, null, deviceProfile.getId(), "Device 1"); Map entitiesExportData = Stream.of(customer.getId(), asset.getId(), device.getId(), - ruleChain.getId(), dashboard.getId(), assetProfile.getId(), deviceProfile.getId()) + ruleChain.getId(), dashboard.getId(), assetProfile.getId(), deviceProfile.getId()) .map(entityId -> { try { return exportEntity(tenantAdmin1, entityId, EntityExportSettings.builder() diff --git a/monitoring/src/main/conf/logback.xml b/monitoring/src/main/conf/logback.xml index 58e49c7639..3c1e1382b7 100644 --- a/monitoring/src/main/conf/logback.xml +++ b/monitoring/src/main/conf/logback.xml @@ -33,14 +33,19 @@ %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + - + From 050b91801972d982eacf1d82471d1c8b692763ae Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Wed, 12 Apr 2023 17:48:43 +0300 Subject: [PATCH 078/100] UI: Add notification sms template max length limit --- .../sent/sent-notification-dialog.component.html | 7 ++++++- .../pages/notification/template/template-configuration.ts | 2 +- .../template/template-notification-dialog.component.html | 7 ++++++- ui-ngx/src/assets/locale/locale.constant-en_US.json | 1 + 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.html b/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.html index 3aa7c98069..1ebd5a098e 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.html @@ -246,7 +246,7 @@ trigger-text="{{ 'notification.see-documentation' | translate }}">
    - + notification.message