diff --git a/application/src/main/data/json/tenant/dashboards/gateways.json b/application/src/main/data/json/tenant/dashboards/gateways.json
index 4ccee86d5e..59e5672b53 100644
--- a/application/src/main/data/json/tenant/dashboards/gateways.json
+++ b/application/src/main/data/json/tenant/dashboards/gateways.json
@@ -1,8 +1,8 @@
{
"title": "ThingsBoard IoT Gateways",
- "image": null,
+ "image": "tb-image:Z2F0ZXdheS1kYXNoYm9hcmQucG5n:Z2F0ZXdheS1kYXNoYm9hcmQucG5n;data:image/png;base64,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",
"mobileHide": false,
- "mobileOrder": null,
+ "mobileOrder": 6,
"configuration": {
"description": "",
"widgets": {
@@ -136,7 +136,7 @@
{
"name": "active_connectors",
"type": "attribute",
- "label": "Active Connectors",
+ "label": "Enabled Connectors",
"color": "#3f51b5",
"settings": {
"columnWidth": "20%",
@@ -248,7 +248,7 @@
"type": "customPretty",
"customHtml": "
\r\n",
"customCss": ".add-entity-form {\r\n min-width: 400px !important;\r\n}\r\n\r\n.add-entity-form .boolean-value-input {\r\n padding-left: 5px;\r\n}\r\n\r\n.add-entity-form .boolean-value-input .checkbox-label {\r\n margin-bottom: 8px;\r\n color: rgba(0,0,0,0.54);\r\n font-size: 12px;\r\n}\r\n\r\n.relations-list .header {\r\n padding-right: 5px;\r\n padding-bottom: 5px;\r\n padding-left: 5px;\r\n}\r\n\r\n.relations-list .header .cell {\r\n padding-right: 5px;\r\n padding-left: 5px;\r\n font-size: 12px;\r\n font-weight: 700;\r\n color: rgba(0, 0, 0, .54);\r\n white-space: nowrap;\r\n}\r\n\r\n.relations-list .mat-form-field-infix {\r\n width: auto !important;\r\n}\r\n\r\n.relations-list .body {\r\n padding-right: 5px;\r\n padding-bottom: 15px;\r\n padding-left: 5px;\r\n}\r\n\r\n.relations-list .body .row {\r\n padding-top: 5px;\r\n}\r\n\r\n.relations-list .body .cell {\r\n padding-right: 5px;\r\n padding-left: 5px;\r\n}\r\n\r\n.relations-list .body .md-button {\r\n margin: 0;\r\n}\r\n\r\n",
- "customFunction": "let $injector = widgetContext.$scope.$injector;\r\nlet customDialog = $injector.get(widgetContext.servicesMap.get('customDialog'));\r\nlet assetService = $injector.get(widgetContext.servicesMap.get('assetService'));\r\nlet deviceService = $injector.get(widgetContext.servicesMap.get('deviceService'));\r\nlet attributeService = $injector.get(widgetContext.servicesMap.get('attributeService'));\r\nlet entityRelationService = $injector.get(widgetContext.servicesMap.get('entityRelationService'));\r\nlet userSettingsService = $injector.get(widgetContext.servicesMap.get('userSettingsService'));\r\n\r\nopenAddEntityDialog();\r\n\r\nfunction openAddEntityDialog() {\r\n customDialog.customDialog(htmlTemplate, AddEntityDialogController).subscribe();\r\n}\r\n\r\nfunction AddEntityDialogController(instance) {\r\n let vm = instance;\r\n let userSettings;\r\n userSettingsService.loadUserSettings().subscribe(settings=> {\r\n userSettings = settings;\r\n if (!userSettings.createdGatewaysCount) userSettings.createdGatewaysCount = 0;\r\n });\r\n \r\n\r\n vm.addEntityFormGroup = vm.fb.group({\r\n entityName: ['', [vm.validators.required]],\r\n entityType: ['DEVICE'],\r\n entityLabel: [''],\r\n type: ['', [vm.validators.required]],\r\n });\r\n\r\n vm.cancel = function() {\r\n vm.dialogRef.close(null);\r\n };\r\n\r\n\r\n vm.save = function($event) {\r\n vm.addEntityFormGroup.markAsPristine();\r\n saveEntityObservable().subscribe(\r\n function (device) {\r\n widgetContext.updateAliases();\r\n userSettingsService.putUserSettings({ createdGatewaysCount: ++userSettings.createdGatewaysCount }).subscribe(_=>{\r\n });\r\n vm.dialogRef.close(null);\r\n openCommandDialog(device, $event);\r\n }\r\n );\r\n };\r\n \r\n function openCommandDialog(device, $event) {\r\n vm.device = device;\r\n let openCommandAction = widgetContext.actionsApi.getActionDescriptors(\"actionCellButton\").find(action => action.name == \"Docker commands\");\r\n widgetContext.actionsApi.handleWidgetAction($event, openCommandAction, device.id, device.name, {newDevice: true});\r\n setTimeout(function() {\r\n document.querySelector(\".dashboard-state-dialog .mat-mdc-button-touch-target\").addEventListener('click', goToConfigState);\r\n document.querySelector(\".dashboard-state-dialog .mat-mdc-button.mat-primary\").addEventListener('click', goToConfigState);\r\n }, 500);\r\n }\r\n\r\n \r\n function goToConfigState() {\r\n document.querySelector(\".dashboard-state-dialog .mat-mdc-button-touch-target\").removeEventListener('click', goToConfigState);\r\n document.querySelector(\".dashboard-state-dialog .mat-mdc-button.mat-primary\").removeEventListener('click', goToConfigState);\r\n const stateParams = {};\r\n stateParams.entityId = vm.device.id;\r\n stateParams.entityName = vm.device.name;\r\n const newStateParams = {\r\n targetEntityParamName: 'default',\r\n new_gateway: {\r\n entityId: vm.device.id,\r\n entityName: vm.device.name\r\n }\r\n }\r\n const params = {...stateParams, ...newStateParams};\r\n widgetContext.stateController.openState('gateway_details', params, false);\r\n }\r\n\r\n function saveEntityObservable() {\r\n const formValues = vm.addEntityFormGroup.value;\r\n let entity = {\r\n name: formValues.entityName,\r\n type: formValues.type,\r\n label: formValues.entityLabel,\r\n additionalInfo: {\r\n gateway: true\r\n }\r\n };\r\n return deviceService.saveDevice(entity);\r\n }\r\n}\r\n",
+ "customFunction": "let $injector = widgetContext.$scope.$injector;\r\nlet customDialog = $injector.get(widgetContext.servicesMap.get('customDialog'));\r\nlet assetService = $injector.get(widgetContext.servicesMap.get('assetService'));\r\nlet deviceService = $injector.get(widgetContext.servicesMap.get('deviceService'));\r\nlet attributeService = $injector.get(widgetContext.servicesMap.get('attributeService'));\r\nlet entityRelationService = $injector.get(widgetContext.servicesMap.get('entityRelationService'));\r\nlet userSettingsService = $injector.get(widgetContext.servicesMap.get('userSettingsService'));\r\n\r\nopenAddEntityDialog();\r\n\r\nfunction openAddEntityDialog() {\r\n customDialog.customDialog(htmlTemplate, AddEntityDialogController).subscribe();\r\n}\r\n\r\nfunction AddEntityDialogController(instance) {\r\n let vm = instance;\r\n let userSettings;\r\n userSettingsService.loadUserSettings().subscribe(settings=> {\r\n userSettings = settings;\r\n if (!userSettings.createdGatewaysCount) userSettings.createdGatewaysCount = 0;\r\n });\r\n \r\n\r\n vm.addEntityFormGroup = vm.fb.group({\r\n entityName: ['', [vm.validators.required]],\r\n entityType: ['DEVICE'],\r\n entityLabel: [''],\r\n type: ['', [vm.validators.required]],\r\n });\r\n\r\n vm.cancel = function() {\r\n vm.dialogRef.close(null);\r\n };\r\n\r\n\r\n vm.save = function($event) {\r\n vm.addEntityFormGroup.markAsPristine();\r\n saveEntityObservable().subscribe(\r\n function (device) {\r\n widgetContext.updateAliases();\r\n userSettingsService.putUserSettings({ createdGatewaysCount: ++userSettings.createdGatewaysCount }).subscribe(_=>{\r\n });\r\n vm.dialogRef.close(null);\r\n openCommandDialog(device, $event);\r\n }\r\n );\r\n };\r\n \r\n function openCommandDialog(device, $event) {\r\n vm.device = device;\r\n let openCommandAction = widgetContext.actionsApi.getActionDescriptors(\"actionCellButton\").find(action => action.name == \"Docker commands\");\r\n widgetContext.actionsApi.handleWidgetAction($event, openCommandAction, device.id, device.name, {newDevice: true});\r\n goToConfigState();\r\n }\r\n\r\n \r\n function goToConfigState() {\r\n const stateParams = {};\r\n stateParams.entityId = vm.device.id;\r\n stateParams.entityName = vm.device.name;\r\n const newStateParams = {\r\n targetEntityParamName: 'default',\r\n new_gateway: {\r\n entityId: vm.device.id,\r\n entityName: vm.device.name\r\n }\r\n }\r\n const params = {...stateParams, ...newStateParams};\r\n widgetContext.stateController.openState('gateway_details', params, false);\r\n }\r\n\r\n function saveEntityObservable() {\r\n const formValues = vm.addEntityFormGroup.value;\r\n let entity = {\r\n name: formValues.entityName,\r\n type: formValues.type,\r\n label: formValues.entityLabel,\r\n additionalInfo: {\r\n gateway: true\r\n }\r\n };\r\n return deviceService.saveDevice(entity);\r\n }\r\n}\r\n",
"customResources": [],
"openInSeparateDialog": false,
"openInPopover": false,
@@ -567,7 +567,8 @@
"padding": "8px",
"settings": {
"useMarkdownTextFunction": true,
- "markdownTextFunction": "var blockData = '';\nvar connectorsIndex = ctx.actionsApi.getActionDescriptors('elementClick').findIndex(action=>action.name==\"Connectors\");\nvar logsIndex = ctx.actionsApi.getActionDescriptors('elementClick').findIndex(action=>action.name==\"Logs\");\nfunction generateMatHeader(index) {\n if( index !== undefined && index > -1) {\n return ``\n } else {\n return \"\" \n }\n}\nfunction createDataBlock(value, label, dividerStyle, mobile, index) {\n blockData += `\n \n \n \n ${generateMatHeader(index)}\n ${label}\n \n ${value}\n `;\n}\ncreateDataBlock(data[0].Status, \"Status\", data[0].Status === \"Active\"? 'divider-green' : 'divider-red');\ncreateDataBlock(data[0].Name, \"Gateway Name\", '', ctx.isMobile);\ncreateDataBlock(data[0].Type, \"Gateway Type\", '');\ncreateDataBlock(\n `${(data[1]?data[1].count:0)} `\n + \" | \" + \n `${(data[2]?data[2][\"count 2\"]:0)} `\n , \"Devices (Active | Inactive)\", '');\ncreateDataBlock(\n `${(data[0].active_connectors?JSON.parse(data[0].active_connectors).length:0)} `\n + \" | \" + \n `${(data[0].inactive_connectors?JSON.parse(data[0].inactive_connectors).length:0)} `\n , \"Connectors (Active | Inactive)\", '', '', connectorsIndex);\ncreateDataBlock(data[0].ALL_ERRORS_COUNT || 0, \"Errors\", (data[0].ALL_ERRORS_COUNT || 0) === 0 ? 'divider-green' : 'divider-red', '', logsIndex);\nreturn `${blockData}
`;",
+ "markdownTextPattern": "# Markdown/HTML card \\n - **Current entity**: **${entityName}**. \\n - **Current value**: **${Random}**.",
+ "markdownTextFunction": "var blockData = '';\nvar connectorsIndex = ctx.actionsApi.getActionDescriptors('elementClick').findIndex(action=>action.name==\"Connectors\");\nvar logsIndex = ctx.actionsApi.getActionDescriptors('elementClick').findIndex(action=>action.name==\"Logs\");\nfunction generateMatHeader(index) {\n if( index !== undefined && index > -1) {\n return ``\n } else {\n return \"\" \n }\n}\nfunction createDataBlock(value, label, dividerStyle, mobile, index) {\n blockData += `\n \n \n \n ${generateMatHeader(index)}\n ${label}\n \n ${value}\n `;\n}\ncreateDataBlock(data[0].Status, \"Status\", data[0].Status === \"Active\"? 'divider-green' : 'divider-red');\ncreateDataBlock(data[0].Name, \"Gateway Name\", '', ctx.isMobile);\ncreateDataBlock(data[0].Type, \"Gateway Type\", '');\ncreateDataBlock(\n `${(data[1]?data[1].count:0)} `\n + \" | \" + \n `${(data[2]?data[2][\"count 2\"]:0)} `\n , \"Devices (Active | Inactive)\", '');\ncreateDataBlock(\n `${(data[0].active_connectors?JSON.parse(data[0].active_connectors).length:0)} `\n + \" | \" + \n `${(data[0].inactive_connectors?JSON.parse(data[0].inactive_connectors).length:0)} `\n , \"Connectors (Enabled | Disabled)\", '', '', connectorsIndex);\ncreateDataBlock(data[0].ALL_ERRORS_COUNT || 0, \"Errors\", (data[0].ALL_ERRORS_COUNT || 0) === 0 ? 'divider-green' : 'divider-red', '', logsIndex);\nreturn `${blockData}
`;",
"applyDefaultMarkdownStyle": false,
"markdownCss": ".divider {\n position: absolute;\n width: 3px;\n top: 8px;\n border-radius: 2px;\n bottom: 8px;\n border: 1px solid rgba(31, 70, 144, 1);\n background-color: rgba(31, 70, 144, 1);\n left: 10px;\n}\n.divider-green .divider {\n border: 1px solid rgb(25,128,56);\n background-color: rgb(25,128,56);\n}\n\n.divider-green .mat-mdc-card-content {\n color: rgb(25,128,56);\n}\n\n.divider-red .divider {\n border: 1px solid rgb(203,37,48);\n background-color: rgb(203,37,48);\n}\n\n.divider-red .mat-mdc-card-content {\n color: rgb(203,37,48);\n}\n\n.mdc-card {\n position: relative;\n padding-left: 10px;\n margin-bottom: 1px;\n}\n\n.mat-mdc-card-subtitle {\n font-weight: 400;\n font-size: 12px;\n}\n\n.mat-mdc-card-header {\n padding: 8px 16px 0;\n}\n\n.mat-mdc-card-content:last-child {\n padding-bottom: 8px;\n font-size: 16px;\n}\n\n.cards-container {\n height: calc(100% - 1px);\n justify-content: stretch;\n align-items: center;\n margin-bottom: 1px;\n}\n\n::ng-deep.tb-home-widget-link > div {\n flex-grow: 1;\n cursor: pointer;\n}\n\n .tb-home-widget-link {\n width: 100%;\n }\n\n .tb-home-widget-link:hover::after{\n color: inherit;\n }\n \n .tb-home-widget-link::after{\n content: 'arrow_forward';\n display: inline-block;\n transform: rotate(315deg);\n font-family: 'Material Icons';\n font-weight: normal;\n font-style: normal;\n font-size: 18px;\n color: rgba(0, 0, 0, 0.12);\n vertical-align: bottom;\n margin-left: 6px;\n}"
},
@@ -5218,7 +5219,7 @@
"widgets": {
"02a05113-d878-b086-328e-75bd6f02b2bb": {
"sizeX": 24,
- "sizeY": 11,
+ "sizeY": 12,
"row": 0,
"col": 0
}
@@ -5230,7 +5231,7 @@
"backgroundSizeMode": "100%",
"autoFillHeight": true,
"backgroundImageUrl": null,
- "mobileAutoFillHeight": false,
+ "mobileAutoFillHeight": true,
"mobileRowHeight": 70,
"outerMargin": true
}
@@ -6741,4 +6742,4 @@
},
"externalId": null,
"name": "Gateway"
-}
+}
\ No newline at end of file
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/broker-security.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/broker-security.component.html
new file mode 100644
index 0000000000..319b10f0b2
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/broker-security.component.html
@@ -0,0 +1,86 @@
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/broker-security.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/broker-security.component.scss
new file mode 100644
index 0000000000..416f368279
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/broker-security.component.scss
@@ -0,0 +1,20 @@
+/**
+ * Copyright © 2016-2024 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+:host {
+ width: 100%;
+ height: 100%;
+ display: block;
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/broker-security.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/broker-security.component.ts
new file mode 100644
index 0000000000..655545e5fb
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/broker-security.component.ts
@@ -0,0 +1,165 @@
+///
+/// Copyright © 2016-2024 The Thingsboard Authors
+///
+/// Licensed under the Apache License, Version 2.0 (the "License");
+/// you may not use this file except in compliance with the License.
+/// You may obtain a copy of the License at
+///
+/// http://www.apache.org/licenses/LICENSE-2.0
+///
+/// Unless required by applicable law or agreed to in writing, software
+/// distributed under the License is distributed on an "AS IS" BASIS,
+/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+/// See the License for the specific language governing permissions and
+/// limitations under the License.
+///
+
+import {
+ ChangeDetectionStrategy,
+ ChangeDetectorRef,
+ Component,
+ ElementRef,
+ forwardRef,
+ NgZone,
+ OnDestroy,
+ ViewContainerRef
+} from '@angular/core';
+import { PageComponent } from '@shared/components/page.component';
+import { Store } from '@ngrx/store';
+import { AppState } from '@core/core.state';
+import { TranslateService } from '@ngx-translate/core';
+import { MatDialog } from '@angular/material/dialog';
+import { DialogService } from '@core/services/dialog.service';
+import { Subject } from 'rxjs';
+import { Overlay } from '@angular/cdk/overlay';
+import { UtilsService } from '@core/services/utils.service';
+import { EntityService } from '@core/http/entity.service';
+import {
+ ControlValueAccessor,
+ FormBuilder,
+ NG_VALIDATORS,
+ NG_VALUE_ACCESSOR,
+ UntypedFormGroup,
+ ValidationErrors,
+ Validator,
+ Validators
+} from '@angular/forms';
+import {
+ BrokerSecurityType,
+ BrokerSecurityTypeTranslationsMap,
+ noLeadTrailSpacesRegex
+} from '@home/components/widget/lib/gateway/gateway-widget.models';
+import { takeUntil } from 'rxjs/operators';
+
+@Component({
+ selector: 'tb-broker-security',
+ templateUrl: './broker-security.component.html',
+ styleUrls: ['./broker-security.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => BrokerSecurityComponent),
+ multi: true
+ },
+ {
+ provide: NG_VALIDATORS,
+ useExisting: forwardRef(() => BrokerSecurityComponent),
+ multi: true
+ }
+ ]
+})
+export class BrokerSecurityComponent extends PageComponent implements ControlValueAccessor, Validator, OnDestroy {
+
+ BrokerSecurityType = BrokerSecurityType;
+
+ securityTypes = Object.values(BrokerSecurityType);
+
+ SecurityTypeTranslationsMap = BrokerSecurityTypeTranslationsMap;
+
+ securityFormGroup: UntypedFormGroup;
+
+ private destroy$ = new Subject();
+ private propagateChange = (v: any) => {};
+
+ constructor(protected store: Store,
+ public translate: TranslateService,
+ public dialog: MatDialog,
+ private overlay: Overlay,
+ private viewContainerRef: ViewContainerRef,
+ private dialogService: DialogService,
+ private entityService: EntityService,
+ private utils: UtilsService,
+ private zone: NgZone,
+ private cd: ChangeDetectorRef,
+ private elementRef: ElementRef,
+ private fb: FormBuilder) {
+ super(store);
+ this.securityFormGroup = this.fb.group({
+ type: [BrokerSecurityType.ANONYMOUS, []],
+ username: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]],
+ password: ['', [Validators.pattern(noLeadTrailSpacesRegex)]],
+ pathToCACert: ['', [Validators.pattern(noLeadTrailSpacesRegex)]],
+ pathToPrivateKey: ['', [Validators.pattern(noLeadTrailSpacesRegex)]],
+ pathToClientCert: ['', [Validators.pattern(noLeadTrailSpacesRegex)]]
+ });
+ this.securityFormGroup.valueChanges.pipe(
+ takeUntil(this.destroy$)
+ ).subscribe((value) => {
+ this.updateView(value);
+ });
+ this.securityFormGroup.get('type').valueChanges.pipe(
+ takeUntil(this.destroy$)
+ ).subscribe((type) => {
+ this.updateValidators(type);
+ });
+ }
+
+ ngOnDestroy() {
+ this.destroy$.next();
+ this.destroy$.complete();
+ super.ngOnDestroy();
+ }
+
+ registerOnChange(fn: any): void {
+ this.propagateChange = fn;
+ }
+
+ registerOnTouched(fn: any): void {}
+
+ writeValue(deviceInfo: any) {
+ if (!deviceInfo.type) {
+ deviceInfo.type = BrokerSecurityType.ANONYMOUS;
+ }
+ this.securityFormGroup.reset(deviceInfo);
+ this.updateView(deviceInfo);
+ }
+
+ validate(): ValidationErrors | null {
+ return this.securityFormGroup.valid ? null : {
+ securityForm: { valid: false }
+ };
+ }
+
+ updateView(value: any) {
+ this.propagateChange(value);
+ }
+
+ private updateValidators(type) {
+ if (type) {
+ this.securityFormGroup.get('username').disable({emitEvent: false});
+ this.securityFormGroup.get('password').disable({emitEvent: false});
+ this.securityFormGroup.get('pathToCACert').disable({emitEvent: false});
+ this.securityFormGroup.get('pathToPrivateKey').disable({emitEvent: false});
+ this.securityFormGroup.get('pathToClientCert').disable({emitEvent: false});
+ if (type === BrokerSecurityType.BASIC) {
+ this.securityFormGroup.get('username').enable({emitEvent: false});
+ this.securityFormGroup.get('password').enable({emitEvent: false});
+ } else if (type === BrokerSecurityType.CERTIFICATES) {
+ this.securityFormGroup.get('pathToCACert').enable({emitEvent: false});
+ this.securityFormGroup.get('pathToPrivateKey').enable({emitEvent: false});
+ this.securityFormGroup.get('pathToClientCert').enable({emitEvent: false});
+ }
+ }
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table.component.html
new file mode 100644
index 0000000000..2a3108b8b4
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table.component.html
@@ -0,0 +1,97 @@
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table.component.scss
new file mode 100644
index 0000000000..9dbaa52831
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table.component.scss
@@ -0,0 +1,57 @@
+/**
+ * Copyright © 2016-2024 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+:host {
+ width: 100%;
+ height: 100%;
+ display: block;
+
+ .tb-form-row {
+ &.bottom-same-padding {
+ padding-bottom: 16px;
+ }
+
+ &.top-same-padding {
+ padding-top: 16px;
+ }
+
+ .fixed-title-width {
+ width: 19%;
+ }
+ }
+
+ .table-column {
+ width: 40%;
+ }
+
+ .table-name-column {
+ width: 20%;
+ }
+
+ .raw-name {
+ width: 19%;
+ }
+
+ .raw-value-option {
+ max-width: 40%;
+ }
+
+}
+
+:host ::ng-deep {
+ .mat-mdc-form-field-icon-suffix {
+ display: flex;
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table.component.ts
new file mode 100644
index 0000000000..8a40f25430
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table.component.ts
@@ -0,0 +1,179 @@
+///
+/// Copyright © 2016-2024 The Thingsboard Authors
+///
+/// Licensed under the Apache License, Version 2.0 (the "License");
+/// you may not use this file except in compliance with the License.
+/// You may obtain a copy of the License at
+///
+/// http://www.apache.org/licenses/LICENSE-2.0
+///
+/// Unless required by applicable law or agreed to in writing, software
+/// distributed under the License is distributed on an "AS IS" BASIS,
+/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+/// See the License for the specific language governing permissions and
+/// limitations under the License.
+///
+
+import {
+ ChangeDetectionStrategy,
+ ChangeDetectorRef,
+ Component,
+ ElementRef,
+ forwardRef,
+ Input,
+ NgZone,
+ OnDestroy,
+ OnInit,
+ ViewContainerRef
+} from '@angular/core';
+import { PageComponent } from '@shared/components/page.component';
+import { Store } from '@ngrx/store';
+import { AppState } from '@core/core.state';
+import { TranslateService } from '@ngx-translate/core';
+import { MatDialog } from '@angular/material/dialog';
+import { DialogService } from '@core/services/dialog.service';
+import { Subject } from 'rxjs';
+import { takeUntil } from 'rxjs/operators';
+import { Overlay } from '@angular/cdk/overlay';
+import { UtilsService } from '@core/services/utils.service';
+import { EntityService } from '@core/http/entity.service';
+import {
+ ControlValueAccessor,
+ FormBuilder,
+ NG_VALIDATORS,
+ NG_VALUE_ACCESSOR,
+ UntypedFormGroup,
+ ValidationErrors,
+ Validator,
+ Validators
+} from '@angular/forms';
+import {
+ DeviceInfoType,
+ noLeadTrailSpacesRegex,
+ SourceTypes,
+ SourceTypeTranslationsMap
+} from '@home/components/widget/lib/gateway/gateway-widget.models';
+import { coerceBoolean } from '@shared/decorators/coercion';
+
+@Component({
+ selector: 'tb-device-info-table',
+ templateUrl: './device-info-table.component.html',
+ styleUrls: ['./device-info-table.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => DeviceInfoTableComponent),
+ multi: true
+ },
+ {
+ provide: NG_VALIDATORS,
+ useExisting: forwardRef(() => DeviceInfoTableComponent),
+ multi: true
+ }
+ ]
+})
+export class DeviceInfoTableComponent extends PageComponent implements ControlValueAccessor, Validator, OnInit, OnDestroy {
+
+ SourceTypeTranslationsMap = SourceTypeTranslationsMap;
+
+ DeviceInfoType = DeviceInfoType;
+
+ @coerceBoolean()
+ @Input()
+ useSource = true;
+
+ @coerceBoolean()
+ @Input()
+ required = false;
+
+ @Input()
+ sourceTypes: Array = Object.values(SourceTypes);
+
+ deviceInfoTypeValue: any;
+
+ get deviceInfoType(): any {
+ return this.deviceInfoTypeValue;
+ }
+
+ @Input()
+ set deviceInfoType(value: any) {
+ if (this.deviceInfoTypeValue !== value) {
+ this.deviceInfoTypeValue = value;
+ }
+ }
+
+ mappingFormGroup: UntypedFormGroup;
+
+ private destroy$ = new Subject();
+ private propagateChange = (v: any) => {};
+
+ constructor(protected store: Store,
+ public translate: TranslateService,
+ public dialog: MatDialog,
+ private overlay: Overlay,
+ private viewContainerRef: ViewContainerRef,
+ private dialogService: DialogService,
+ private entityService: EntityService,
+ private utils: UtilsService,
+ private zone: NgZone,
+ private cd: ChangeDetectorRef,
+ private elementRef: ElementRef,
+ private fb: FormBuilder) {
+ super(store);
+ }
+
+ ngOnInit() {
+ this.mappingFormGroup = this.fb.group({
+ deviceNameExpression: ['', this.required ?
+ [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)] : [Validators.pattern(noLeadTrailSpacesRegex)]]
+ });
+
+ if (this.useSource) {
+ this.mappingFormGroup.addControl('deviceNameExpressionSource',
+ this.fb.control(SourceTypes.MSG, []));
+ }
+
+ if (this.deviceInfoType === DeviceInfoType.FULL) {
+ if (this.useSource) {
+ this.mappingFormGroup.addControl('deviceProfileExpressionSource',
+ this.fb.control(SourceTypes.MSG, []));
+ }
+ this.mappingFormGroup.addControl('deviceProfileExpression',
+ this.fb.control('', this.required ?
+ [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)] : [Validators.pattern(noLeadTrailSpacesRegex)]));
+ }
+
+ this.mappingFormGroup.valueChanges.pipe(
+ takeUntil(this.destroy$)
+ ).subscribe((value) => {
+ this.updateView(value);
+ });
+ }
+
+ ngOnDestroy() {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+
+ registerOnChange(fn: any): void {
+ this.propagateChange = fn;
+ }
+
+ registerOnTouched(fn: any): void {}
+
+ writeValue(deviceInfo: any) {
+ this.mappingFormGroup.patchValue(deviceInfo, {emitEvent: false});
+ }
+
+ validate(): ValidationErrors | null {
+ return this.mappingFormGroup.valid ? null : {
+ mappingForm: { valid: false }
+ };
+ }
+
+ updateView(value: any) {
+ this.propagateChange(value);
+ }
+
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/ellipsis-chip-list.directive.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/ellipsis-chip-list.directive.ts
new file mode 100644
index 0000000000..aa4738d707
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/ellipsis-chip-list.directive.ts
@@ -0,0 +1,128 @@
+///
+/// Copyright © 2016-2024 The Thingsboard Authors
+///
+/// Licensed under the Apache License, Version 2.0 (the "License");
+/// you may not use this file except in compliance with the License.
+/// You may obtain a copy of the License at
+///
+/// http://www.apache.org/licenses/LICENSE-2.0
+///
+/// Unless required by applicable law or agreed to in writing, software
+/// distributed under the License is distributed on an "AS IS" BASIS,
+/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+/// See the License for the specific language governing permissions and
+/// limitations under the License.
+///
+
+import {
+ Directive,
+ ElementRef,
+ Inject,
+ Input,
+ OnDestroy,
+ Renderer2
+} from '@angular/core';
+import { isEqual } from '@core/utils';
+import { TranslateService } from '@ngx-translate/core';
+import { WINDOW } from '@core/services/window.service';
+import { fromEvent, Subject } from 'rxjs';
+import { takeUntil } from 'rxjs/operators';
+
+@Directive({
+ // eslint-disable-next-line @angular-eslint/directive-selector
+ selector: '[tb-ellipsis-chip-list]'
+})
+export class EllipsisChipListDirective implements OnDestroy {
+
+ chipsValue: string[];
+
+ private destroy$ = new Subject();
+
+ @Input('tb-ellipsis-chip-list')
+ set chips(value: string[]) {
+ if (!isEqual(this.chipsValue, value)) {
+ this.chipsValue = value;
+ setTimeout(() => {
+ this.adjustChips();
+ }, 0);
+ }
+ }
+
+ constructor(private el: ElementRef,
+ private renderer: Renderer2,
+ private translate: TranslateService,
+ @Inject(WINDOW) private window: Window) {
+ this.renderer.setStyle(this.el.nativeElement, 'max-height', '48px');
+ this.renderer.setStyle(this.el.nativeElement, 'overflow', 'auto');
+ fromEvent(window, 'resize').pipe(
+ takeUntil(this.destroy$)
+ ).subscribe(() => {
+ this.adjustChips();
+ });
+ }
+
+ private adjustChips(): void {
+ const chipListElement = this.el.nativeElement;
+ const ellipsisChip = this.el.nativeElement.querySelector('.ellipsis-chip');
+ const margin = parseFloat(this.window.getComputedStyle(ellipsisChip).marginLeft) || 0;
+ const chipNodes = chipListElement.querySelectorAll('mat-chip:not(.ellipsis-chip)');
+
+ if (this.chipsValue.length > 1) {
+ const ellipsisText = this.el.nativeElement.querySelector('.ellipsis-text');
+ this.renderer.setStyle(ellipsisChip, 'display', 'inline-flex');
+ ellipsisText.innerHTML = this.translate.instant('gateway.ellipsis-chips-text',
+ {count: (this.chipsValue.length)});
+
+ const availableWidth = chipListElement.offsetWidth - (ellipsisChip.offsetWidth + margin);
+ let usedWidth = 0;
+ let visibleChipsCount = 0;
+
+ chipNodes.forEach((chip) => {
+ this.renderer.setStyle(chip, 'display', 'inline-flex');
+ const textLabelContainer = chip.querySelector('.mdc-evolution-chip__text-label');
+
+ this.applyMaxChipTextWidth(textLabelContainer, (availableWidth / 3));
+
+ if ((usedWidth + (chip.offsetWidth + margin) <= availableWidth) && (visibleChipsCount < this.chipsValue.length)) {
+ visibleChipsCount++;
+ usedWidth += chip.offsetWidth + margin;
+ } else {
+ this.renderer.setStyle(chip, 'display', 'none');
+ }
+ });
+
+ ellipsisText.innerHTML = this.translate.instant('gateway.ellipsis-chips-text',
+ {count: (this.chipsValue.length - visibleChipsCount)});
+
+ if (visibleChipsCount === this.chipsValue?.length) {
+ this.renderer.setStyle(ellipsisChip, 'display', 'none');
+ }
+ } else if (this.chipsValue.length === 1) {
+ const chipLabelContainer = chipNodes[0].querySelector('.mdc-evolution-chip__action');
+ const textLabelContainer = chipLabelContainer.querySelector('.mdc-evolution-chip__text-label');
+ const leftPadding = parseFloat(this.window.getComputedStyle(chipLabelContainer).paddingLeft) || 0;
+ const rightPadding = parseFloat(this.window.getComputedStyle(chipLabelContainer).paddingRight) || 0;
+ const computedTextWidth = chipListElement.offsetWidth - margin -
+ (leftPadding + rightPadding);
+
+ this.renderer.setStyle(ellipsisChip, 'display', 'none');
+ this.renderer.setStyle(chipNodes[0], 'display', 'inline-flex');
+
+ this.applyMaxChipTextWidth(textLabelContainer, computedTextWidth);
+ } else {
+ this.renderer.setStyle(ellipsisChip, 'display', 'none');
+ }
+ }
+
+ private applyMaxChipTextWidth(element: HTMLElement, widthLimit: number): void {
+ this.renderer.setStyle(element, 'max-width', widthLimit + 'px');
+ this.renderer.setStyle(element, 'overflow', 'hidden');
+ this.renderer.setStyle(element, 'text-overflow', 'ellipsis');
+ this.renderer.setStyle(element, 'white-space', 'nowrap');
+ }
+
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel.component.html
new file mode 100644
index 0000000000..4c3a6434fd
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel.component.html
@@ -0,0 +1,195 @@
+
+
+
+
+
+ {{ noKeysText }}
+
+
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel.component.scss
new file mode 100644
index 0000000000..24498863c9
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel.component.scss
@@ -0,0 +1,60 @@
+/**
+ * Copyright © 2016-2024 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+:host {
+ .tb-mapping-keys-panel {
+ width: 77vw;
+ max-width: 700px;
+
+ .title-container {
+ max-width: 11vw;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap
+ }
+
+ .key-panel {
+ height: 500px;
+ overflow: auto;
+ }
+
+ tb-value-input {
+ width: 100%;
+ }
+
+ .tb-form-panel {
+ .mat-mdc-icon-button {
+ width: 56px;
+ height: 56px;
+ padding: 16px;
+ color: rgba(0, 0, 0, 0.54);
+ }
+ }
+
+ .see-example {
+ width: 32px;
+ height: 32px;
+ margin: 4px;
+ }
+ }
+}
+
+:host ::ng-deep {
+ .mat-mdc-form-field-icon-suffix {
+ display: flex;
+ }
+}
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel.component.ts
new file mode 100644
index 0000000000..40ee2728bd
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel.component.ts
@@ -0,0 +1,173 @@
+///
+/// Copyright © 2016-2024 The Thingsboard Authors
+///
+/// Licensed under the Apache License, Version 2.0 (the "License");
+/// you may not use this file except in compliance with the License.
+/// You may obtain a copy of the License at
+///
+/// http://www.apache.org/licenses/LICENSE-2.0
+///
+/// Unless required by applicable law or agreed to in writing, software
+/// distributed under the License is distributed on an "AS IS" BASIS,
+/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+/// See the License for the specific language governing permissions and
+/// limitations under the License.
+///
+
+import {
+ Component,
+ EventEmitter,
+ Input,
+ OnInit,
+ Output
+} from '@angular/core';
+import {
+ AbstractControl,
+ UntypedFormArray,
+ UntypedFormBuilder,
+ Validators
+} from '@angular/forms';
+import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
+import { coerceBoolean } from '@shared/decorators/coercion';
+import { TbPopoverComponent } from '@shared/components/popover.component';
+import { Store } from '@ngrx/store';
+import { AppState } from '@core/core.state';
+import { PageComponent } from '@shared/components/page.component';
+import { isDefinedAndNotNull } from '@core/utils';
+import {
+ MappingDataKey,
+ MappingKeysType,
+ MappingValueType,
+ mappingValueTypesMap,
+ noLeadTrailSpacesRegex
+} from '@home/components/widget/lib/gateway/gateway-widget.models';
+
+@Component({
+ selector: 'tb-mapping-data-keys-panel',
+ templateUrl: './mapping-data-keys-panel.component.html',
+ styleUrls: ['./mapping-data-keys-panel.component.scss'],
+ providers: []
+})
+export class MappingDataKeysPanelComponent extends PageComponent implements OnInit {
+
+ @Input()
+ panelTitle: string;
+
+ @Input()
+ addKeyTitle: string;
+
+ @Input()
+ deleteKeyTitle: string;
+
+ @Input()
+ noKeysText: string;
+
+ @Input()
+ keys: Array | {[key: string]: any};
+
+ @Input()
+ keysType: string;
+
+ @Input()
+ @coerceBoolean()
+ rawData = false;
+
+ @Input()
+ popover: TbPopoverComponent;
+
+ @Output()
+ keysDataApplied = new EventEmitter | {[key: string]: any}>();
+
+ valueTypeKeys = Object.values(MappingValueType);
+
+ MappingKeysType = MappingKeysType;
+
+ valueTypeEnum = MappingValueType;
+
+ valueTypes = mappingValueTypesMap;
+
+ dataKeyType: DataKeyType;
+
+ keysListFormArray: UntypedFormArray;
+
+ errorText = '';
+
+ constructor(private fb: UntypedFormBuilder,
+ protected store: Store) {
+ super(store);
+ }
+
+ ngOnInit() {
+ this.keysListFormArray = this.prepareKeysFormArray(this.keys)
+ }
+
+ trackByKey(index: number, keyControl: AbstractControl): any {
+ return keyControl;
+ }
+
+ addKey(): void {
+ const dataKeyFormGroup = this.fb.group({
+ key: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]],
+ value: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]]
+ });
+ if (this.keysType !== MappingKeysType.CUSTOM) {
+ dataKeyFormGroup.addControl('type', this.fb.control(this.rawData ? 'raw' : MappingValueType.STRING));
+ }
+ this.keysListFormArray.push(dataKeyFormGroup);
+ }
+
+ deleteKey($event: Event, index: number): void {
+ if ($event) {
+ $event.stopPropagation();
+ }
+ this.keysListFormArray.removeAt(index);
+ this.keysListFormArray.markAsDirty();
+ }
+
+ cancel() {
+ this.popover?.hide();
+ }
+
+ applyKeysData() {
+ let keys = this.keysListFormArray.value;
+ if (this.keysType === MappingKeysType.CUSTOM) {
+ keys = {};
+ for (let key of this.keysListFormArray.value) {
+ keys[key.key] = key.value;
+ }
+ }
+ this.keysDataApplied.emit(keys);
+ }
+
+ private prepareKeysFormArray(keys: Array | {[key: string]: any}): UntypedFormArray {
+ const keysControlGroups: Array = [];
+ if (keys) {
+ if (this.keysType === MappingKeysType.CUSTOM) {
+ keys = Object.keys(keys).map(key => {
+ return {key, value: keys[key], type: ''};
+ });
+ }
+ keys.forEach((keyData) => {
+ const { key, value, type } = keyData;
+ const dataKeyFormGroup = this.fb.group({
+ key: [key, [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]],
+ value: [value, [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]],
+ type: [type, []]
+ });
+ keysControlGroups.push(dataKeyFormGroup);
+ });
+ }
+ return this.fb.array(keysControlGroups);
+ }
+
+ valueTitle(value: any): string {
+ if (isDefinedAndNotNull(value)) {
+ if (typeof value === 'object') {
+ return JSON.stringify(value);
+ }
+ return value;
+ }
+ return '';
+ }
+
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table.component.html
new file mode 100644
index 0000000000..e5c703d8d0
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table.component.html
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ column.title | translate }}
+
+
+ {{ mapping[column.def] }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ widget.no-data-found
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table.component.scss
new file mode 100644
index 0000000000..707b5fa056
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table.component.scss
@@ -0,0 +1,101 @@
+/**
+ * Copyright © 2016-2024 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+@import '../scss/constants';
+
+:host {
+ width: 100%;
+ height: 100%;
+ display: block;
+ .tb-mapping-table {
+ .tb-mapping-table-content {
+ 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;
+ }
+
+ .title-container{
+ overflow: hidden;
+ }
+
+ .tb-mapping-table-title {
+ padding-right: 20px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .table-container {
+ overflow: auto;
+ .mat-mdc-table {
+ table-layout: fixed;
+ min-width: 450px;
+
+ .table-value-column {
+ padding: 0 12px;
+ width: 23%;
+
+ &.request-column {
+ width: 38%;
+ }
+ }
+ }
+ }
+
+ .ellipsis {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ }
+ }
+
+ .no-data-found {
+ height: calc(100% - 120px);
+ }
+
+ @media #{$mat-xs} {
+ .mat-toolbar {
+ height: auto;
+ min-height: 100px;
+
+ .tb-mapping-table-title{
+ padding-bottom: 5px;
+ width: 100%;
+ }
+ }
+ }
+}
+
+:host ::ng-deep {
+ mat-cell.tb-value-cell {
+ cursor: pointer;
+ .mat-icon {
+ height: 24px;
+ width: 24px;
+ font-size: 24px;
+ color: #757575
+ }
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table.component.ts
new file mode 100644
index 0000000000..4ef9695c65
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table.component.ts
@@ -0,0 +1,339 @@
+///
+/// Copyright © 2016-2024 The Thingsboard Authors
+///
+/// Licensed under the Apache License, Version 2.0 (the "License");
+/// you may not use this file except in compliance with the License.
+/// You may obtain a copy of the License at
+///
+/// http://www.apache.org/licenses/LICENSE-2.0
+///
+/// Unless required by applicable law or agreed to in writing, software
+/// distributed under the License is distributed on an "AS IS" BASIS,
+/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+/// See the License for the specific language governing permissions and
+/// limitations under the License.
+///
+
+import {
+ AfterViewInit,
+ ChangeDetectionStrategy,
+ ChangeDetectorRef,
+ Component,
+ ElementRef,
+ forwardRef,
+ Input,
+ NgZone,
+ OnDestroy,
+ OnInit,
+ ViewChild,
+ ViewContainerRef
+} from '@angular/core';
+import { PageComponent } from '@shared/components/page.component';
+import { PageLink } from '@shared/models/page/page-link';
+import { Store } from '@ngrx/store';
+import { AppState } from '@core/core.state';
+import { TranslateService } from '@ngx-translate/core';
+import { MatDialog } from '@angular/material/dialog';
+import { DialogService } from '@core/services/dialog.service';
+import { BehaviorSubject, Observable, Subject } from 'rxjs';
+import { debounceTime, distinctUntilChanged, map, takeUntil } from 'rxjs/operators';
+import { Overlay } from '@angular/cdk/overlay';
+import { UtilsService } from '@core/services/utils.service';
+import { EntityService } from '@core/http/entity.service';
+import { ControlValueAccessor, FormBuilder, NG_VALUE_ACCESSOR, UntypedFormArray } from '@angular/forms';
+import {
+ ConvertorTypeTranslationsMap,
+ MappingInfo,
+ MappingType,
+ MappingTypeTranslationsMap,
+ RequestType,
+ RequestTypesTranslationsMap
+} from '@home/components/widget/lib/gateway/gateway-widget.models';
+import { CollectionViewer, DataSource } from '@angular/cdk/collections';
+import { MappingDialogComponent } from '@home/components/widget/lib/gateway/dialog/mapping-dialog.component';
+import { isDefinedAndNotNull, isUndefinedOrNull } from '@core/utils';
+
+@Component({
+ selector: 'tb-mapping-table',
+ templateUrl: './mapping-table.component.html',
+ styleUrls: ['./mapping-table.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => MappingTableComponent),
+ multi: true
+ }
+ ]
+})
+export class MappingTableComponent extends PageComponent implements ControlValueAccessor, AfterViewInit, OnInit, OnDestroy {
+
+ mappingTypeTranslationsMap = MappingTypeTranslationsMap;
+ mappingTypeEnum = MappingType;
+ displayedColumns = [];
+ mappingColumns = [];
+ textSearchMode = false;
+ dataSource: MappingDatasource;
+ hidePageSize = false;
+
+ activeValue = false;
+ dirtyValue = false;
+
+ viewsInited = false;
+
+ mappingTypeValue: MappingType;
+
+ get mappingType(): MappingType {
+ return this.mappingTypeValue;
+ }
+
+ @Input()
+ set mappingType(value: MappingType) {
+ if (this.mappingTypeValue !== value) {
+ this.mappingTypeValue = value;
+ }
+ }
+
+ @ViewChild('searchInput') searchInputField: ElementRef;
+
+ mappingFormGroup: UntypedFormArray;
+ textSearch = this.fb.control('', {nonNullable: true});
+
+ private destroy$ = new Subject();
+ private propagateChange = (v: any) => {};
+
+ constructor(protected store: Store,
+ public translate: TranslateService,
+ public dialog: MatDialog,
+ private overlay: Overlay,
+ private viewContainerRef: ViewContainerRef,
+ private dialogService: DialogService,
+ private entityService: EntityService,
+ private utils: UtilsService,
+ private zone: NgZone,
+ private cd: ChangeDetectorRef,
+ private elementRef: ElementRef,
+ private fb: FormBuilder) {
+ super(store);
+ this.mappingFormGroup = this.fb.array([]);
+ this.dirtyValue = !this.activeValue;
+ this.dataSource = new MappingDatasource();
+ }
+
+ ngOnInit() {
+ if (this.mappingType === MappingType.DATA) {
+ this.mappingColumns.push(
+ {def: 'topicFilter', title: 'gateway.topic-filter'},
+ {def: 'QoS', title: 'gateway.mqtt-qos'},
+ {def: 'converter', title: 'gateway.payload-type'}
+ )
+ } else {
+ this.mappingColumns.push(
+ {def: 'type', title: 'gateway.type'},
+ {def: 'details', title: 'gateway.details'}
+ );
+ }
+ this.displayedColumns.push(...this.mappingColumns.map(column => column.def), 'actions');
+ this.mappingFormGroup.valueChanges.pipe(
+ takeUntil(this.destroy$)
+ ).subscribe((value) => {
+ this.updateTableData(value);
+ this.updateView(value);
+ });
+ }
+
+ ngOnDestroy() {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+
+ ngAfterViewInit() {
+ this.textSearch.valueChanges.pipe(
+ debounceTime(150),
+ distinctUntilChanged((prev, current) => (prev ?? '') === current.trim()),
+ takeUntil(this.destroy$)
+ ).subscribe((text) => {
+ const searchText = text.trim();
+ this.updateTableData(this.mappingFormGroup.value, searchText.trim())
+ });
+ }
+
+ registerOnChange(fn: any): void {
+ this.propagateChange = fn;
+ }
+
+ registerOnTouched(fn: any): void {}
+
+ writeValue(config: any) {
+ if (isUndefinedOrNull(config)) {
+ config = this.mappingType === MappingType.REQUESTS ? {} : [];
+ }
+ let mappingConfigs = config;
+ if (this.mappingType === MappingType.REQUESTS) {
+ mappingConfigs = [];
+
+ Object.keys(config).forEach((configKey) => {
+ for (let mapping of config[configKey]) {
+ mappingConfigs.push({
+ requestType: configKey,
+ requestValue: mapping
+ });
+ }
+ });
+ }
+ this.mappingFormGroup.clear({emitEvent: false});
+ for (let mapping of mappingConfigs) {
+ this.mappingFormGroup.push(this.fb.group(mapping), {emitEvent: false});
+ }
+ this.updateTableData(mappingConfigs);
+ }
+
+ updateView(mappingConfigs: Array<{[key: string]: any}>) {
+ let config;
+ if (this.mappingType === MappingType.REQUESTS) {
+ config = {};
+ for (let mappingConfig of mappingConfigs) {
+ if (config[mappingConfig.requestType]) {
+ config[mappingConfig.requestType].push(mappingConfig.requestValue);
+ } else {
+ config[mappingConfig.requestType] = [mappingConfig.requestValue];
+ }
+ }
+ } else {
+ config = mappingConfigs;
+ }
+
+ this.propagateChange(config);
+ }
+
+ enterFilterMode() {
+ this.textSearchMode = true;
+ setTimeout(() => {
+ this.searchInputField.nativeElement.focus();
+ this.searchInputField.nativeElement.setSelectionRange(0, 0);
+ }, 10);
+ }
+
+ exitFilterMode() {
+ this.updateTableData(this.mappingFormGroup.value);
+ this.textSearchMode = false;
+ this.textSearch.reset();
+ }
+
+ manageMapping($event: Event, index?: number) {
+ if ($event) {
+ $event.stopPropagation();
+ }
+ const value = isDefinedAndNotNull(index) ? this.mappingFormGroup.at(index).value : {};
+ this.dialog.open(MappingDialogComponent, {
+ disableClose: true,
+ panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
+ data: {
+ mappingType: this.mappingType,
+ value,
+ buttonTitle: isUndefinedOrNull(index) ? 'action.add' : 'action.apply'
+ }
+ }).afterClosed().subscribe(
+ (res) => {
+ if (res) {
+ if (isDefinedAndNotNull(index)) {
+ this.mappingFormGroup.at(index).patchValue(res);
+ } else {
+ this.mappingFormGroup.push(this.fb.group(res));
+ }
+ }
+ }
+ );
+ }
+
+ updateTableData(value: Array<{[key: string]: any}>, textSearch?: string): void {
+ let tableValue = value;
+ if (this.mappingType === MappingType.DATA) {
+ tableValue = tableValue.map((value) => {
+ return {
+ topicFilter: value.topicFilter,
+ QoS: value.subscriptionQos,
+ converter: this.translate.instant(ConvertorTypeTranslationsMap.get(value.converter.type))
+ };
+ });
+ } else {
+ tableValue = tableValue.map((value) => {
+ let details;
+ if (value.requestType === RequestType.ATTRIBUTE_UPDATE) {
+ details = value.requestValue.attributeFilter;
+ } else if (value.requestType === RequestType.SERVER_SIDE_RPC) {
+ details = value.requestValue.methodFilter;
+ } else {
+ details = value.requestValue.topicFilter;
+ }
+ return {
+ type: this.translate.instant(RequestTypesTranslationsMap.get(value.requestType)),
+ details
+ };
+ });
+ }
+ if (textSearch) {
+ tableValue = tableValue.filter(value =>
+ Object.values(value).some(val =>
+ val.toString().toLowerCase().includes(textSearch.toLowerCase())
+ )
+ );
+ }
+ this.dataSource.loadMappings(tableValue);
+ }
+
+ deleteMapping($event: Event, index: number) {
+ if ($event) {
+ $event.stopPropagation();
+ }
+ this.dialogService.confirm(
+ this.translate.instant('gateway.delete-mapping-title'),
+ '',
+ this.translate.instant('action.no'),
+ this.translate.instant('action.yes'),
+ true
+ ).subscribe((result) => {
+ if (result) {
+ this.mappingFormGroup.removeAt(index);
+ }
+ });
+ }
+
+}
+
+export class MappingDatasource implements DataSource<{[key: string]: any}> {
+
+ private mappingSubject = new BehaviorSubject>([]);
+
+ private allMappings: Observable>;
+
+ constructor() {}
+
+ connect(collectionViewer: CollectionViewer): Observable> {
+ return this.mappingSubject.asObservable();
+ }
+
+ disconnect(collectionViewer: CollectionViewer): void {
+ this.mappingSubject.complete();
+ }
+
+ loadMappings(mappings: Array<{[key: string]: any}>, pageLink?: PageLink, reload: boolean = false): void {
+ if (reload) {
+ this.allMappings = null;
+ }
+ this.mappingSubject.next(mappings);
+ }
+
+ isEmpty(): Observable {
+ return this.mappingSubject.pipe(
+ map((mappings) => !mappings.length)
+ );
+ }
+
+ total(): Observable {
+ return this.mappingSubject.pipe(
+ map((mappings) => mappings.length)
+ );
+ }
+
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/add-connector-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/add-connector-dialog.component.html
new file mode 100644
index 0000000000..ec1fa9a87f
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/add-connector-dialog.component.html
@@ -0,0 +1,100 @@
+
+
+
+ {{ "gateway.add-connector" | translate}}
+
+
+
+
+
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/add-connector-dialog.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/add-connector-dialog.component.scss
new file mode 100644
index 0000000000..56e4bc229c
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/add-connector-dialog.component.scss
@@ -0,0 +1,22 @@
+/**
+ * Copyright © 2016-2024 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+:host {
+ .add-connector {
+ min-width: 400px;
+ width: 500px;
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/add-connector-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/add-connector-dialog.component.ts
new file mode 100644
index 0000000000..78a22cd267
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/add-connector-dialog.component.ts
@@ -0,0 +1,122 @@
+///
+/// Copyright © 2016-2024 The Thingsboard Authors
+///
+/// Licensed under the Apache License, Version 2.0 (the "License");
+/// you may not use this file except in compliance with the License.
+/// You may obtain a copy of the License at
+///
+/// http://www.apache.org/licenses/LICENSE-2.0
+///
+/// Unless required by applicable law or agreed to in writing, software
+/// distributed under the License is distributed on an "AS IS" BASIS,
+/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+/// See the License for the specific language governing permissions and
+/// limitations under the License.
+///
+
+import { Component, Inject, OnDestroy } 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, UntypedFormControl, UntypedFormGroup, ValidatorFn, Validators } from '@angular/forms';
+import { BaseData, HasId } from '@shared/models/base-data';
+import { DialogComponent } from '@shared/components/dialog.component';
+import { Router } from '@angular/router';
+import {
+ AddConnectorConfigData,
+ ConnectorType,
+ CreatedConnectorConfigData,
+ GatewayConnectorDefaultTypesTranslatesMap,
+ GatewayLogLevel,
+ getDefaultConfig,
+ noLeadTrailSpacesRegex
+} from '@home/components/widget/lib/gateway/gateway-widget.models';
+import { Subject } from 'rxjs';
+import { ResourcesService } from '@core/services/resources.service';
+
+@Component({
+ selector: 'tb-add-connector-dialog',
+ templateUrl: './add-connector-dialog.component.html',
+ styleUrls: ['./add-connector-dialog.component.scss'],
+ providers: [],
+})
+export class AddConnectorDialogComponent extends DialogComponent> implements OnDestroy {
+
+ connectorForm: UntypedFormGroup;
+
+ connectorType = ConnectorType;
+
+ gatewayConnectorDefaultTypesTranslatesMap = GatewayConnectorDefaultTypesTranslatesMap;
+ gatewayLogLevel = Object.values(GatewayLogLevel);
+
+ submitted = false;
+
+ private destroy$ = new Subject();
+
+ constructor(protected store: Store,
+ protected router: Router,
+ @Inject(MAT_DIALOG_DATA) public data: AddConnectorConfigData,
+ public dialogRef: MatDialogRef,
+ private fb: FormBuilder,
+ private resourcesService: ResourcesService) {
+ super(store, router, dialogRef);
+ this.connectorForm = this.fb.group({
+ type: [ConnectorType.MQTT, []],
+ name: ['', [Validators.required, this.uniqNameRequired(), Validators.pattern(noLeadTrailSpacesRegex)]],
+ logLevel: [GatewayLogLevel.INFO, []],
+ useDefaults: [true, []],
+ sendDataOnlyOnChange: [false, []],
+ });
+ }
+
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ super.ngOnDestroy();
+ }
+
+ helpLinkId(): string {
+ return 'https://thingsboard.io/docs/iot-gateway/configuration/';
+ }
+
+ cancel(): void {
+ this.dialogRef.close(null);
+ }
+
+ add(): void {
+ this.submitted = true;
+ const value = this.connectorForm.getRawValue();
+ if (value.useDefaults) {
+ getDefaultConfig(this.resourcesService, value.type).subscribe((defaultConfig) => {
+ value.configurationJson = defaultConfig;
+ if (this.connectorForm.valid) {
+ this.dialogRef.close(value);
+ }
+ });
+ }
+ if (this.connectorForm.valid) {
+ this.dialogRef.close(value);
+ }
+ }
+
+ private uniqNameRequired(): ValidatorFn {
+ return (c: UntypedFormControl) => {
+ const newName = c.value.trim().toLowerCase();
+ const found = this.data.dataSourceData.find((connectorAttr) => {
+ const connectorData = connectorAttr.value;
+ return connectorData.name.toLowerCase() === newName;
+ });
+ if (found) {
+ if (c.hasError('required')) {
+ return c.getError('required');
+ }
+ return {
+ duplicateName: {
+ valid: false
+ }
+ };
+ }
+ return null;
+ };
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/mapping-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/mapping-dialog.component.html
new file mode 100644
index 0000000000..78ceaa566f
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/mapping-dialog.component.html
@@ -0,0 +1,634 @@
+
+
+
+ {{ MappingTypeTranslationsMap.get(this.data?.mappingType) | translate}}
+
+
+
+
+
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/mapping-dialog.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/mapping-dialog.component.scss
new file mode 100644
index 0000000000..db98bc07cb
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/mapping-dialog.component.scss
@@ -0,0 +1,76 @@
+/**
+ * Copyright © 2016-2024 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+:host {
+ display: grid;
+ height: 100%;
+
+ .key-mapping {
+ max-width: 900px;
+ display: flex;
+ flex-direction: column;
+
+ .mat-toolbar {
+ min-height: 64px;
+ }
+
+ tb-toggle-select {
+ padding: 4px 0;
+ }
+ }
+
+ .mat-mdc-dialog-content {
+ max-height: 670px;
+ height: 670px;
+ }
+
+ .ellipsis-chips-container {
+ max-width: 70%;
+ }
+}
+
+:host ::ng-deep {
+ .key-mapping {
+ .mat-mdc-chip-listbox {
+ .mdc-evolution-chip-set__chips {
+ justify-content: flex-end;
+ align-items: center;
+ }
+ }
+ }
+ .tb-form-row {
+ .fixed-title-width {
+ min-width: 40px;
+ width: 35%;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ .mat-mdc-form-field {
+ width: 0;
+ }
+ }
+
+ .see-example {
+ width: 32px;
+ height: 32px;
+ margin: 4px;
+ }
+
+ .mat-mdc-form-field-icon-suffix {
+ display: flex;
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/mapping-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/mapping-dialog.component.ts
new file mode 100644
index 0000000000..d01050b52e
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/mapping-dialog.component.ts
@@ -0,0 +1,360 @@
+///
+/// Copyright © 2016-2024 The Thingsboard Authors
+///
+/// Licensed under the Apache License, Version 2.0 (the "License");
+/// you may not use this file except in compliance with the License.
+/// You may obtain a copy of the License at
+///
+/// http://www.apache.org/licenses/LICENSE-2.0
+///
+/// Unless required by applicable law or agreed to in writing, software
+/// distributed under the License is distributed on an "AS IS" BASIS,
+/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+/// See the License for the specific language governing permissions and
+/// limitations under the License.
+///
+
+import { Component, Inject, OnDestroy, Renderer2, ViewContainerRef } 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, UntypedFormGroup, Validators } from '@angular/forms';
+import { BaseData, HasId } from '@shared/models/base-data';
+import { DialogComponent } from '@shared/components/dialog.component';
+import { Router } from '@angular/router';
+import {
+ ConvertorType,
+ ConvertorTypeTranslationsMap,
+ DataConversionTranslationsMap,
+ DeviceInfoType,
+ MappingHintTranslationsMap,
+ MappingInfo,
+ MappingKeysAddKeyTranslationsMap,
+ MappingKeysDeleteKeyTranslationsMap,
+ MappingKeysNoKeysTextTranslationsMap,
+ MappingKeysPanelTitleTranslationsMap,
+ MappingKeysType,
+ MappingType,
+ MappingTypeTranslationsMap,
+ noLeadTrailSpacesRegex,
+ QualityTypes,
+ QualityTypeTranslationsMap,
+ RequestType,
+ RequestTypesTranslationsMap,
+ ServerSideRPCType,
+ SourceTypes,
+ SourceTypeTranslationsMap
+} from '@home/components/widget/lib/gateway/gateway-widget.models';
+import { Subject } from 'rxjs';
+import { startWith, takeUntil } from 'rxjs/operators';
+import { MatButton } from '@angular/material/button';
+import { TbPopoverService } from '@shared/components/popover.service';
+import { MappingDataKeysPanelComponent } from '@home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel.component';
+import { TranslateService } from '@ngx-translate/core';
+
+@Component({
+ selector: 'tb-mapping-dialog',
+ templateUrl: './mapping-dialog.component.html',
+ styleUrls: ['./mapping-dialog.component.scss'],
+ providers: [],
+})
+export class MappingDialogComponent extends DialogComponent> implements OnDestroy {
+
+ mappingForm: UntypedFormGroup;
+
+ MappingType = MappingType;
+
+ qualityTypes = QualityTypes;
+ QualityTranslationsMap = QualityTypeTranslationsMap;
+
+ convertorTypes = Object.values(ConvertorType);
+ ConvertorTypeEnum = ConvertorType;
+ ConvertorTypeTranslationsMap = ConvertorTypeTranslationsMap;
+
+ sourceTypes = Object.values(SourceTypes);
+ sourceTypesEnum = SourceTypes;
+ SourceTypeTranslationsMap = SourceTypeTranslationsMap;
+
+ requestTypes = Object.values(RequestType);
+ RequestTypeEnum = RequestType;
+ RequestTypesTranslationsMap = RequestTypesTranslationsMap;
+
+ DeviceInfoType = DeviceInfoType;
+
+ ServerSideRPCType = ServerSideRPCType;
+
+ MappingKeysType = MappingKeysType;
+
+ MappingHintTranslationsMap = MappingHintTranslationsMap;
+
+ MappingTypeTranslationsMap = MappingTypeTranslationsMap;
+
+ DataConversionTranslationsMap = DataConversionTranslationsMap;
+
+ hiddenAttributesCount = 0;
+
+ keysPopupClosed = true;
+
+ submitted = false;
+
+ private destroy$ = new Subject();
+
+ constructor(protected store: Store,
+ protected router: Router,
+ @Inject(MAT_DIALOG_DATA) public data: MappingInfo,
+ public dialogRef: MatDialogRef,
+ private fb: FormBuilder,
+ private popoverService: TbPopoverService,
+ private renderer: Renderer2,
+ private viewContainerRef: ViewContainerRef,
+ private translate: TranslateService) {
+ super(store, router, dialogRef);
+
+ this.createMappingForm();
+ }
+
+ get converterAttributes(): Array {
+ if (this.converterType) {
+ return this.mappingForm.get('converter').get(this.converterType).value.attributes.map(value => value.key);
+ }
+ }
+
+ get converterTelemetry(): Array {
+ if (this.converterType) {
+ return this.mappingForm.get('converter').get(this.converterType).value.timeseries.map(value => value.key);
+ }
+ }
+
+ get converterType(): ConvertorType {
+ return this.mappingForm.get('converter').get('type').value;
+ }
+
+ get customKeys(): Array {
+ return Object.keys(this.mappingForm.get('converter').get('custom').value.extensionConfig);
+ }
+
+ get requestMappingType(): RequestType {
+ return this.mappingForm.get('requestType').value;
+ }
+
+ get responseTimeoutErrorTooltip(): string {
+ const control = this.mappingForm.get('requestValue.serverSideRpc.responseTimeout');
+ if (control.hasError('required')) {
+ return this.translate.instant('gateway.response-timeout-required');
+ } else if (control.hasError('min')) {
+ return this.translate.instant('gateway.response-timeout-limits-error', {min: 1});
+ }
+ return '';
+ }
+
+
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ super.ngOnDestroy();
+ }
+
+ private createMappingForm(): void {
+ this.mappingForm = this.fb.group({});
+ if (this.data.mappingType === MappingType.DATA) {
+ this.mappingForm.addControl('topicFilter',
+ this.fb.control('', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]));
+ this.mappingForm.addControl('subscriptionQos', this.fb.control(0));
+ this.mappingForm.addControl('converter', this.fb.group({
+ type: [ConvertorType.JSON, []],
+ json: this.fb.group({
+ deviceInfo: [{}, []],
+ attributes: [[], []],
+ timeseries: [[], []]
+ }),
+ bytes: this.fb.group({
+ deviceInfo: [{}, []],
+ attributes: [[], []],
+ timeseries: [[], []]
+ }),
+ custom: this.fb.group({
+ extension: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]],
+ extensionConfig: [{}, []]
+ }),
+ }));
+ this.mappingForm.patchValue(this.prepareFormValueData());
+ this.mappingForm.get('converter.type').valueChanges.pipe(
+ startWith(this.mappingForm.get('converter.type').value),
+ takeUntil(this.destroy$)
+ ).subscribe((value) => {
+ const converterGroup = this.mappingForm.get('converter');
+ converterGroup.get('json').disable({emitEvent: false});
+ converterGroup.get('bytes').disable({emitEvent: false});
+ converterGroup.get('custom').disable({emitEvent: false});
+ converterGroup.get(value).enable({emitEvent: false});
+ })
+ }
+
+ if (this.data.mappingType === MappingType.REQUESTS) {
+ this.mappingForm.addControl('requestType', this.fb.control(RequestType.CONNECT_REQUEST, []));
+ this.mappingForm.addControl('requestValue', this.fb.group({
+ connectRequests: this.fb.group({
+ topicFilter: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]],
+ deviceInfo: [{}, []]
+ }),
+ disconnectRequests: this.fb.group({
+ topicFilter: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]],
+ deviceInfo: [{}, []]
+ }),
+ attributeRequests: this.fb.group({
+ topicFilter: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]],
+ deviceInfo: this.fb.group({
+ deviceNameExpressionSource: [SourceTypes.MSG, []],
+ deviceNameExpression: ['', [Validators.required]],
+ }),
+ attributeNameExpressionSource: [SourceTypes.MSG, []],
+ attributeNameExpression: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]],
+ topicExpression: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]],
+ valueExpression: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]],
+ retain: [false, []]
+ }),
+ attributeUpdates: this.fb.group({
+ deviceNameFilter: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]],
+ attributeFilter: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]],
+ topicExpression: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]],
+ valueExpression: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]],
+ retain: [true, []]
+ }),
+ serverSideRpc: this.fb.group({
+ type: [ServerSideRPCType.TWO_WAY, []],
+ deviceNameFilter: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]],
+ methodFilter: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]],
+ requestTopicExpression: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]],
+ responseTopicExpression: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]],
+ valueExpression: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]],
+ responseTopicQoS: [0, []],
+ responseTimeout: [10000, [Validators.required, Validators.min(1)]],
+ })
+ }));
+ this.mappingForm.get('requestType').valueChanges.pipe(
+ startWith(this.mappingForm.get('requestType').value),
+ takeUntil(this.destroy$)
+ ).subscribe((value) => {
+ const requestValueGroup = this.mappingForm.get('requestValue');
+ requestValueGroup.get('connectRequests').disable({emitEvent: false});
+ requestValueGroup.get('disconnectRequests').disable({emitEvent: false});
+ requestValueGroup.get('attributeRequests').disable({emitEvent: false});
+ requestValueGroup.get('attributeUpdates').disable({emitEvent: false});
+ requestValueGroup.get('serverSideRpc').disable({emitEvent: false});
+ requestValueGroup.get(value).enable();
+ });
+ this.mappingForm.get('requestValue.serverSideRpc.type').valueChanges.pipe(
+ takeUntil(this.destroy$)
+ ).subscribe((value) => {
+ const requestValueGroup = this.mappingForm.get('requestValue.serverSideRpc');
+ if (value === ServerSideRPCType.ONE_WAY) {
+ requestValueGroup.get('responseTopicExpression').disable({emitEvent: false});
+ requestValueGroup.get('responseTopicQoS').disable({emitEvent: false});
+ requestValueGroup.get('responseTimeout').disable({emitEvent: false});
+ } else {
+ requestValueGroup.get('responseTopicExpression').enable({emitEvent: false});
+ requestValueGroup.get('responseTopicQoS').enable({emitEvent: false});
+ requestValueGroup.get('responseTimeout').enable({emitEvent: false});
+ }
+ });
+ this.mappingForm.patchValue(this.prepareFormValueData());
+ }
+ }
+
+ helpLinkId(): string {
+ return 'https://thingsboard.io/docs/iot-gateway/config/mqtt/#section-mapping';
+ }
+
+ cancel(): void {
+ if (this.keysPopupClosed) {
+ this.dialogRef.close(null);
+ }
+ }
+
+ add(): void {
+ this.submitted = true;
+ if (this.mappingForm.valid) {
+ this.dialogRef.close(this.prepareMappingData());
+ }
+ }
+
+ manageKeys($event: Event, matButton: MatButton, keysType: MappingKeysType) {
+ if ($event) {
+ $event.stopPropagation();
+ }
+ const trigger = matButton._elementRef.nativeElement;
+ if (this.popoverService.hasPopover(trigger)) {
+ this.popoverService.hidePopover(trigger);
+ } else {
+ const keysControl = this.mappingForm.get('converter').get(this.converterType).get(keysType);
+ const ctx: any = {
+ keys: keysControl.value,
+ keysType: keysType,
+ rawData: this.mappingForm.get('converter.type').value === ConvertorType.BYTES,
+ panelTitle: MappingKeysPanelTitleTranslationsMap.get(keysType),
+ addKeyTitle: MappingKeysAddKeyTranslationsMap.get(keysType),
+ deleteKeyTitle: MappingKeysDeleteKeyTranslationsMap.get(keysType),
+ noKeysText: MappingKeysNoKeysTextTranslationsMap.get(keysType)
+ };
+ this.keysPopupClosed = false;
+ const dataKeysPanelPopover = this.popoverService.displayPopover(trigger, this.renderer,
+ this.viewContainerRef, MappingDataKeysPanelComponent, 'leftBottom', false, null,
+ ctx,
+ {},
+ {}, {}, true);
+ dataKeysPanelPopover.tbComponentRef.instance.popover = dataKeysPanelPopover;
+ dataKeysPanelPopover.tbComponentRef.instance.keysDataApplied.subscribe((keysData) => {
+ dataKeysPanelPopover.hide();
+ keysControl.patchValue(keysData);
+ keysControl.markAsDirty();
+ });
+ dataKeysPanelPopover.tbHideStart.subscribe(() => {
+ this.keysPopupClosed = true;
+ });
+ }
+ }
+
+ private prepareMappingData(): {[key: string]: any} {
+ const formValue = this.mappingForm.value;
+ if (this.data.mappingType === MappingType.DATA) {
+ const { converter, topicFilter, subscriptionQos } = formValue;
+ return {
+ topicFilter,
+ subscriptionQos,
+ converter: {
+ type: converter.type,
+ ...converter[converter.type]
+ }
+ };
+ } else {
+ return {
+ requestType: formValue.requestType,
+ requestValue: formValue.requestValue[formValue.requestType]
+ };
+ }
+ }
+
+ private prepareFormValueData(): {[key: string]: any} {
+ if (this.data.value && Object.keys(this.data.value).length) {
+ if (this.data.mappingType === MappingType.DATA) {
+ const { converter, topicFilter, subscriptionQos } = this.data.value;
+ return {
+ topicFilter,
+ subscriptionQos,
+ converter: {
+ type: converter.type,
+ [converter.type]: { ...converter }
+ }
+ };
+ } else {
+ return {
+ requestType: this.data.value.requestType,
+ requestValue: {
+ [this.data.value.requestType]: this.data.value.requestValue
+ }
+ };
+ }
+ }
+ return this.data.value;
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-configuration.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-configuration.component.html
index 153b6b0e14..8aeaeef2ed 100644
--- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-configuration.component.html
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-configuration.component.html
@@ -80,6 +80,15 @@
{{ 'security.access-token-required' | translate }}
+
+
info_outlined
@@ -92,6 +101,15 @@
{{ 'security.clientId-required' | translate }}
+
+
info_outlined
@@ -102,6 +120,15 @@
{{ 'security.username-required' | translate }}
+
+
info_outlined
@@ -109,8 +136,17 @@
- security.password
+ gateway.password
+
+
info_outlined
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-configuration.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-configuration.component.scss
index 46292aa36e..f7562c748e 100644
--- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-configuration.component.scss
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-configuration.component.scss
@@ -97,5 +97,9 @@
color: #9E9E9E;
}
}
+
+ .mat-mdc-form-field-icon-suffix {
+ display: flex;
+ }
}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-connectors.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-connectors.component.html
index 2b9329dc21..3a7f5403b9 100644
--- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-connectors.component.html
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-connectors.component.html
@@ -22,13 +22,21 @@
+
+
+
@@ -74,6 +82,7 @@
0,
'noErrors': +getErrorsCount(attribute) === 0 || getErrorsCount(attribute) === ''}">
@@ -119,7 +128,7 @@
private_connectivity
-