From 67d9b094fae278dd393c80c67d680c9de71f669a Mon Sep 17 00:00:00 2001 From: Vladyslav Date: Thu, 26 Mar 2020 14:49:26 +0200 Subject: [PATCH] [3.0] Add gateway widget (#2547) * Create gateway widget * Refacoring and add styled * Update bundle * Add model and refactoring --- .../widget_bundles/gateway_widgets.json | 6 +- ui-ngx/package-lock.json | 37 +- ui-ngx/package.json | 2 + .../import-export/import-export.models.ts | 12 + .../import-export/import-export.service.ts | 39 +- .../lib/gateway/gateway-form.component.html | 262 +++++++++++ .../lib/gateway/gateway-form.component.scss | 41 ++ .../lib/gateway/gateway-form.component.ts | 407 ++++++++++++++++++ .../widget/lib/gateway/gateway-form.models.ts | 374 ++++++++++++++++ .../widget/widget-components.module.ts | 11 +- .../components/widget/widget.component.ts | 1 + .../json-object-edit-dialog.component.html | 2 +- .../json-object-edit-dialog.component.ts | 33 +- .../entity-gateway-select.component.html | 56 +++ .../entity/entity-gateway-select.component.ts | 223 ++++++++++ .../components/value-input.component.ts | 4 +- ui-ngx/src/app/shared/shared.module.ts | 8 +- .../assets/locale/locale.constant-en_US.json | 3 +- ui-ngx/tsconfig.json | 5 +- 19 files changed, 1465 insertions(+), 61 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-form.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-form.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-form.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-form.models.ts create mode 100644 ui-ngx/src/app/shared/components/entity/entity-gateway-select.component.html create mode 100644 ui-ngx/src/app/shared/components/entity/entity-gateway-select.component.ts diff --git a/application/src/main/data/json/system/widget_bundles/gateway_widgets.json b/application/src/main/data/json/system/widget_bundles/gateway_widgets.json index 77dcfac936..6bc8dc73ef 100644 --- a/application/src/main/data/json/system/widget_bundles/gateway_widgets.json +++ b/application/src/main/data/json/system/widget_bundles/gateway_widgets.json @@ -13,13 +13,13 @@ "sizeX": 8, "sizeY": 6.5, "resources": [], - "templateHtml": "\n\n", + "templateHtml": "\n\n", "templateCss": "", - "controllerScript": "self.onInit = function() {\n var scope = self.ctx.$scope;\n var id = self.ctx.$scope.$injector.get('utils').guid();\n scope.formId = \"form-\"+id;\n scope.ctx = self.ctx;\n}\n\nself.onResize = function() {\n self.ctx.$scope.$broadcast('gateway-form-resize', self.ctx.$scope.formId);\n}\n", + "controllerScript": "self.onInit = function() {\n}\n", "settingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Settings\",\n \"properties\": {\n \"widgetTitle\": {\n \"title\": \"Widget title\",\n \"type\": \"string\",\n \"default\": \"Gateway Configuration\"\n },\n \"archiveFileName\": {\n \"title\": \"Default archive file name\",\n \"type\": \"string\",\n \"default\": \"gatewayConfiguration\"\n },\n \"gatewayType\": {\n \"title\": \"Device type for new gateway\",\n \"type\": \"string\",\n \"default\": \"Gateway\"\n },\n \"successfulSave\": {\n \"title\": \"Text message about successfully saved gateway configuration\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"gatewayNameExists\": {\n \"title\": \"Text message when device with entered name is already exists\",\n \"type\": \"string\",\n \"default\": \"\"\n }\n },\n \"required\": []\n },\n \"form\": [\n [\n \"widgetTitle\",\n \"archiveFileName\",\n \"gatewayType\"\n ],\n [\n \"successfulSave\",\n \"gatewayNameExists\"\n ]\n ],\n \"groupInfoes\": [{\n \"formIndex\": 0,\n \"GroupTitle\": \"General settings\"\n }, {\n \"formIndex\": 1,\n \"GroupTitle\": \"Messages settings\"\n }]\n}", "dataKeySettingsSchema": "{}\n", "defaultConfig": "{\"datasources\":[{\"type\":\"static\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -1000) {\\n\\tvalue = -1000;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"widgetTitle\":\"Gateway Configuration\",\"archiveFileName\":\"configurationGateway\"},\"title\":\"Gateway Configuration\",\"dropShadow\":true,\"showTitleIcon\":false,\"titleIcon\":\"more_horiz\",\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"enableFullscreen\":true,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"displayTimewindow\":true,\"showLegend\":false,\"actions\":{}}" } } ] -} \ No newline at end of file +} diff --git a/ui-ngx/package-lock.json b/ui-ngx/package-lock.json index 9124267fa1..8623eb93ef 100644 --- a/ui-ngx/package-lock.json +++ b/ui-ngx/package-lock.json @@ -1657,6 +1657,15 @@ "@types/jquery": "*" } }, + "@types/jszip": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/jszip/-/jszip-3.1.7.tgz", + "integrity": "sha512-+XQKNI5zpxutK05hO67huUTw/2imXCuJWjnFdU63tRES/xXSX1yVR9cv/QAdO6Rii2y2tTHbzjQ4i2apLfuK0Q==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/leaflet": { "version": "1.5.12", "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.5.12.tgz", @@ -3701,8 +3710,7 @@ "core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", - "dev": true + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" }, "cosmiconfig": { "version": "5.2.1", @@ -6017,8 +6025,7 @@ "immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=", - "dev": true + "integrity": "sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=" }, "import-cwd": { "version": "2.1.0", @@ -6503,8 +6510,7 @@ "isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", - "dev": true + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" }, "isbinaryfile": { "version": "3.0.3", @@ -7001,7 +7007,6 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.2.2.tgz", "integrity": "sha512-NmKajvAFQpbg3taXQXr/ccS2wcucR1AZ+NtyWp2Nq7HHVsXhcJFR8p0Baf32C2yVvBylFWVeKf+WI2AnvlPhpA==", - "dev": true, "requires": { "lie": "~3.3.0", "pako": "~1.0.2", @@ -7236,7 +7241,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", - "dev": true, "requires": { "immediate": "~3.0.5" } @@ -8748,8 +8752,7 @@ "pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "dev": true + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" }, "parallel-transform": { "version": "1.2.0", @@ -9598,8 +9601,7 @@ "process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, "promise": { "version": "7.3.1", @@ -10238,7 +10240,6 @@ "version": "2.3.7", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", - "dev": true, "requires": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -10569,8 +10570,7 @@ "safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, "safe-regex": { "version": "1.1.0", @@ -10898,8 +10898,7 @@ "set-immediate-shim": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz", - "integrity": "sha1-SysbJ+uAip+NzEgaWOXlb1mfP2E=", - "dev": true + "integrity": "sha1-SysbJ+uAip+NzEgaWOXlb1mfP2E=" }, "set-value": { "version": "2.0.1", @@ -11725,7 +11724,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, "requires": { "safe-buffer": "~5.1.0" } @@ -12623,8 +12621,7 @@ "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", - "dev": true + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, "util-promisify": { "version": "2.1.0", diff --git a/ui-ngx/package.json b/ui-ngx/package.json index 1830fa64cf..f739af99c5 100644 --- a/ui-ngx/package.json +++ b/ui-ngx/package.json @@ -56,6 +56,7 @@ "json-schema-defaults": "^0.4.0", "jstree": "^3.3.9", "jstree-bootstrap-theme": "^1.0.1", + "jszip": "^3.2.2", "leaflet": "^1.6.0", "leaflet-geometryutil": "^0.9.3", "leaflet-polylinedecorator": "^1.6.0", @@ -105,6 +106,7 @@ "@types/jquery": "^3.3.32", "@types/js-beautify": "^1.8.1", "@types/jstree": "^3.3.39", + "@types/jszip": "^3.1.7", "@types/leaflet": "^1.5.9", "@types/leaflet-polylinedecorator": "^1.6.0", "@types/lodash": "^4.14.149", diff --git a/ui-ngx/src/app/modules/home/components/import-export/import-export.models.ts b/ui-ngx/src/app/modules/home/components/import-export/import-export.models.ts index 3de12d3969..9bceb4ad0b 100644 --- a/ui-ngx/src/app/modules/home/components/import-export/import-export.models.ts +++ b/ui-ngx/src/app/modules/home/components/import-export/import-export.models.ts @@ -80,6 +80,18 @@ export interface CsvColumnParam { sampleData: any; } +export enum FileType { + zip = 'application/zip', + json = 'text/json' +} + +export const FileTypeExtension = new Map( + [ + [FileType.zip, 'zip'], + [FileType.json, 'json'], + ] +); + export function convertCSVToJson(csvdata: string, config: CsvToJsonConfig, onError: (messageId: string, params?: any) => void): CsvToJsonResult | number { config = config || {}; diff --git a/ui-ngx/src/app/modules/home/components/import-export/import-export.service.ts b/ui-ngx/src/app/modules/home/components/import-export/import-export.service.ts index 0bd96e2456..1ec1468b4e 100644 --- a/ui-ngx/src/app/modules/home/components/import-export/import-export.service.ts +++ b/ui-ngx/src/app/modules/home/components/import-export/import-export.service.ts @@ -44,7 +44,7 @@ import { EntityAliasesDialogData } from '@home/components/alias/entity-aliases-dialog.component'; import { ItemBufferService, WidgetItem } from '@core/services/item-buffer.service'; -import { ImportWidgetResult, WidgetsBundleItem } from './import-export.models'; +import { FileType, FileTypeExtension, ImportWidgetResult, WidgetsBundleItem } from './import-export.models'; import { EntityType } from '@shared/models/entity-type.models'; import { UtilsService } from '@core/services/utils.service'; import { WidgetService } from '@core/http/widget.service'; @@ -54,6 +54,7 @@ import { ImportEntitiesResultInfo, ImportEntityData } from '@shared/models/entit import { RequestConfig } from '@core/http/http-utils'; import { RuleChain, RuleChainImport, RuleChainMetaData } from '@shared/models/rule-chain.models'; import { RuleChainService } from '@core/http/rule-chain.service'; +import * as JSZip from 'jszip'; // @dynamic @Injectable() @@ -79,7 +80,7 @@ export class ImportExportService { (dashboard) => { let name = dashboard.title; name = name.toLowerCase().replace(/\W/g, '_'); - this.exportToPc(this.prepareDashboardExport(dashboard), name + '.json'); + this.exportToFile(this.prepareDashboardExport(dashboard), name, FileType.json); }, (e) => { this.handleExportError(e, 'dashboard.export-failed-error'); @@ -136,7 +137,7 @@ export class ImportExportService { const widgetItem = this.itembuffer.prepareWidgetItem(dashboard, sourceState, sourceLayout, widget); let name = widgetItem.widget.config.title; name = name.toLowerCase().replace(/\W/g, '_'); - this.exportToPc(this.prepareExport(widgetItem), name + '.json'); + this.exportToFile(this.prepareExport(widgetItem), name, FileType.json); } public importWidget(dashboard: Dashboard, targetState: string, @@ -235,7 +236,7 @@ export class ImportExportService { } let name = widgetType.name; name = name.toLowerCase().replace(/\W/g, '_'); - this.exportToPc(this.prepareExport(widgetType), name + '.json'); + this.exportToFile(this.prepareExport(widgetType), name, FileType.json); }, (e) => { this.handleExportError(e, 'widget-type.export-failed-error'); @@ -281,7 +282,7 @@ export class ImportExportService { } let name = widgetsBundle.title; name = name.toLowerCase().replace(/\W/g, '_'); - this.exportToPc(widgetsBundleItem, name + '.json'); + this.exportToFile(widgetsBundleItem, name, FileType.json); }, (e) => { this.handleExportError(e, 'widgets-bundle.export-failed-error'); @@ -383,7 +384,7 @@ export class ImportExportService { ).subscribe((ruleChainExport) => { let name = ruleChainExport.ruleChain.name; name = name.toLowerCase().replace(/\W/g, '_'); - this.exportToPc(ruleChainExport, name + '.json'); + this.exportToFile(ruleChainExport, name, FileType.json); }, (e) => { this.handleExportError(e, 'rulechain.export-failed-error'); @@ -414,6 +415,19 @@ export class ImportExportService { ); } + public exportJSZip(data: object, filename: string) { + const jsZip: JSZip = new JSZip(); + for (const keyName in data) { + if (data.hasOwnProperty(keyName)) { + const valueData = data[keyName]; + jsZip.file(keyName, valueData); + } + } + jsZip.generateAsync({type: 'blob'}).then(content => { + this.exportToFile(content, filename, FileType.zip); + }); + } + private prepareRuleChain(ruleChain: RuleChain): RuleChain { ruleChain = this.prepareExport(ruleChain); if (ruleChain.firstRuleNodeId) { @@ -673,18 +687,19 @@ export class ImportExportService { )); } - private exportToPc(data: any, filename: string) { + private exportToFile(data: any, filename: string, fileType: FileType) { if (!data) { console.error('No data'); return; } if (!filename) { - filename = 'download.json'; + filename = 'download'; } - if (isObject(data)) { - data = JSON.stringify(data, null, 2); + filename += '.' + FileTypeExtension.get(fileType); + if (fileType === FileType.json && isObject(data)) { + data = JSON.stringify(data, null, 2); } - const blob = new Blob([data], {type: 'text/json'}); + const blob = new Blob([data], {type: fileType}); if (this.window.navigator && this.window.navigator.msSaveOrOpenBlob) { this.window.navigator.msSaveOrOpenBlob(blob, filename); } else { @@ -692,7 +707,7 @@ export class ImportExportService { const a = this.document.createElement('a'); a.download = filename; a.href = URL.createObjectURL(blob); - a.dataset.downloadurl = ['text/json', a.download, a.href].join(':'); + a.dataset.downloadurl = [fileType, a.download, a.href].join(':'); // @ts-ignore e.initEvent('click', true, false, this.window, 0, 0, 0, 0, 0, false, false, false, false, 0, null); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-form.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-form.component.html new file mode 100644 index 0000000000..7e7feb4136 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-form.component.html @@ -0,0 +1,262 @@ + +
+ + + + +
{{ 'gateway.thingsboard' | translate | uppercase }}
+
+
+ + +
+ + {{'gateway.security-type' | translate }} + + + {{ securityType.value.toString() | translate }} + + + +
+
+ + {{ 'gateway.thingsboard-host' | translate }} + + + gateway.thingsboard-host-required + + + + {{ 'gateway.thingsboard-port' | translate }} + + + gateway.thingsboard-port-required + + + gateway.thingsboard-port-min + + + gateway.thingsboard-port-max + + + gateway.thingsboard-port-pattern + + +
+ +
+ + {{ 'gateway.tls-path-ca-certificate' | translate }} + + + + {{ 'gateway.tls-path-private-key' | translate }} + + + + {{ 'gateway.tls-path-client-certificate' | translate }} + + +
+ + {{ 'gateway.remote' | translate }} + +
+ + {{'gateway.remote-logging-level' | translate }} + + + {{ logLevel }} + + + + + + {{ 'gateway.path-logs' | translate }} + + + gateway.path-logs-required + + +
+ +
+ + + + +
{{ 'gateway.storage' | translate | uppercase }}
+
+
+ +
+ + {{'gateway.storage-type' | translate }} + + + {{ storageType.value.toString() | translate}} + + + + +
+ + {{ 'gateway.storage-pack-size' | translate }} + + + gateway.storage-pack-size-required + + + gateway.storage-pack-size-min + + + gateway.storage-pack-size-pattern + + + + + + {{ (gatewayConfigurationGroup.get('storageType').value !== 'file' ? 'gateway.storage-max-records' : 'gateway.storage-max-file-records') | translate}} + + + + gateway.storage-max-records-required + + + gateway.storage-max-records-min + + + gateway.storage-max-records-pattern + + +
+ +
+ + {{ 'gateway.storage-max-files' | translate }} + + + gateway.storage-max-files-required + + + gateway.storage-max-files-min + + + gateway.storage-max-files-pattern + + + + + {{ 'gateway.storage-path' | translate }} + + + gateway.storage-path-required + + +
+
+
+ + + + +
{{ 'gateway.connectors' | translate | uppercase }}
+
+
+ +
+
+
+
+ +
+
+ + {{'gateway.connector-type' | translate }} + + + {{ connectorType }} + + + + gateway.connector-type-required + + + + + {{ 'gateway.connector-name' | translate }} + + + gateway.connector-name-required + + +
+
+ + +
+
+
+ {{'gateway.no-connectors' | translate}} +
+ +
+
+
+
+
+ + + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-form.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-form.component.scss new file mode 100644 index 0000000000..ed84b55a6d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-form.component.scss @@ -0,0 +1,41 @@ +/** + * Copyright © 2016-2020 The 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{ + .gateway-form { + height: 100%; + padding: 5px; + background-color: transparent; + overflow-y: auto; + overflow-x: hidden; + + .form-action-buttons{ + padding-top: 8px; + } + + .gateway-config { + .no-data-found { + position: relative; + display: flex; + height: 40px; + text-transform: uppercase; + } + } + } + + ::ng-deep .mat-form-field-infix { + width: auto !important; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-form.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-form.component.ts new file mode 100644 index 0000000000..4eb9d424f7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-form.component.ts @@ -0,0 +1,407 @@ +/// +/// Copyright © 2016-2020 The 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, ElementRef, Inject, Input, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { AbstractControl, FormArray, FormBuilder, FormGroup, NgForm, Validators } from '@angular/forms'; +import { WidgetContext } from '@home/models/widget-component.models'; +import { UtilsService } from '@core/services/utils.service'; +import { + CONFIGURATION_ATTRIBUTE, + CONFIGURATION_DRAFT_ATTRIBUTE, + ConnectorType, + createFormConfig, + CURRENT_CONFIGURATION_ATTRIBUTE, + DEFAULT_CONNECTOR, + gatewayConfigJSON, + GatewayFormConnectorModel, + GatewayFormModels, + GatewayLogLevel, + generateConnectorConfigFiles, + generateLogConfigFile, + generateYAMLConfigFile, + getDraftConnectorsJSON, + getEntityId, + REMOTE_LOGGING_LEVEL_ATTRIBUTE, + SecurityType, + SecurityTypeTranslationMap, + StorageType, + StorageTypeTranslationMap, + ValidateJSON, + WidgetSetting +} from './gateway-form.models'; +import { WINDOW } from '@core/services/window.service'; +import { MatDialog } from '@angular/material/dialog'; +import { + JsonObjectEditDialogComponent, + JsonObjectEditDialogData +} from '@shared/components/dialog/json-object-edit-dialog.component'; +import { TranslateService } from '@ngx-translate/core'; +import { DeviceService } from '@core/http/device.service'; +import { AttributeService } from '@core/http/attribute.service'; +import { AttributeData, AttributeScope } from '@shared/models/telemetry/telemetry.models'; +import { forkJoin, Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { ImportExportService } from '@home/components/import-export/import-export.service'; + +@Component({ + selector: 'tb-gateway-form', + templateUrl: './gateway-form.component.html', + styleUrls: ['./gateway-form.component.scss'] +}) + +export class GatewayFormComponent extends PageComponent implements OnInit, OnDestroy { + constructor( + protected store: Store, + private elementRef: ElementRef, + private utils: UtilsService, + private ngZone: NgZone, + private fb: FormBuilder, + @Inject(WINDOW) private window: Window, + private dialog: MatDialog, + private translate: TranslateService, + private deviceService: DeviceService, + private attributeService: AttributeService, + private importExport: ImportExportService + ) { + super(store); + } + + get connectors(): FormArray { + return this.gatewayConfigurationGroup.get('connectors') as FormArray; + } + + @ViewChild('formContainer', {static: true}) formContainerRef: ElementRef; + @ViewChild('gatewayConfigurationForm', {static: true}) multipleInputForm: NgForm; + + private successfulSaved: string; + private gatewayNameExists: string; + private archiveFileName: string; + private formResizeListener: any; + private subscribeStorageType$: any; + private subscribeGateway$: any; + + alignment = 'row'; + layoutGap = '5px'; + gatewayType: string; + gatewayConfigurationGroup: FormGroup; + securityTypes = SecurityTypeTranslationMap; + gatewayLogLevels = Object.values(GatewayLogLevel); + connectorTypes = Object.keys(ConnectorType); + storageTypes = StorageTypeTranslationMap; + + toastTargetId = 'gateway-configuration-widget' + this.utils.guid(); + + @Input() + ctx: WidgetContext; + + ngOnInit(): void { + this.initWidgetSettings(this.ctx.settings); + + this.buildForm(); + this.ctx.updateWidgetParams(); + this.formResizeListener = this.resize.bind(this); + // @ts-ignore + addResizeListener(this.formContainerRef.nativeElement, this.formResizeListener); + } + + ngOnDestroy(): void { + if (this.formResizeListener) { + // @ts-ignore + removeResizeListener(this.formContainerRef.nativeElement, this.formResizeListener); + } + this.subscribeGateway$.unsubscribe(); + this.subscribeStorageType$.unsubscribe(); + } + + private initWidgetSettings(settings: WidgetSetting): void { + let widgetTitle; + if (settings.widgetTitle && settings.widgetTitle.length) { + widgetTitle = this.utils.customTranslation(settings.widgetTitle, settings.widgetTitle); + } else { + widgetTitle = this.translate.instant('gateway.gateway'); + } + this.ctx.widgetTitle = widgetTitle; + + this.archiveFileName = settings.archiveFileName?.length ? settings.archiveFileName : 'gatewayConfiguration'; + this.gatewayType = settings.gatewayType?.length ? settings.gatewayType : 'Gateway'; + this.gatewayNameExists = this.utils.customTranslation(settings.gatewayNameExists, settings.gatewayNameExists) || this.translate.instant('gateway.gateway-exists'); + this.successfulSaved = this.utils.customTranslation(settings.successfulSave, settings.successfulSave) || this.translate.instant('gateway.gateway-saved'); + + this.updateWidgetDisplaying(); + } + + private resize(): void { + this.ngZone.run(() => { + this.updateWidgetDisplaying(); + this.ctx.detectChanges(); + }); + } + + private updateWidgetDisplaying(): void { + if(this.ctx.$container && this.ctx.$container[0].offsetWidth <= 425){ + this.layoutGap = '0'; + this.alignment = 'column'; + } else { + this.layoutGap = '5px'; + this.alignment = 'row'; + } + } + + private saveAttribute(attributeName: string, attributeValue: string, attributeScope: AttributeScope): Observable { + const gatewayId = this.gatewayConfigurationGroup.get('gateway').value; + const attributes: AttributeData = { + key: attributeName, + value: attributeValue + }; + return this.attributeService.saveEntityAttributes(getEntityId(gatewayId), attributeScope, [attributes]); + } + + private createConnector(setting: GatewayFormConnectorModel = DEFAULT_CONNECTOR): void { + this.connectors.push(this.fb.group({ + enabled: [setting.enabled], + configType: [setting.configType, [Validators.required]], + name: [setting.name, [Validators.required]], + config: [setting.config, [Validators.nullValidator, ValidateJSON]] + })); + } + + private getFormField(name: string): AbstractControl { + return this.gatewayConfigurationGroup.get(name); + } + + private buildForm(): void { + this.gatewayConfigurationGroup = this.fb.group({ + gateway: [null], + accessToken: [null, [Validators.required]], + securityType: [SecurityType.accessToken], + host: [this.window.location.hostname, [Validators.required]], + port: [1883, [Validators.required, Validators.min(1), Validators.max(65535), Validators.pattern(/^-?[0-9]+$/)]], + remoteConfiguration: [true], + caCertPath: ['/etc/thingsboard-gateway/ca.pem'], + privateKeyPath: ['/etc/thingsboard-gateway/privateKey.pem'], + certPath: ['/etc/thingsboard-gateway/certificate.pem'], + remoteLoggingLevel: [GatewayLogLevel.debug], + remoteLoggingPathToLogs: ['./logs/', [Validators.required]], + storageType: [StorageType.memory], + readRecordsCount: [100, [Validators.required, Validators.min(1), Validators.pattern(/^-?[0-9]+$/)]], + maxRecordsCount: [10000, [Validators.required, Validators.min(1), Validators.pattern(/^-?[0-9]+$/)]], + maxFilesCount: [5, [Validators.required, Validators.min(1), Validators.pattern(/^-?[0-9]+$/)]], + dataFolderPath: ['./data/', [Validators.required]], + connectors: this.fb.array([]) + }); + + this.subscribeStorageType$ = this.getFormField('storageType').valueChanges.subscribe((value: StorageType) => { + if (value === StorageType.memory) { + this.getFormField('maxFilesCount').disable(); + this.getFormField('dataFolderPath').disable(); + } else { + this.getFormField('maxFilesCount').enable(); + this.getFormField('dataFolderPath').enable(); + } + }); + + this.subscribeGateway$ = this.getFormField('gateway').valueChanges.subscribe((gatewayId: string) => { + if (gatewayId !== null) { + forkJoin([ + this.deviceService.getDeviceCredentials(gatewayId).pipe(tap(deviceCredential => { + this.getFormField('accessToken').patchValue(deviceCredential.credentialsId); + })), + ...this.getAttributes(gatewayId)]).subscribe(() => { + this.gatewayConfigurationGroup.markAsPristine(); + this.ctx.detectChanges(); + }); + } else { + this.getFormField('accessToken').patchValue(''); + } + }) + } + + gatewayExist(): void { + this.ctx.showErrorToast(this.gatewayNameExists, 'top', 'left', this.toastTargetId); + } + + exportConfig(): void { + const gatewayConfiguration: GatewayFormModels = this.gatewayConfigurationGroup.value; + const filesZip: any = {}; + filesZip['tb_gateway.yaml'] = generateYAMLConfigFile(gatewayConfiguration); + generateConnectorConfigFiles(filesZip, gatewayConfiguration.connectors); + generateLogConfigFile(filesZip, gatewayConfiguration.remoteLoggingLevel, gatewayConfiguration.remoteLoggingPathToLogs); + this.importExport.exportJSZip(filesZip, this.archiveFileName); + this.saveAttribute( + REMOTE_LOGGING_LEVEL_ATTRIBUTE, + this.gatewayConfigurationGroup.value.remoteLoggingLevel.toUpperCase(), + AttributeScope.SHARED_SCOPE); + } + + addNewConnector(): void { + this.createConnector(); + } + + removeConnector(index: number): void { + if (index > -1) { + this.connectors.removeAt(index); + this.connectors.markAsDirty(); + } + } + + openConfigDialog($event: Event, index: number, config: object, type: string): void { + if ($event) { + $event.stopPropagation(); + $event.preventDefault(); + } + this.dialog.open(JsonObjectEditDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + jsonValue: config, + title: this.translate.instant('gateway.title-connectors-json', {typeName: type}) + } + }).afterClosed().subscribe( + (res) => { + if (res) { + this.connectors.at(index).get('config').patchValue(res); + this.ctx.detectChanges(); + } + } + ); + } + + private createConnectorName(connectors: GatewayFormConnectorModel[], name: string, index: number = 0): string { + const newKeyName = index ? name + index : name; + const indexRes = connectors.findIndex((element) => element.name === newKeyName); + return indexRes === -1 ? newKeyName : this.createConnectorName(connectors, name, ++index); + } + + private validateConnectorName(connectors: GatewayFormConnectorModel[], name: string, connectorIndex: number, index = 0): string { + for (let i = 0; i < connectors.length; i++) { + const nameEq = (index === 0) ? name : name + index; + if (i !== connectorIndex && connectors[i].name === nameEq) { + this.validateConnectorName(connectors, name, connectorIndex, ++index); + } + } + return (index === 0) ? name : name + index; + } + + changeConnectorType(connector: AbstractControl): void { + if (!connector.get('name').value) { + const typeConnector = connector.get('configType').value; + const connectors = this.gatewayConfigurationGroup.value.connectors; + connector.get('name').patchValue(this.createConnectorName(connectors, ConnectorType[typeConnector])); + } + } + + changeConnectorName(connector: AbstractControl, index: number): void { + const connectors = this.gatewayConfigurationGroup.value.connectors; + connector.get('name').patchValue(this.validateConnectorName(connectors, connector.get('name').value, index)); + } + + save(): void { + const gatewayConfiguration: GatewayFormModels = this.gatewayConfigurationGroup.value; + forkJoin([ + this.saveAttribute( + CONFIGURATION_ATTRIBUTE, + window.btoa(JSON.stringify(gatewayConfigJSON(gatewayConfiguration))), + AttributeScope.SHARED_SCOPE), + this.saveAttribute( + CONFIGURATION_DRAFT_ATTRIBUTE, + window.btoa(JSON.stringify(getDraftConnectorsJSON(gatewayConfiguration.connectors))), + AttributeScope.SERVER_SCOPE), + this.saveAttribute( + REMOTE_LOGGING_LEVEL_ATTRIBUTE, + this.gatewayConfigurationGroup.value.remoteLoggingLevel.toUpperCase(), + AttributeScope.SHARED_SCOPE) + ]).subscribe(() =>{ + this.ctx.showSuccessToast(this.successfulSaved, + 2000, 'top', 'left', this.toastTargetId); + this.gatewayConfigurationGroup.markAsPristine(); + }) + } + + private getAttributes(gatewayId: string): Array>> { + const tasks = []; + tasks.push(forkJoin([this.getAttribute(CURRENT_CONFIGURATION_ATTRIBUTE, AttributeScope.CLIENT_SCOPE, gatewayId), + this.getAttribute(CONFIGURATION_DRAFT_ATTRIBUTE, AttributeScope.SERVER_SCOPE, gatewayId)]).pipe( + tap(([currentConfig, draftConfig]) => { + this.setFormGatewaySettings(currentConfig); + this.setFormConnectorsDraft(draftConfig); + }) + ) + ); + tasks.push(this.getAttribute(REMOTE_LOGGING_LEVEL_ATTRIBUTE, AttributeScope.SHARED_SCOPE, gatewayId).pipe( + tap(logsLevel => this.processLoggingLevel(logsLevel)) + )); + return tasks; + } + + private getAttribute(attributeName: string, attributeScope: AttributeScope, gatewayId: string): Observable> { + return this.attributeService.getEntityAttributes(getEntityId(gatewayId), attributeScope, [attributeName]); + } + + private setFormGatewaySettings(response: Array): void { + this.connectors.clear(); + if (response.length > 0) { + const attribute = JSON.parse(window.atob(response[0].value)); + for (const attributeKey of Object.keys(attribute)) { + const keyValue = attribute[attributeKey]; + if (attributeKey === 'thingsboard') { + if (keyValue !== null && Object.keys(keyValue).length > 0) { + this.gatewayConfigurationGroup.patchValue(createFormConfig(keyValue)); + } + } else { + for (const connector of Object.keys(keyValue)) { + let name = 'No name'; + if (Object.prototype.hasOwnProperty.call(keyValue[connector], 'name')) { + name = keyValue[connector].name; + } + const newConnector: GatewayFormConnectorModel = { + enabled: true, + configType: (attributeKey as ConnectorType), + config: keyValue[connector].config, + name + }; + this.createConnector(newConnector); + } + } + } + } + } + + private setFormConnectorsDraft(response: Array): void { + if (response.length > 0) { + const attribute = JSON.parse(window.atob(response[0].value)); + for (const connectorName of Object.keys(attribute)) { + const newConnector: GatewayFormConnectorModel = { + enabled: false, + configType: (attribute[connectorName].connector as ConnectorType), + config: attribute[connectorName].config, + name: connectorName + }; + this.createConnector(newConnector); + } + } + } + + private processLoggingLevel(value: Array): void { + let logsLevel = GatewayLogLevel.debug; + if (value.length > 0 && GatewayLogLevel[value[0].value.toLowerCase()]) { + logsLevel = GatewayLogLevel[value[0].value.toLowerCase()]; + } + this.getFormField('remoteLoggingLevel').patchValue(logsLevel); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-form.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-form.models.ts new file mode 100644 index 0000000000..4cf5914459 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-form.models.ts @@ -0,0 +1,374 @@ +/// +/// Copyright © 2016-2020 The 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 { AbstractControl, ValidationErrors } from '@angular/forms'; +import { EntityId } from '@shared/models/id/entity-id'; +import { EntityType } from '@shared/models/entity-type.models'; + +export enum SecurityType { + tls = 'tls', + accessToken = 'accessToken' +} + +export interface WidgetSetting { + widgetTitle: string; + archiveFileName: string; + gatewayType: string; + successfulSave: string; + gatewayNameExists: string; +} + +export const CURRENT_CONFIGURATION_ATTRIBUTE = 'current_configuration'; +export const CONFIGURATION_DRAFT_ATTRIBUTE = 'configuration_drafts'; +export const CONFIGURATION_ATTRIBUTE = 'configuration'; +export const REMOTE_LOGGING_LEVEL_ATTRIBUTE = 'RemoteLoggingLevel'; + +export const SecurityTypeTranslationMap = new Map( + [ + [SecurityType.tls, 'gateway.security-types.tls'], + [SecurityType.accessToken, 'gateway.security-types.access-token'] + ] +); + +export enum GatewayLogLevel { + none = 'NONE', + critical = 'CRITICAL', + error = 'ERROR', + warning = 'WARNING', + info = 'INFO', + debug = 'DEBUG' +} + +export enum StorageType { + memory = 'memory', + file = 'file' +} + +export const StorageTypeTranslationMap = new Map( + [ + [StorageType.memory, 'gateway.storage-types.memory-storage'], + [StorageType.file, 'gateway.storage-types.file-storage'] + ] +); + +export enum ConnectorType { + mqtt= 'MQTT', + modbus = 'Modbus', + opc_ua = 'OPC-UA', + ble = 'BLE' +} + +export interface GatewayFormModels { + gateway?: string; + accessToken?: string; + securityType?: SecurityType; + host?: string; + port?: number; + remoteConfiguration?: boolean; + caCertPath?: string; + privateKeyPath?: string; + certPath?: string; + remoteLoggingLevel?: GatewayLogLevel; + remoteLoggingPathToLogs?:string; + storageType?: StorageType; + readRecordsCount?: number; + maxRecordsCount?: number; + maxFilesCount?: number; + dataFolderPath?: number; + connectors?: Array; +} + +export interface GatewayFormConnectorModel { + config: object; + name: string; + configType: ConnectorType; + enabled: boolean; +} + +export const DEFAULT_CONNECTOR: GatewayFormConnectorModel = { + config: {}, + name: '', + configType: null, + enabled: false +}; + +type Connector = { + [key in ConnectorType]?: Array; +} + +interface GatewaySetting extends Connector{ + thingsboard: GatewayMainSetting; +} + +interface ConnectorConfig { + name: string; + config: object; +} + +interface GatewayMainSetting { + thingsboard: GatewayMainThingsboardSetting; + connectors: Array, + logs: string, + storage: GatewayStorage +} + +interface GatewayMainThingsboardSetting { + host: string, + remoteConfiguration: boolean, + port: number, + security: GatewaySecurity +} + +type GatewaySecurity = SecurityToken | SecurityCertificate; + +interface SecurityToken { + accessToken: string; +} + +interface SecurityCertificate { + caCert: string; + privateKey: string; + cert: string; +} + +type GatewayStorage = GatewayStorageMemory | GatewayStorageFile; + +interface GatewayStorageMemory { + type: string; + max_records_count: number; + read_records_count: number; +} + +interface GatewayStorageFile { + type: string; + data_folder_path: number; + max_file_count: number; + max_read_records_count: number; + max_records_per_file: number; +} + +interface GatewayMainConnector { + configuration: string; + name: string; + type: ConnectorType; +} + +export function ValidateJSON(control: AbstractControl): ValidationErrors | null { + if (JSON.stringify(control.value) === JSON.stringify({})) { + return { validJSON: true }; + } + return null; +} + +const TEMPLATE_LOGS_CONFIG = '[loggers]}}keys=root, service, connector, converter, tb_connection, storage, extension}}[handlers]}}keys=consoleHandler, serviceHandler, connectorHandler, converterHandler, tb_connectionHandler, storageHandler, extensionHandler}}[formatters]}}keys=LogFormatter}}[logger_root]}}level=ERROR}}handlers=consoleHandler}}[logger_connector]}}level={ERROR}}}handlers=connectorHandler}}formatter=LogFormatter}}qualname=connector}}[logger_storage]}}level={ERROR}}}handlers=storageHandler}}formatter=LogFormatter}}qualname=storage}}[logger_tb_connection]}}level={ERROR}}}handlers=tb_connectionHandler}}formatter=LogFormatter}}qualname=tb_connection}}[logger_service]}}level={ERROR}}}handlers=serviceHandler}}formatter=LogFormatter}}qualname=service}}[logger_converter]}}level={ERROR}}}handlers=connectorHandler}}formatter=LogFormatter}}qualname=converter}}[logger_extension]}}level={ERROR}}}handlers=connectorHandler}}formatter=LogFormatter}}qualname=extension}}[handler_consoleHandler]}}class=StreamHandler}}level={ERROR}}}formatter=LogFormatter}}args=(sys.stdout,)}}[handler_connectorHandler]}}level={ERROR}}}class=logging.handlers.TimedRotatingFileHandler}}formatter=LogFormatter}}args=("{./logs/}connector.log", "d", 1, 7,)}}[handler_storageHandler]}}level={ERROR}}}class=logging.handlers.TimedRotatingFileHandler}}formatter=LogFormatter}}args=("{./logs/}storage.log", "d", 1, 7,)}}[handler_serviceHandler]}}level={ERROR}}}class=logging.handlers.TimedRotatingFileHandler}}formatter=LogFormatter}}args=("{./logs/}service.log", "d", 1, 7,)}}[handler_converterHandler]}}level={ERROR}}}class=logging.handlers.TimedRotatingFileHandler}}formatter=LogFormatter}}args=("{./logs/}converter.log", "d", 1, 3,)}}[handler_extensionHandler]}}level={ERROR}}}class=logging.handlers.TimedRotatingFileHandler}}formatter=LogFormatter}}args=("{./logs/}extension.log", "d", 1, 3,)}}[handler_tb_connectionHandler]}}level={ERROR}}}class=logging.handlers.TimedRotatingFileHandler}}formatter=LogFormatter}}args=("{./logs/}tb_connection.log", "d", 1, 3,)}}[formatter_LogFormatter]}}format="%(asctime)s - %(levelname)s - [%(filename)s] - %(module)s - %(lineno)d - %(message)s" }}datefmt="%Y-%m-%d %H:%M:%S"'; + +export function generateYAMLConfigFile(gatewaySetting: GatewayFormModels): string { + let config; + config = 'thingsboard:\n'; + config += ' host: ' + gatewaySetting.host + '\n'; + config += ' remoteConfiguration: ' + gatewaySetting.remoteConfiguration + '\n'; + config += ' port: ' + gatewaySetting.port + '\n'; + config += ' security:\n'; + if (gatewaySetting.securityType === SecurityType.accessToken) { + config += ' access-token: ' + gatewaySetting.accessToken + '\n'; + } else { + config += ' ca_cert: ' + gatewaySetting.caCertPath + '\n'; + config += ' privateKey: ' + gatewaySetting.privateKeyPath + '\n'; + config += ' cert: ' + gatewaySetting.certPath + '\n'; + } + config += 'storage:\n'; + if (gatewaySetting.storageType === StorageType.memory) { + config += ' type: memory\n'; + config += ' read_records_count: ' + gatewaySetting.readRecordsCount + '\n'; + config += ' max_records_count: ' + gatewaySetting.maxRecordsCount + '\n'; + } else { + config += ' type: file\n'; + config += ' data_folder_path: ' + gatewaySetting.dataFolderPath + '\n'; + config += ' max_file_count: ' + gatewaySetting.maxFilesCount + '\n'; + config += ' max_read_records_count: ' + gatewaySetting.readRecordsCount + '\n'; + config += ' max_records_per_file: ' + gatewaySetting.maxRecordsCount + '\n'; + } + config += 'connectors:\n'; + for(const connector of gatewaySetting.connectors){ + if (connector.enabled) { + config += ' -\n'; + config += ' name: ' + connector.name + '\n'; + config += ' type: ' + connector.configType + '\n'; + config += ' configuration: ' + generateFileName(connector.name) + '\n'; + } + } + return config; +} + +export function generateConnectorConfigFiles(fileZipAdd: object, connectors: Array): void { + for(const connector of connectors) { + if (connector.enabled) { + fileZipAdd[generateFileName(connector.name)] = JSON.stringify(connector.config); + } + } +} + +function generateFileName(fileName): string { + return fileName.replace('_', '') + .replace('-', '') + .replace(/^\s+|\s+/g, '') + .toLowerCase() + '.json'; +} + +export function generateLogConfigFile(fileZipAdd: object, logsLevel: string, logsPath: string): void { + fileZipAdd['logs.conf'] = getLogsConfig(logsLevel, logsPath); +} + +function getLogsConfig(logsLevel: string, logsPath: string): string { + return TEMPLATE_LOGS_CONFIG + .replace(/{ERROR}/g, logsLevel) + .replace(/{.\/logs\/}/g, logsPath); +} + +export function getEntityId(gatewayId: string): EntityId { + return { + id: gatewayId, + entityType: EntityType.DEVICE + } +} + +export function createFormConfig(keyValue: GatewayMainSetting): GatewayFormModels { + const formSetting: GatewayFormModels = {}; + if (Object.prototype.hasOwnProperty.call(keyValue, 'thingsboard')) { + formSetting.host = keyValue.thingsboard.host; + formSetting.port = keyValue.thingsboard.port; + formSetting.remoteConfiguration = keyValue.thingsboard.remoteConfiguration; + if (Object.prototype.hasOwnProperty.call(keyValue.thingsboard.security, SecurityType.accessToken)) { + formSetting.securityType = SecurityType.accessToken; + formSetting.accessToken = (keyValue.thingsboard.security as SecurityToken).accessToken; + } else { + formSetting.securityType = SecurityType.tls; + formSetting.caCertPath = (keyValue.thingsboard.security as SecurityCertificate).caCert; + formSetting.privateKeyPath = (keyValue.thingsboard.security as SecurityCertificate).privateKey; + formSetting.certPath = (keyValue.thingsboard.security as SecurityCertificate).cert; + } + } + + if (Object.prototype.hasOwnProperty.call(keyValue, 'storage') && Object.prototype.hasOwnProperty.call(keyValue.storage, 'type')) { + if (keyValue.storage.type === StorageType.memory) { + formSetting.storageType = StorageType.memory; + formSetting.readRecordsCount = (keyValue.storage as GatewayStorageMemory).read_records_count; + formSetting.maxRecordsCount = (keyValue.storage as GatewayStorageMemory).max_records_count; + } else if (keyValue.storage.type === StorageType.file) { + formSetting.storageType = StorageType.file; + formSetting.dataFolderPath = (keyValue.storage as GatewayStorageFile).data_folder_path; + formSetting.maxFilesCount = (keyValue.storage as GatewayStorageFile).max_file_count; + formSetting.readRecordsCount = (keyValue.storage as GatewayStorageMemory).read_records_count; + formSetting.maxRecordsCount = (keyValue.storage as GatewayStorageMemory).max_records_count; + } + } + return formSetting; +} + +export function getDraftConnectorsJSON(currentConnectors: Array) { + const draftConnectors = {}; + for(const connector of currentConnectors){ + if (!connector.enabled) { + draftConnectors[connector.name] = { + connector: connector.configType, + config: connector.config + }; + } + } + return draftConnectors; +} + +export function gatewayConfigJSON(gatewayConfiguration: GatewayFormModels): GatewaySetting { + const gatewayConfig = { + thingsboard: gatewayMainConfigJSON(gatewayConfiguration) + }; + gatewayConnectorJSON(gatewayConfig, gatewayConfiguration.connectors); + return gatewayConfig; +} + +function gatewayMainConfigJSON(gatewayConfiguration: GatewayFormModels): GatewayMainSetting { + let security: GatewaySecurity; + if (gatewayConfiguration.securityType === SecurityType.accessToken) { + security = { + accessToken: gatewayConfiguration.accessToken + } + } else { + security = { + caCert: gatewayConfiguration.caCertPath, + privateKey: gatewayConfiguration.privateKeyPath, + cert: gatewayConfiguration.certPath + } + } + const thingsboard: GatewayMainThingsboardSetting = { + host: gatewayConfiguration.host, + remoteConfiguration: gatewayConfiguration.remoteConfiguration, + port: gatewayConfiguration.port, + security + }; + + let storage: GatewayStorage; + if (gatewayConfiguration.storageType === StorageType.memory) { + storage = { + type: StorageType.memory, + read_records_count: gatewayConfiguration.readRecordsCount, + max_records_count: gatewayConfiguration.maxRecordsCount + }; + } else { + storage = { + type: StorageType.file, + data_folder_path: gatewayConfiguration.dataFolderPath, + max_file_count: gatewayConfiguration.maxFilesCount, + max_read_records_count: gatewayConfiguration.readRecordsCount, + max_records_per_file: gatewayConfiguration.maxRecordsCount + }; + } + + const connectors: Array = []; + for (const connector of gatewayConfiguration.connectors) { + if (connector.enabled) { + const connectorConfig: GatewayMainConnector = { + configuration: generateFileName(connector.name), + name: connector.name, + type: connector.configType + }; + connectors.push(connectorConfig); + } + } + + return { + thingsboard, + connectors, + storage, + logs: window.btoa(getLogsConfig(gatewayConfiguration.remoteLoggingLevel, gatewayConfiguration.remoteLoggingPathToLogs)) + } +} + +function gatewayConnectorJSON(gatewayConfiguration, currentConnectors: Array): void { + for (const connector of currentConnectors) { + if (connector.enabled) { + const typeConnector = connector.configType; + if (!Array.isArray(gatewayConfiguration[typeConnector])) { + gatewayConfiguration[typeConnector] = []; + } + + const connectorConfig: ConnectorConfig = { + name: connector.name, + config: connector.config + }; + gatewayConfiguration[typeConnector].push(connectorConfig); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts index c849c09193..4e00afe9c3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts @@ -33,6 +33,8 @@ import { import { MultipleInputWidgetComponent } from './lib/multiple-input-widget.component'; import { TripAnimationComponent } from './trip-animation/trip-animation.component'; import { WebCameraInputWidgetComponent } from './lib/web-camera-input.component'; +import { GatewayFormComponent } from './lib/gateway/gateway-form.component'; +import { ImportExportService } from '@home/components/import-export/import-export.service'; @NgModule({ declarations: @@ -47,7 +49,8 @@ import { WebCameraInputWidgetComponent } from './lib/web-camera-input.component' DateRangeNavigatorPanelComponent, MultipleInputWidgetComponent, TripAnimationComponent, - WebCameraInputWidgetComponent + WebCameraInputWidgetComponent, + GatewayFormComponent ], imports: [ CommonModule, @@ -64,10 +67,12 @@ import { WebCameraInputWidgetComponent } from './lib/web-camera-input.component' DateRangeNavigatorWidgetComponent, MultipleInputWidgetComponent, TripAnimationComponent, - WebCameraInputWidgetComponent + WebCameraInputWidgetComponent, + GatewayFormComponent ], providers: [ - CustomDialogService + CustomDialogService, + ImportExportService ] }) export class WidgetComponentsModule { } diff --git a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts index 5dd493a4f3..f4ddeb1b0c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts @@ -706,6 +706,7 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI this.dynamicWidgetComponentRef = this.widgetContentContainer.createComponent(this.widgetInfo.componentFactory, 0, injector); this.cd.detectChanges(); } catch (e) { + console.error(e); if (this.dynamicWidgetComponentRef) { this.dynamicWidgetComponentRef.destroy(); this.dynamicWidgetComponentRef = null; 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 07098ea49c..8dc4f5b1d2 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 @@ -17,7 +17,7 @@ -->
-

{{ (this.data.title ? this.data.title : 'details.edit-json') | translate }}

+

{{ title }}

+ + + + + +
+
+ gateway.no-gateway-found +
+ + + gateway.no-gateway-matching + + + + gateway.create-new-gateway + +
+
+
+ + {{ 'gateway.gateway-name-required' | translate }} + + diff --git a/ui-ngx/src/app/shared/components/entity/entity-gateway-select.component.ts b/ui-ngx/src/app/shared/components/entity/entity-gateway-select.component.ts new file mode 100644 index 0000000000..f37a249820 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-gateway-select.component.ts @@ -0,0 +1,223 @@ +/// +/// Copyright © 2016-2020 The 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, ElementRef, EventEmitter, forwardRef, Input, OnInit, Output, ViewChild } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { ENTER } from '@angular/cdk/keycodes'; +import { Observable, of } from 'rxjs'; +import { filter, map, mergeMap, reduce, share, switchMap, tap } from 'rxjs/operators'; +import { EntityService } from '@core/http/entity.service'; +import { EntityType } from '@shared/models/entity-type.models'; +import { Device } from '@shared/models/device.models'; +import { DialogService } from '@core/services/dialog.service'; +import { TranslateService } from '@ngx-translate/core'; +import { DeviceService } from '@core/http/device.service'; + +@Component({ + selector: 'tb-entity-gateway-select', + templateUrl: './entity-gateway-select.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntityGatewaySelectComponent), + multi: true + }] +}) + +export class EntityGatewaySelectComponent implements ControlValueAccessor, OnInit { + get required(): boolean { + return this.requiredValue; + } + + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + @Input() + set newGatewayType(value: string){ + this.gatewayType = value; + } + + @Output() + private gatewayNameExist = new EventEmitter(); + + constructor(private store: Store, + private entityService: EntityService, + private dialogService: DialogService, + private deviceService: DeviceService, + private translate: TranslateService, + private fb: FormBuilder) { + this.loadGatewayList(); + this.selectDeviceGatewayFormGroup = this.fb.group({ + gateway: [null] + }); + } + + private gatewayType = 'Gateway'; + private dirty: boolean; + private requiredValue: boolean; + private gatewayList: Array; + + searchText = ''; + filteredGateways: Observable>; + selectDeviceGatewayFormGroup: FormGroup; + modelValue: string | null; + + @ViewChild('deviceGatewayInput', {static: true}) deviceGatewayInput: ElementRef; + private propagateChange = (v: any) => { }; + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + this.filteredGateways = this.selectDeviceGatewayFormGroup.get('gateway').valueChanges + .pipe( + tap(value => { + let modelValue; + if (typeof value === 'string' || !value) { + modelValue = null; + } else { + modelValue = value.id.id; + } + this.updateView(modelValue); + if (value === null) { + this.clear(); + } + }), + map(value => value ? (typeof value === 'string' ? value : value.name) : ''), + mergeMap(name => this.fetchGateway(name) ), + share() + ); + } + + fetchGateway(searchText?: string): Observable> { + this.searchText = searchText; + let result = []; + if (searchText && searchText.length) { + result = this.gatewayList.filter((gateway) => gateway.name.toLowerCase().includes(searchText.toLowerCase())); + } else { + result = this.gatewayList; + } + return of(result); + } + + onFocus() { + if (this.dirty) { + this.selectDeviceGatewayFormGroup.get('gateway').updateValueAndValidity({onlySelf: true, emitEvent: true}); + this.dirty = false; + } + } + + displayGatewayFn(gateway?: Device): string | undefined { + return gateway ? gateway.name : undefined; + } + + setDisabledState(isDisabled: boolean): void { + } + + writeValue(value: string | null): void { + if(value === null){ + this.searchText = ''; + this.selectDeviceGatewayFormGroup.get('gateway').patchValue('', {emitEvent: false}); + this.dirty = true; + } + } + + clear(value: string = '', hideList?: boolean) { + this.searchText = value; + this.selectDeviceGatewayFormGroup.get('gateway').patchValue(value, {emitEvent: true}); + if(!hideList) { + setTimeout(() => { + this.deviceGatewayInput.nativeElement.blur(); + this.deviceGatewayInput.nativeElement.focus(); + }, 0); + } + } + + textIsNotEmpty(text: string): boolean { + return !!text && text.length > 0; + } + + gatewayNameEnter($event: KeyboardEvent) { + if ($event.keyCode === ENTER) { + if (!this.modelValue) { + this.createGateway($event, this.searchText); + } + } + } + + createGateway($event: Event, gatewayName: string) { + $event.preventDefault(); + $event.stopPropagation(); + const title = this.translate.instant('gateway.create-new-gateway'); + const content = this.translate.instant('gateway.create-new-gateway-text', {gatewayName}); + this.dialogService.confirm(title, content, null, null, true).subscribe(value => { + if(value){ + this.createDeviceGateway(gatewayName); + } else { + this.clear('', true); + } + }); + } + + private createDeviceGateway(gatewayName: string){ + this.deviceService.findByName(gatewayName, {ignoreErrors: true}).subscribe(value => { + this.gatewayNameExist.emit(gatewayName) + }, () => { + const newGateway: Device = { + name: gatewayName, + label: null, + type: this.gatewayType, + additionalInfo: { + gateway: true + } + }; + + this.deviceService.saveDevice(newGateway).subscribe( + (device) => { + this.searchText = ''; + this.gatewayList.push(device); + this.selectDeviceGatewayFormGroup.get('gateway').patchValue(device, {emitEvent: true}); + } + ); + }) + } + + private loadGatewayList(): void{ + this.entityService.getEntitiesByNameFilter(EntityType.DEVICE, '', -1).pipe( + switchMap(results => results), + filter((device) => (device as Device)?.additionalInfo?.gateway), + reduce((acc, val) => acc.concat(val), []) + ).subscribe((devices) => { + this.gatewayList = devices; + if(!this.searchText && this.gatewayList.length){ + this.selectDeviceGatewayFormGroup.get('gateway').patchValue(this.gatewayList[0], {emitEvent: true}); + } + }) + } + + private updateView(modelValue: any) { + this.propagateChange(modelValue); + } +} diff --git a/ui-ngx/src/app/shared/components/value-input.component.ts b/ui-ngx/src/app/shared/components/value-input.component.ts index f067e6e1b7..1cddd25292 100644 --- a/ui-ngx/src/app/shared/components/value-input.component.ts +++ b/ui-ngx/src/app/shared/components/value-input.component.ts @@ -21,7 +21,7 @@ import { isObject } from '@core/utils'; import { MatDialog } from '@angular/material/dialog'; import { JsonObjectEditDialogComponent, - JsonObjectEdittDialogData + JsonObjectEditDialogData } from '@shared/components/dialog/json-object-edit-dialog.component'; @Component({ @@ -69,7 +69,7 @@ export class ValueInputComponent implements OnInit, ControlValueAccessor { if ($event) { $event.stopPropagation(); } - this.dialog.open(JsonObjectEditDialogComponent, { + this.dialog.open(JsonObjectEditDialogComponent, { disableClose: true, panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], data: { diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts index 472cd9f01a..5008f10459 100644 --- a/ui-ngx/src/app/shared/shared.module.ts +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -94,7 +94,6 @@ import { EntityListSelectComponent } from '@shared/components/entity/entity-list import { JsonObjectEditComponent } from '@shared/components/json-object-edit.component'; import { FooterFabButtonsComponent } from '@shared/components/footer-fab-buttons.component'; import { CircularProgressDirective } from '@shared/components/circular-progress.directive'; -import { MatSpinner } from '@angular/material/progress-spinner'; import { FabToolbarComponent, FabActionsDirective, FabTriggerDirective } from '@shared/components/fab-toolbar.component'; import { DashboardSelectPanelComponent } from '@shared/components/dashboard-select-panel.component'; import { DashboardSelectComponent } from '@shared/components/dashboard-select.component'; @@ -129,6 +128,7 @@ import { TbJsonToStringDirective } from '@shared/components/directives/tb-json-t import { JsonObjectEditDialogComponent } from '@shared/components/dialog/json-object-edit-dialog.component'; import { HistorySelectorComponent } from './components/time/history-selector/history-selector.component'; import { TbTemplatePipe } from '@shared/pipe/template.pipe'; +import { EntityGatewaySelectComponent } from '@shared/components/entity/entity-gateway-select.component'; @NgModule({ providers: [ @@ -213,7 +213,8 @@ import { TbTemplatePipe } from '@shared/pipe/template.pipe'; KeyboardShortcutPipe, TbJsonToStringDirective, JsonObjectEditDialogComponent, - HistorySelectorComponent + HistorySelectorComponent, + EntityGatewaySelectComponent ], imports: [ CommonModule, @@ -373,7 +374,8 @@ import { TbTemplatePipe } from '@shared/pipe/template.pipe'; KeyboardShortcutPipe, TranslateModule, JsonObjectEditDialogComponent, - HistorySelectorComponent + HistorySelectorComponent, + EntityGatewaySelectComponent ] }) export class SharedModule { } 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 978e2cda87..40182763a0 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -53,7 +53,8 @@ "export": "Export", "share-via": "Share via {{provider}}", "continue": "Continue", - "discard-changes": "Discard Changes" + "discard-changes": "Discard Changes", + "download": "Download" }, "aggregation": { "aggregation": "Aggregation", diff --git a/ui-ngx/tsconfig.json b/ui-ngx/tsconfig.json index 8e1880e048..bef693e404 100644 --- a/ui-ngx/tsconfig.json +++ b/ui-ngx/tsconfig.json @@ -30,7 +30,10 @@ "@core/*": ["src/app/core/*"], "@modules/*": ["src/app/modules/*"], "@shared/*": ["src/app/shared/*"], - "@home/*": ["src/app/modules/home/*"] + "@home/*": ["src/app/modules/home/*"], + "jszip": [ + "node_modules/jszip/dist/jszip.min.js" + ] }, "lib": [ "es2018",