Commit dad2a26f22ad40f75d769675e17cc4ce22e759b0

Authored by Sergey Tarnavskiy
1 parent 05dabec2

added mqtt form

@@ -332,6 +332,24 @@ export default angular.module('thingsboard.types', []) @@ -332,6 +332,24 @@ export default angular.module('thingsboard.types', [])
332 toDouble: 'extension.to-double', 332 toDouble: 'extension.to-double',
333 custom: 'extension.custom' 333 custom: 'extension.custom'
334 }, 334 },
  335 + mqttConverterTypes: {
  336 + json: 'extension.converter-json',
  337 + custom: 'extension.custom'
  338 + },
  339 + mqttCredentialTypes: {
  340 + anonymous: {
  341 + value: "anonymous",
  342 + name: "extension.anonymous"
  343 + },
  344 + basic: {
  345 + value: "basic",
  346 + name: "extension.basic"
  347 + },
  348 + pem: {
  349 + value: "cert.PEM",
  350 + name: "extension.pem"
  351 + }
  352 + },
335 latestTelemetry: { 353 latestTelemetry: {
336 value: "LATEST_TELEMETRY", 354 value: "LATEST_TELEMETRY",
337 name: "attribute.scope-latest-telemetry", 355 name: "attribute.scope-latest-telemetry",
@@ -45,6 +45,7 @@ export default function ExtensionDialogController($scope, $mdDialog, $translate, @@ -45,6 +45,7 @@ export default function ExtensionDialogController($scope, $mdDialog, $translate,
45 $mdDialog.cancel(); 45 $mdDialog.cancel();
46 } 46 }
47 function save() { 47 function save() {
  48 + $mdDialog.hide();
48 saveTransformers(); 49 saveTransformers();
49 if(vm.isAdd) { 50 if(vm.isAdd) {
50 vm.allExtensions.push(vm.newExtension); 51 vm.allExtensions.push(vm.newExtension);
@@ -60,7 +61,6 @@ export default function ExtensionDialogController($scope, $mdDialog, $translate, @@ -60,7 +61,6 @@ export default function ExtensionDialogController($scope, $mdDialog, $translate,
60 attributeService.saveEntityAttributes(vm.entityType, vm.entityId, types.attributesScope.shared.value, [{key:"configuration", value:editedValue}]).then( 61 attributeService.saveEntityAttributes(vm.entityType, vm.entityId, types.attributesScope.shared.value, [{key:"configuration", value:editedValue}]).then(
61 function success() { 62 function success() {
62 $scope.theForm.$setPristine(); 63 $scope.theForm.$setPristine();
63 - $mdDialog.hide();  
64 } 64 }
65 ); 65 );
66 } 66 }
@@ -85,21 +85,39 @@ export default function ExtensionDialogController($scope, $mdDialog, $translate, @@ -85,21 +85,39 @@ export default function ExtensionDialogController($scope, $mdDialog, $translate,
85 } 85 }
86 86
87 function saveTransformers() { 87 function saveTransformers() {
88 - var config = vm.newExtension.configuration.converterConfigurations;  
89 if(vm.newExtension.type == types.extensionType.http) { 88 if(vm.newExtension.type == types.extensionType.http) {
90 - for(let i=0;i<config.length;i++) {  
91 - for(let j=0;j<config[i].converters.length;j++){  
92 - for(let k=0;k<config[i].converters[j].attributes.length;k++){  
93 - if(config[i].converters[j].attributes[k].transformerType == "toDouble"){  
94 - config[i].converters[j].attributes[k].transformer = {type: "intToDouble"}; 89 + var config = vm.newExtension.configuration.converterConfigurations;
  90 + if(config && config.length > 0) {
  91 + for(let i=0;i<config.length;i++) {
  92 + for(let j=0;j<config[i].converters.length;j++){
  93 + for(let k=0;k<config[i].converters[j].attributes.length;k++){
  94 + if(config[i].converters[j].attributes[k].transformerType == "toDouble"){
  95 + config[i].converters[j].attributes[k].transformer = {type: "intToDouble"};
  96 + }
  97 + delete config[i].converters[j].attributes[k].transformerType;
  98 + }
  99 + for(let l=0;l<config[i].converters[j].timeseries.length;l++) {
  100 + if(config[i].converters[j].timeseries[l].transformerType == "toDouble"){
  101 + config[i].converters[j].timeseries[l].transformer = {type: "intToDouble"};
  102 + }
  103 + delete config[i].converters[j].timeseries[l].transformerType;
95 } 104 }
96 - delete config[i].converters[j].attributes[k].transformerType;  
97 } 105 }
98 - for(let l=0;l<config[i].converters[j].timeseries.length;l++) {  
99 - if(config[i].converters[j].timeseries[l].transformerType == "toDouble"){  
100 - config[i].converters[j].timeseries[l].transformer = {type: "intToDouble"}; 106 + }
  107 + }
  108 + }
  109 + if(vm.newExtension.type == types.extensionType.mqtt) {
  110 + var brokers = vm.newExtension.configuration.brokers;
  111 + if(brokers && brokers.length > 0) {
  112 + for(let i=0;i<brokers.length;i++) {
  113 + if(brokers[i].mapping && brokers[i].mapping.length > 0) {
  114 + for(let j=0;j<brokers[i].mapping.length;j++) {
  115 + if(brokers[i].mapping[j].converterType == "json") {
  116 + delete brokers[i].mapping[j].converter.nameExp;
  117 + delete brokers[i].mapping[j].converter.typeExp;
  118 + }
  119 + delete brokers[i].mapping[j].converterType;
101 } 120 }
102 - delete config[i].converters[j].timeseries[l].transformerType;  
103 } 121 }
104 } 122 }
105 } 123 }
@@ -107,8 +125,8 @@ export default function ExtensionDialogController($scope, $mdDialog, $translate, @@ -107,8 +125,8 @@ export default function ExtensionDialogController($scope, $mdDialog, $translate,
107 } 125 }
108 126
109 function editTransformers(extension) { 127 function editTransformers(extension) {
110 - var config = extension.configuration.converterConfigurations;  
111 if(extension.type == types.extensionType.http) { 128 if(extension.type == types.extensionType.http) {
  129 + var config = extension.configuration.converterConfigurations;
112 for(let i=0;i<config.length;i++) { 130 for(let i=0;i<config.length;i++) {
113 for(let j=0;j<config[i].converters.length;j++){ 131 for(let j=0;j<config[i].converters.length;j++){
114 for(let k=0;k<config[i].converters[j].attributes.length;k++){ 132 for(let k=0;k<config[i].converters[j].attributes.length;k++){
@@ -134,6 +152,30 @@ export default function ExtensionDialogController($scope, $mdDialog, $translate, @@ -134,6 +152,30 @@ export default function ExtensionDialogController($scope, $mdDialog, $translate,
134 } 152 }
135 } 153 }
136 } 154 }
  155 + if(extension.type == types.extensionType.mqtt) {
  156 + var brokers = extension.configuration.brokers;
  157 + for(let i=0;i<brokers.length;i++) {
  158 + if(brokers[i].mapping && brokers[i].mapping.length > 0) {
  159 + for(let j=0;j<brokers[i].mapping.length;j++) {
  160 + if(brokers[i].mapping[j].converter.type == "json") {
  161 + if(brokers[i].mapping[j].converter.deviceNameTopicExpression) {
  162 + brokers[i].mapping[j].converter.nameExp = "deviceNameTopicExpression";
  163 + } else {
  164 + brokers[i].mapping[j].converter.nameExp = "deviceNameJsonExpression";
  165 + }
  166 + if(brokers[i].mapping[j].converter.deviceTypeTopicExpression) {
  167 + brokers[i].mapping[j].converter.typeExp = "deviceTypeTopicExpression";
  168 + } else {
  169 + brokers[i].mapping[j].converter.typeExp = "deviceTypeJsonExpression";
  170 + }
  171 + brokers[i].mapping[j].converterType = "json";
  172 + } else {
  173 + brokers[i].mapping[j].converterType = "custom";
  174 + }
  175 + }
  176 + }
  177 + }
  178 + }
137 } 179 }
138 } 180 }
139 181
@@ -55,6 +55,7 @@ @@ -55,6 +55,7 @@
55 </section> 55 </section>
56 56
57 <div tb-extension-form-http config="vm.configuration" is-add="vm.isAdd" ng-if="vm.newExtension.type && vm.newExtension.type == vm.types.extensionType.http"></div> 57 <div tb-extension-form-http config="vm.configuration" is-add="vm.isAdd" ng-if="vm.newExtension.type && vm.newExtension.type == vm.types.extensionType.http"></div>
  58 + <div tb-extension-form-mqtt config="vm.configuration" is-add="vm.isAdd" ng-if="vm.newExtension.type && vm.newExtension.type == vm.types.extensionType.mqtt"></div>
58 59
59 </fieldset> 60 </fieldset>
60 61
1 -/* 1 +/**
2 * Copyright © 2016-2017 The Thingsboard Authors 2 * Copyright © 2016-2017 The Thingsboard Authors
3 * 3 *
4 * Licensed under the Apache License, Version 2.0 (the "License"); 4 * Licensed under the Apache License, Version 2.0 (the "License");
@@ -132,7 +132,7 @@ export default function ExtensionFormHttpDirective($compile, $templateCache, $tr @@ -132,7 +132,7 @@ export default function ExtensionFormHttpDirective($compile, $templateCache, $tr
132 } 132 }
133 } 133 }
134 } 134 }
135 - 135 +
136 $compile(element.contents())(scope); 136 $compile(element.contents())(scope);
137 } 137 }
138 138
  1 +/*
  2 + * Copyright © 2016-2017 The Thingsboard Authors
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +
  17 +import './extension-form.scss';
  18 +
  19 +/* eslint-disable angular/log */
  20 +
  21 +import extensionFormMqttTemplate from './extension-form-mqtt.tpl.html';
  22 +
  23 +/* eslint-enable import/no-unresolved, import/default */
  24 +
  25 +/*@ngInject*/
  26 +export default function ExtensionFormHttpDirective($compile, $templateCache, $translate, types) {
  27 +
  28 + var linker = function(scope, element) {
  29 +
  30 + var template = $templateCache.get(extensionFormMqttTemplate);
  31 + element.html(template);
  32 +
  33 + scope.types = types;
  34 + scope.theForm = scope.$parent.theForm;
  35 +
  36 + scope.nameExpressions = {
  37 + deviceNameJsonExpression: "extension.converter-json",
  38 + deviceNameTopicExpression: "extension.topic"
  39 + };
  40 + scope.typeExpressions = {
  41 + deviceTypeJsonExpression: "extension.converter-json",
  42 + deviceTypeTopicExpression: "extension.topic"
  43 + };
  44 +
  45 + scope.extensionCustomConverterOptions = {
  46 + useWrapMode: false,
  47 + mode: 'json',
  48 + showGutter: true,
  49 + showPrintMargin: true,
  50 + theme: 'github',
  51 + advanced: {
  52 + enableSnippets: true,
  53 + enableBasicAutocompletion: true,
  54 + enableLiveAutocompletion: true
  55 + },
  56 + onLoad: function(_ace) {
  57 + _ace.$blockScrolling = 1;
  58 + }
  59 + };
  60 +
  61 +
  62 + if(scope.isAdd) {
  63 + scope.brokers = [];
  64 + scope.config.brokers = scope.brokers;
  65 + } else {
  66 + scope.brokers = scope.config.brokers;
  67 + }
  68 +
  69 + scope.updateValidity = function () {
  70 + var valid = scope.brokers && scope.brokers.length > 0;
  71 + scope.theForm.$setValidity('brokers', valid);
  72 + if(scope.brokers.length) {
  73 + for(let i=0;i<scope.brokers.length;i++) {
  74 + if(scope.brokers[i].credentials.type == scope.types.mqttCredentialTypes.pem.value) {
  75 + if(!(scope.brokers[i].credentials.caCert && scope.brokers[i].credentials.privateKey && scope.brokers[i].credentials.cert)) {
  76 + scope.theForm.$setValidity('cert.PEM', false);
  77 + break;
  78 + } else {
  79 + scope.theForm.$setValidity('cert.PEM', true);
  80 + }
  81 + }
  82 + }
  83 + }
  84 + }
  85 +
  86 + scope.$watch('brokers', function() {
  87 + scope.updateValidity();
  88 + }, true);
  89 +
  90 + scope.addBroker = function() {
  91 + var newBroker = {host:"localhost", port:1882, ssl:false, retryInterval:3000, credentials:{type:"anonymous"}, mapping:[]};
  92 + scope.brokers.push(newBroker);
  93 + }
  94 +
  95 + scope.removeBroker = function(broker) {
  96 + var index = scope.brokers.indexOf(broker);
  97 + if (index > -1) {
  98 + scope.brokers.splice(index, 1);
  99 + }
  100 + scope.theForm.$setDirty();
  101 + }
  102 +
  103 + scope.addMap = function(mapping) {
  104 + var newMap = {topicFilter:"sensors", converter:{attributes:[],timeseries:[]}};
  105 +
  106 + mapping.push(newMap);
  107 + }
  108 +
  109 + scope.removeMap = function(map, mapping) {
  110 + var index = mapping.indexOf(map);
  111 + if (index > -1) {
  112 + mapping.splice(index, 1);
  113 + }
  114 + scope.theForm.$setDirty();
  115 + }
  116 +
  117 + scope.addAttribute = function(attributes) {
  118 + var newAttribute = {type:"", key:"", value:""};
  119 + attributes.push(newAttribute);
  120 + }
  121 +
  122 + scope.removeAttribute = function(attribute, attributes) {
  123 + var index = attributes.indexOf(attribute);
  124 + if (index > -1) {
  125 + attributes.splice(index, 1);
  126 + }
  127 + scope.theForm.$setDirty();
  128 + }
  129 +
  130 + scope.changeCredentials = function(broker) {
  131 + var type = broker.credentials.type;
  132 + broker.credentials = {};
  133 + broker.credentials.type = type;
  134 + }
  135 +
  136 + scope.changeConverterType = function(map) {
  137 + if(map.converterType == "custom"){
  138 + map.converter = "";
  139 + }
  140 + if(map.converterType == "json") {
  141 + map.converter = {attributes:[],timeseries:[]};
  142 + }
  143 + }
  144 +
  145 + scope.changeNameExpression = function(converter) {
  146 + if(converter.nameExp == "deviceNameJsonExpression") {
  147 + if(converter.deviceNameTopicExpression) {
  148 + delete converter.deviceNameTopicExpression;
  149 + }
  150 + }
  151 + if(converter.nameExp == "deviceNameTopicExpression") {
  152 + if(converter.deviceNameJsonExpression) {
  153 + delete converter.deviceNameJsonExpression;
  154 + }
  155 + }
  156 + }
  157 +
  158 + scope.changeTypeExpression = function(converter) {
  159 + if(converter.typeExp == "deviceTypeJsonExpression") {
  160 + if(converter.deviceTypeTopicExpression) {
  161 + delete converter.deviceTypeTopicExpression;
  162 + }
  163 + }
  164 + if(converter.typeExp == "deviceTypeTopicExpression") {
  165 + if(converter.deviceTypeJsonExpression) {
  166 + delete converter.deviceTypeJsonExpression;
  167 + }
  168 + }
  169 + }
  170 +
  171 + scope.validateCustomConverter = function(model, editorName) {
  172 + if(model && model.length) {
  173 + try {
  174 + angular.fromJson(model);
  175 + scope.theForm[editorName].$setValidity('converterJSON', true);
  176 + } catch(e) {
  177 + scope.theForm[editorName].$setValidity('converterJSON', false);
  178 + }
  179 + }
  180 + }
  181 +
  182 + scope.fileAdded = function($file, broker, fileType) {
  183 + var reader = new FileReader();
  184 + reader.onload = function(event) {
  185 + scope.$apply(function() {
  186 + if(event.target.result) {
  187 + scope.theForm.$setDirty();
  188 + var addedFile = event.target.result;
  189 + if (addedFile && addedFile.length > 0) {
  190 + if(fileType == "caCert") {
  191 + broker.credentials.caCertFileName = $file.name;
  192 + broker.credentials.caCert = addedFile.replace(/^data.*base64,/, "");
  193 + }
  194 + if(fileType == "privateKey") {
  195 + broker.credentials.privateKeyFileName = $file.name;
  196 + broker.credentials.privateKey = addedFile.replace(/^data.*base64,/, "");
  197 + }
  198 + if(fileType == "Cert") {
  199 + broker.credentials.certFileName = $file.name;
  200 + broker.credentials.cert = addedFile.replace(/^data.*base64,/, "");
  201 + }
  202 + }
  203 + }
  204 + });
  205 + };
  206 + reader.readAsDataURL($file.file);
  207 + }
  208 +
  209 + scope.clearFile = function(broker, fileType) {
  210 + scope.theForm.$setDirty();
  211 + if(fileType == "caCert") {
  212 + broker.credentials.caCertFileName = null;
  213 + broker.credentials.caCert = null;
  214 + }
  215 + if(fileType == "privateKey") {
  216 + broker.credentials.privateKeyFileName = null;
  217 + broker.credentials.privateKey = null;
  218 + }
  219 + if(fileType == "Cert") {
  220 + broker.credentials.certFileName = null;
  221 + broker.credentials.cert = null;
  222 + }
  223 + }
  224 +
  225 + $compile(element.contents())(scope);
  226 + }
  227 +
  228 + return {
  229 + restrict: "A",
  230 + link: linker,
  231 + scope: {
  232 + config: "=",
  233 + isAdd: "="
  234 + }
  235 + }
  236 +}
@@ -15,4 +15,439 @@ @@ -15,4 +15,439 @@
15 limitations under the License. 15 limitations under the License.
16 16
17 --> 17 -->
18 -<div>MQTT</div>  
  18 +<md-card class="extension-form extension-mqtt">
  19 + <md-card-title name="testValid">
  20 + <md-card-title-text>
  21 + <span translate class="md-headline">extension.configuration</span>
  22 + </md-card-title-text>
  23 + </md-card-title>
  24 + <md-card-content>
  25 + <v-accordion id="mqtt-brokers-accordion" class="vAccordion--default">
  26 + <v-pane id="mqtt-brokers-pane" expanded="isAdd">
  27 + <v-pane-header>
  28 + {{ 'extension.brokers' | translate }}
  29 + </v-pane-header>
  30 + <v-pane-content>
  31 + <div ng-if="brokers.length === 0">
  32 + <span translate layout-align="center center" class="tb-prompt">extension.add-broker-prompt</span>
  33 + </div>
  34 + <div ng-if="brokers.length > 0">
  35 + <ol class="list-group">
  36 + <li class="list-group-item" ng-repeat="(brokerIndex,broker) in brokers">
  37 + <md-button aria-label="{{ 'action.remove' | translate }}" class="md-icon-button" ng-click="removeBroker(broker)">
  38 + <ng-md-icon icon="close" aria-label="{{ 'action.remove' | translate }}"></ng-md-icon>
  39 + <md-tooltip md-direction="top">
  40 + {{ 'action.remove' | translate }}
  41 + </md-tooltip>
  42 + </md-button>
  43 + <md-card>
  44 + <md-card-content>
  45 + <section flex layout="row">
  46 + <md-input-container flex="40" class="md-block">
  47 + <label translate>extension.port</label>
  48 + <input required type="number" min="1" max="65535" name="mqttPort_{{brokerIndex}}" ng-model="broker.port">
  49 + <div ng-messages="theForm['mqttPort_' + brokerIndex].$error">
  50 + <div translate ng-message="required">extension.port-required</div>
  51 + <div translate ng-message="min">extension.port-range</div>
  52 + <div translate ng-message="max">extension.port-range</div>
  53 + </div>
  54 + </md-input-container>
  55 + <md-input-container flex="60" class="md-block">
  56 + <label translate>extension.host</label>
  57 + <input required name="mqttHost_{{brokerIndex}}" ng-model="broker.host">
  58 + <div ng-messages="theForm['mqttHost_' + brokerIndex].$error">
  59 + <div translate ng-message="required">extension.host-required</div>
  60 + </div>
  61 + </md-input-container>
  62 + </section>
  63 + <section flex layout="row">
  64 + <md-input-container flex="40" class="md-block">
  65 + <label translate>extension.retry-interval</label>
  66 + <input required type="number" name="mqttRetryInterval_{{brokerIndex}}" ng-model="broker.retryInterval">
  67 + <div ng-messages="theForm['mqttRetryInterval_' + brokerIndex].$error">
  68 + <div translate ng-message="required">extension.retry-interval-required</div>
  69 + </div>
  70 + </md-input-container>
  71 + <md-input-container flex="50" class="md-block">
  72 + <label translate>extension.credentials</label>
  73 + <md-select required name="mqttCredentials_{{brokerIndex}}" ng-model="broker.credentials.type" ng-change="changeCredentials(broker)">
  74 + <md-option ng-repeat="(credentialsType, credentialsValue) in types.mqttCredentialTypes" ng-value="credentialsValue.value">
  75 + {{credentialsValue.name | translate}}
  76 + </md-option>
  77 + </md-select>
  78 + </md-input-container>
  79 + <md-input-container flex="10" class="md-block t-right">
  80 + <md-checkbox flex aria-label="{{ 'extension.ssl' | translate }}"
  81 + ng-model="broker.ssl">{{ 'extension.ssl' | translate }}
  82 + </md-checkbox>
  83 + </md-input-container>
  84 + </section>
  85 + <section flex layout="row" ng-if='broker.credentials.type == "basic"'>
  86 + <md-input-container flex="40" class="md-block">
  87 + <label translate>extension.username</label>
  88 + <input required name="mqttUsername_{{brokerIndex}}" ng-model="broker.credentials.username">
  89 + <div ng-messages="theForm['mqttUsername_' + brokerIndex].$error">
  90 + <div translate ng-message="required">extension.username-required</div>
  91 + </div>
  92 + </md-input-container>
  93 + <md-input-container flex="60" class="md-block">
  94 + <label translate>extension.password</label>
  95 + <input required name="mqttPassword_{{brokerIndex}}" ng-model="broker.credentials.password">
  96 + <div ng-messages="theForm['mqttPassword_' + brokerIndex].$error">
  97 + <div translate ng-message="required">extension.password-required</div>
  98 + </div>
  99 + </md-input-container>
  100 + </section>
  101 + <section flex layout="column" ng-if='broker.credentials.type == "cert.PEM"'>
  102 + <div class="tb-container">
  103 + <label class="tb-label" translate>extension.ca-cert</label>
  104 + <div flow-init="{singleFile:true}" flow-file-added='fileAdded($file, broker, "caCert")' class="tb-file-select-container">
  105 + <div class="tb-file-clear-container">
  106 + <md-button ng-click='clearFile(broker, "caCert")' class="tb-file-clear-btn md-icon-button md-primary" aria-label="{{ 'action.remove' | translate }}">
  107 + <md-tooltip md-direction="top">
  108 + {{ 'action.remove' | translate }}
  109 + </md-tooltip>
  110 + <md-icon aria-label="{{ 'action.remove' | translate }}" class="material-icons">close</md-icon>
  111 + </md-button>
  112 + </div>
  113 + <div class="alert tb-flow-drop" flow-drop>
  114 + <label for="caCertSelect_{{brokerIndex}}" translate>extension.drop-file</label>
  115 + <input class="file-input" flow-btn flow-attrs="{accept:'.pem'}" id="caCertSelect_{{brokerIndex}}">
  116 + </div>
  117 + </div>
  118 + </div>
  119 + <div class="dropdown-messages">
  120 + <div ng-if="!broker.credentials.caCertFileName" class="tb-error-message" translate>extension.no-file</div>
  121 + <div ng-if="broker.credentials.caCertFileName">{{broker.credentials.caCertFileName}}</div>
  122 + </div>
  123 + <div class="tb-container">
  124 + <label class="tb-label" translate>extension.private-key</label>
  125 + <div flow-init="{singleFile:true}" flow-file-added='fileAdded($file, broker, "privateKey")' class="tb-file-select-container">
  126 + <div class="tb-file-clear-container">
  127 + <md-button ng-click='clearFile(broker, "privateKey")' class="tb-file-clear-btn md-icon-button md-primary" aria-label="{{ 'action.remove' | translate }}">
  128 + <md-tooltip md-direction="top">
  129 + {{ 'action.remove' | translate }}
  130 + </md-tooltip>
  131 + <md-icon aria-label="{{ 'action.remove' | translate }}" class="material-icons">close</md-icon>
  132 + </md-button>
  133 + </div>
  134 + <div class="alert tb-flow-drop" flow-drop>
  135 + <label for="privateKeySelect_{{brokerIndex}}" translate>extension.drop-file</label>
  136 + <input class="file-input" flow-btn flow-attrs="{accept:'.pem'}" id="privateKeySelect_{{brokerIndex}}">
  137 + </div>
  138 + </div>
  139 + </div>
  140 + <div class="dropdown-messages">
  141 + <div ng-if="!broker.credentials.privateKeyFileName" class="tb-error-message" translate>extension.no-file</div>
  142 + <div ng-if="broker.credentials.privateKeyFileName">{{broker.credentials.privateKeyFileName}}</div>
  143 + </div>
  144 + <div class="tb-container" ng-class="broker.credentials.certFileName ? 'ng-valid' : 'ng-invalid'">
  145 + <label class="tb-label" translate>extension.cert</label>
  146 + <div flow-init="{singleFile:true}" flow-file-added='fileAdded($file, broker, "Cert")' class="tb-file-select-container">
  147 + <div class="tb-file-clear-container">
  148 + <md-button ng-click='clearFile(broker, "Cert")' class="tb-file-clear-btn md-icon-button md-primary" aria-label="{{ 'action.remove' | translate }}">
  149 + <md-tooltip md-direction="top">
  150 + {{ 'action.remove' | translate }}
  151 + </md-tooltip>
  152 + <md-icon aria-label="{{ 'action.remove' | translate }}" class="material-icons">close</md-icon>
  153 + </md-button>
  154 + </div>
  155 + <div class="alert tb-flow-drop" flow-drop>
  156 + <label for="CertSelect_{{brokerIndex}}" translate>extension.drop-file</label>
  157 + <input class="file-input" flow-btn flow-attrs="{accept:'.pem'}" id="CertSelect_{{brokerIndex}}">
  158 + </div>
  159 + </div>
  160 + </div>
  161 + <div class="dropdown-messages">
  162 + <div ng-if="!broker.credentials.certFileName" class="tb-error-message" translate>extension.no-file</div>
  163 + <div ng-if="broker.credentials.certFileName">{{broker.credentials.certFileName}}</div>
  164 + </div>
  165 + </section>
  166 +
  167 + <v-accordion id="mqtt-mapping-accordion" class="vAccordion--default">
  168 + <v-pane id="mqtt-mapping-pane">
  169 + <v-pane-header>
  170 + {{ 'extension.mapping' | translate }}
  171 + </v-pane-header>
  172 + <v-pane-content>
  173 + <div ng-if="broker.mapping.length > 0">
  174 + <ol class="list-group">
  175 + <li class="list-group-item" ng-repeat="(mapIndex,map) in broker.mapping">
  176 + <md-button aria-label="{{ 'action.remove' | translate }}" class="md-icon-button" ng-click="removeMap(map, broker.mapping)">
  177 + <ng-md-icon icon="close" aria-label="{{ 'action.remove' | translate }}"></ng-md-icon>
  178 + <md-tooltip md-direction="top">
  179 + {{ 'action.remove' | translate }}
  180 + </md-tooltip>
  181 + </md-button>
  182 + <md-card>
  183 + <md-card-content>
  184 + <section flex layout="row">
  185 + <md-input-container flex="40" class="md-block">
  186 + <label translate>extension.converter-type</label>
  187 + <md-select required name="mqttConverterType_{{brokerIndex}}{{mapIndex}}" ng-model="map.converterType" ng-change="changeConverterType(map)">
  188 + <md-option ng-repeat="(converterType, value) in types.mqttConverterTypes" ng-value="converterType">
  189 + {{value | translate}}
  190 + </md-option>
  191 + </md-select>
  192 + <div ng-messages="theForm['mqttConverterType_' + brokerIndex + mapIndex].$error">
  193 + <div translate ng-message="required">extension.converter-type-required</div>
  194 + </div>
  195 + </md-input-container>
  196 + <md-input-container flex="60" class="md-block">
  197 + <label translate>extension.topic-filter</label>
  198 + <input required name="mqttTopicFilter_{{brokerIndex}}{{mapIndex}}" ng-model="map.topicFilter">
  199 + <div ng-messages="theForm['mqttTopicFilter_' + brokerIndex + mapIndex].$error">
  200 + <div translate ng-message="required">extension.topic-filter-required</div>
  201 + </div>
  202 + </md-input-container>
  203 + </section>
  204 +
  205 + <div ng-if='map.converterType =="json"' ng-init="map.converter.type = 'json'">
  206 + <section flex layout="row">
  207 + <md-input-container flex="40" class="md-block">
  208 + <label translate>extension.name-expression</label>
  209 + <md-select required name="mqttDeviceNameExpression_{{brokerIndex}}{{mapIndex}}" ng-model="map.converter.nameExp" ng-change="changeNameExpression(map.converter)">
  210 + <md-option ng-repeat="(key, value) in nameExpressions" ng-value='key'>
  211 + {{value | translate}}
  212 + </md-option>
  213 + </md-select>
  214 + </md-input-container>
  215 + <md-input-container ng-if="map.converter.nameExp == 'deviceNameJsonExpression'" flex="60" class="md-block">
  216 + <label translate>extension.json-name-expression</label>
  217 + <input required name="mqttJsonNameExp_{{brokerIndex}}{{mapIndex}}" ng-model="map.converter.deviceNameJsonExpression">
  218 + <div ng-messages="theForm['mqttJsonNameExp_' + brokerIndex + mapIndex].$error">
  219 + <div translate ng-message="required">extension.json-name-expression-required</div>
  220 + </div>
  221 + </md-input-container>
  222 + <md-input-container ng-if="map.converter.nameExp == 'deviceNameTopicExpression'" flex="60" class="md-block">
  223 + <label translate>extension.topic-name-expression</label>
  224 + <input required name="mqttTopicNameExp_{{brokerIndex}}{{mapIndex}}" ng-model="map.converter.deviceNameTopicExpression">
  225 + <div ng-messages="theForm['mqttTopicNameExp_' + brokerIndex + mapIndex].$error">
  226 + <div translate ng-message="required">extension.topic-name-expression-required</div>
  227 + </div>
  228 + </md-input-container>
  229 + </section>
  230 + <section flex layout="row">
  231 + <md-input-container flex="40" class="md-block">
  232 + <label translate>extension.type-expression</label>
  233 + <md-select required name="mqttDeviceTypeExpression_{{brokerIndex}}{{mapIndex}}" ng-model="map.converter.typeExp" ng-change="changeTypeExpression(map.converter)">
  234 + <md-option ng-repeat="(key, value) in typeExpressions" ng-value='key'>
  235 + {{value | translate}}
  236 + </md-option>
  237 + </md-select>
  238 + </md-input-container>
  239 + <md-input-container ng-if="map.converter.typeExp == 'deviceTypeJsonExpression'" flex="60" class="md-block">
  240 + <label translate>extension.json-type-expression</label>
  241 + <input required name="mqttJsonTypeExp_{{brokerIndex}}{{mapIndex}}" ng-model="map.converter.deviceTypeJsonExpression">
  242 + <div ng-messages="theForm['mqttJsonTypeExp_' + brokerIndex + mapIndex].$error">
  243 + <div translate ng-message="required">extension.json-type-expression-required</div>
  244 + </div>
  245 + </md-input-container>
  246 + <md-input-container ng-if="map.converter.typeExp == 'deviceTypeTopicExpression'" flex="60" class="md-block">
  247 + <label translate>extension.topic-type-expression</label>
  248 + <input required name="mqttTopicTypeExp_{{brokerIndex}}{{mapIndex}}" ng-model="map.converter.deviceTypeTopicExpression">
  249 + <div ng-messages="theForm['mqttTopicTypeExp_' + brokerIndex + mapIndex].$error">
  250 + <div translate ng-message="required">extension.topic-type-expression-required</div>
  251 + </div>
  252 + </md-input-container>
  253 + </section>
  254 + <section flex layout="row">
  255 + <md-input-container flex="40" class="md-block">
  256 + <label translate>extension.timeout</label>
  257 + <input type="number" name="mqttTimeout_{{brokerIndex}}{{mapIndex}}" ng-model="map.converter.timeout" parse-to-null>
  258 + </md-input-container>
  259 + <md-input-container flex="60" class="md-block">
  260 + <label translate>extension.filter-expression</label>
  261 + <input required name="mqttFilterExpression{{brokerIndex}}{{mapIndex}}" ng-model="map.converter.filterExpression">
  262 + <div ng-messages="theForm['mqttFilterExpression' + brokerIndex + mapIndex].$error">
  263 + <div translate ng-message="required">extension.filter-expression-required</div>
  264 + </div>
  265 + </md-input-container>
  266 + </section>
  267 + </div>
  268 +
  269 + <div ng-if='map.converterType == "custom"'>
  270 + <div class="md-caption" style="padding-left: 3px; padding-bottom: 10px; color: rgba(0,0,0,0.57);" translate>extension.transformer-json</div>
  271 + <div flex class="tb-extension-custom-transformer-panel">
  272 + <div flex class="tb-extension-custom-transformer"
  273 + ui-ace="extensionCustomConverterOptions"
  274 + ng-model="map.converter"
  275 + name="mqttCustomConverter_{{brokerIndex}}{{mapIndex}}"
  276 + ng-change='validateCustomConverter(map.converter, "mqttCustomConverter_" + brokerIndex + mapIndex)'
  277 + required>
  278 + </div>
  279 + </div>
  280 + <div class="tb-error-messages" ng-messages="theForm['mqttCustomConverter_' + brokerIndex + mapIndex].$error" role="alert">
  281 + <div ng-message="required" class="tb-error-message" translate>extension.converter-json-required</div>
  282 + <div ng-message="converterJSON" class="tb-error-message" translate>extension.converter-json-parse</div>
  283 + </div>
  284 + </div>
  285 +
  286 + <v-accordion ng-if='map.converterType =="json"' id="mqtt-attributes-accordion" class="vAccordion--default">
  287 + <v-pane id="mqtt-attributes-pane">
  288 + <v-pane-header>
  289 + {{ 'extension.attributes' | translate }}
  290 + </v-pane-header>
  291 + <v-pane-content>
  292 + <div ng-if="map.converter.attributes.length > 0">
  293 + <ol class="list-group">
  294 + <li class="list-group-item" ng-repeat="(attributeIndex, attribute) in map.converter.attributes">
  295 + <md-button aria-label="{{ 'action.remove' | translate }}" class="md-icon-button" ng-click="removeAttribute(attribute, map.converter.attributes)">
  296 + <ng-md-icon icon="close" aria-label="{{ 'action.remove' | translate }}"></ng-md-icon>
  297 + <md-tooltip md-direction="top">
  298 + {{ 'action.remove' | translate }}
  299 + </md-tooltip>
  300 + </md-button>
  301 + <md-card>
  302 + <md-card-content>
  303 + <section flex layout="row">
  304 + <md-input-container flex="60" class="md-block">
  305 + <label translate>extension.key</label>
  306 + <input required name="mqttAttributeKey_{{brokerIndex}}{{mapIndex}}{{attributeIndex}}" ng-model="attribute.key">
  307 + <div ng-messages="theForm['mqttAttributeKey_' + brokerIndex + mapIndex + attributeIndex].$error">
  308 + <div translate ng-message="required">extension.required-key</div>
  309 + </div>
  310 + </md-input-container>
  311 + <md-input-container flex="40" class="md-block">
  312 + <label translate>extension.type</label>
  313 + <md-select required name="mqttAttributeType_{{brokerIndex}}{{mapIndex}}{{attributeIndex}}" ng-model="attribute.type">
  314 + <md-option ng-repeat="(attrType, attrTypeValue) in types.extensionValueType" ng-value="attrType">
  315 + {{attrTypeValue | translate}}
  316 + </md-option>
  317 + </md-select>
  318 + <div ng-messages="theForm['mqttAttributeType_' + brokerIndex + mapIndex + attributeIndex].$error">
  319 + <div translate ng-message="required">extension.required-type</div>
  320 + </div>
  321 + </md-input-container>
  322 + </section>
  323 + <md-input-container class="md-block">
  324 + <label translate>extension.value</label>
  325 + <input required name="mqttAttributeValue_{{brokerIndex}}{{mapIndex}}{{attributeIndex}}" ng-model="attribute.value">
  326 + <div ng-messages="theForm['mqttAttributeValue_' + brokerIndex + mapIndex + attributeIndex].$error">
  327 + <div translate ng-message="required">extension.required-value</div>
  328 + </div>
  329 + </md-input-container>
  330 + </md-card-content>
  331 + </md-card>
  332 + </li>
  333 + </ol>
  334 + </div>
  335 + <div flex layout="row" layout-align="start center">
  336 + <md-button class="md-primary md-raised"
  337 + ng-click="addAttribute(map.converter.attributes)" aria-label="{{ 'action.add' | translate }}">
  338 + <md-tooltip md-direction="top">
  339 + {{ 'extension.add-attribute' | translate }}
  340 + </md-tooltip>
  341 + <md-icon class="material-icons">add</md-icon>
  342 + <span translate>action.add</span>
  343 + </md-button>
  344 + </div>
  345 + </v-pane-content>
  346 + </v-pane>
  347 + </v-accordion>
  348 +
  349 + <v-accordion ng-if='map.converterType =="json"' id="mqtt-timeseries-accordion" class="vAccordion--default">
  350 + <v-pane id="mqtt-timeseries-pane">
  351 + <v-pane-header>
  352 + {{ 'extension.timeseries' | translate }}
  353 + </v-pane-header>
  354 + <v-pane-content>
  355 + <div ng-if="map.converter.timeseries.length > 0">
  356 + <ol class="list-group">
  357 + <li class="list-group-item" ng-repeat="(timeseriesIndex, timeseries) in map.converter.timeseries">
  358 + <md-button aria-label="{{ 'action.remove' | translate }}" class="md-icon-button" ng-click="removeAttribute(timeseries, map.converter.timeseries)">
  359 + <ng-md-icon icon="close" aria-label="{{ 'action.remove' | translate }}"></ng-md-icon>
  360 + <md-tooltip md-direction="top">
  361 + {{ 'action.remove' | translate }}
  362 + </md-tooltip>
  363 + </md-button>
  364 + <md-card>
  365 + <md-card-content>
  366 + <section flex layout="row">
  367 + <md-input-container flex="60" class="md-block">
  368 + <label translate>extension.key</label>
  369 + <input required name="mqttTimeseriesKey_{{brokerIndex}}{{mapIndex}}{{timeseriesIndex}}" ng-model="timeseries.key">
  370 + <div ng-messages="theForm['mqtTtimeseriesKey_' + brokerIndex + mapIndex + timeseriesIndex].$error">
  371 + <div translate ng-message="required">extension.required-key</div>
  372 + </div>
  373 + </md-input-container>
  374 + <md-input-container flex="40" class="md-block">
  375 + <label translate>extension.type</label>
  376 + <md-select required name="mqttTimeseriesType_{{brokerIndex}}{{mapIndex}}{{timeseriesIndex}}" ng-model="timeseries.type">
  377 + <md-option ng-repeat="(attrType, attrTypeValue) in types.extensionValueType" ng-value="attrType">
  378 + {{attrTypeValue | translate}}
  379 + </md-option>
  380 + </md-select>
  381 + <div ng-messages="theForm['mqttTimeseriesType_' + brokerIndex + mapIndex + timeseriesIndex].$error">
  382 + <div translate ng-message="required">extension.required-type</div>
  383 + </div>
  384 + </md-input-container>
  385 + </section>
  386 + <md-input-container class="md-block">
  387 + <label translate>extension.value</label>
  388 + <input required name="mqttTimeseriesValue_{{brokerIndex}}{{mapIndex}}{{timeseriesIndex}}" ng-model="timeseries.value">
  389 + <div ng-messages="theForm['mqttTimeseriesValue_' + brokerIndex + mapIndex + timeseriesIndex].$error">
  390 + <div translate ng-message="required">extension.required-value</div>
  391 + </div>
  392 + </md-input-container>
  393 + </md-card-content>
  394 + </md-card>
  395 + </li>
  396 + </ol>
  397 + </div>
  398 + <div flex layout="row" layout-align="start center">
  399 + <md-button class="md-primary md-raised"
  400 + ng-click="addAttribute(map.converter.timeseries)" aria-label="{{ 'action.add' | translate }}">
  401 + <md-tooltip md-direction="top">
  402 + {{ 'extension.add-timeseries' | translate }}
  403 + </md-tooltip>
  404 + <md-icon class="material-icons">add</md-icon>
  405 + <span translate>action.add</span>
  406 + </md-button>
  407 + </div>
  408 + </v-pane-content>
  409 + </v-pane>
  410 + </v-accordion>
  411 +
  412 + </md-card-content>
  413 + </md-card>
  414 + </li>
  415 + </ol>
  416 + </div>
  417 + <div flex layout="row" layout-align="start center">
  418 + <md-button class="md-primary md-raised"
  419 + ng-click="addMap(broker.mapping)" aria-label="{{ 'action.add' | translate }}">
  420 + <md-tooltip md-direction="top">
  421 + {{ 'extension.add-map' | translate }}
  422 + </md-tooltip>
  423 + <md-icon class="material-icons">add</md-icon>
  424 + <span translate>action.add</span>
  425 + </md-button>
  426 + </div>
  427 + </v-pane-content>
  428 + </v-pane>
  429 + </v-accordion>
  430 + </md-card-content>
  431 + </md-card>
  432 + </li>
  433 + </ol>
  434 + </div>
  435 +
  436 + <div flex layout="row" layout-align="start center">
  437 + <md-button class="md-primary md-raised"
  438 + ng-click="addBroker()" aria-label="{{ 'action.add' | translate }}">
  439 + <md-tooltip md-direction="top">
  440 + {{ 'extension.add-broker' | translate }}
  441 + </md-tooltip>
  442 + <md-icon class="material-icons">add</md-icon>
  443 + <span translate>action.add</span>
  444 + </md-button>
  445 + </div>
  446 + </v-pane-content>
  447 + </v-pane>
  448 + </v-accordion>
  449 +<pre>
  450 +{{config | json}}
  451 +</pre>
  452 + </md-card-content>
  453 +</md-card>
1 -/* 1 +/**
2 * Copyright © 2016-2017 The Thingsboard Authors 2 * Copyright © 2016-2017 The Thingsboard Authors
3 * 3 *
4 * Licensed under the Apache License, Version 2.0 (the "License"); 4 * Licensed under the Apache License, Version 2.0 (the "License");
@@ -13,7 +13,6 @@ @@ -13,7 +13,6 @@
13 * See the License for the specific language governing permissions and 13 * See the License for the specific language governing permissions and
14 * limitations under the License. 14 * limitations under the License.
15 */ 15 */
16 -  
17 .extension-form { 16 .extension-form {
18 li > .md-button { 17 li > .md-button {
19 color: rgba(0, 0, 0, 0.7); 18 color: rgba(0, 0, 0, 0.7);
@@ -23,6 +22,17 @@ @@ -23,6 +22,17 @@
23 margin-top: 0; 22 margin-top: 0;
24 padding-left: 3px; 23 padding-left: 3px;
25 } 24 }
  25 + .t-right {
  26 + text-align: right;
  27 + }
  28 + .tb-container {
  29 + width:100%;
  30 + }
  31 + .dropdown-messages {
  32 + .tb-error-message {
  33 + padding: 5px 0 0 0;
  34 + }
  35 + }
26 } 36 }
27 37
28 .tb-extension-custom-transformer-panel { 38 .tb-extension-custom-transformer-panel {
@@ -16,10 +16,12 @@ @@ -16,10 +16,12 @@
16 16
17 import ExtensionTableDirective from './extension-table.directive'; 17 import ExtensionTableDirective from './extension-table.directive';
18 import ExtensionFormHttpDirective from './extensions-forms/extension-form-http.directive'; 18 import ExtensionFormHttpDirective from './extensions-forms/extension-form-http.directive';
  19 +import ExtensionFormMqttDirective from './extensions-forms/extension-form-mqtt.directive'
19 import {ParseToNull} from './extension-dialog.controller'; 20 import {ParseToNull} from './extension-dialog.controller';
20 21
21 export default angular.module('thingsboard.extension', []) 22 export default angular.module('thingsboard.extension', [])
22 .directive('tbExtensionTable', ExtensionTableDirective) 23 .directive('tbExtensionTable', ExtensionTableDirective)
23 .directive('tbExtensionFormHttp', ExtensionFormHttpDirective) 24 .directive('tbExtensionFormHttp', ExtensionFormHttpDirective)
  25 + .directive('tbExtensionFormMqtt', ExtensionFormMqttDirective)
24 .directive('parseToNull', ParseToNull) 26 .directive('parseToNull', ParseToNull)
25 .name; 27 .name;
@@ -738,7 +738,7 @@ export default angular.module('thingsboard.locale', []) @@ -738,7 +738,7 @@ export default angular.module('thingsboard.locale', [])
738 "id": "Id", 738 "id": "Id",
739 "extension-id": "Extension id", 739 "extension-id": "Extension id",
740 "extension-type": "Extension type", 740 "extension-type": "Extension type",
741 - "transformer-json": "JSON*", 741 + "transformer-json": "JSON *",
742 "id-required": "Extension id is required.", 742 "id-required": "Extension id is required.",
743 "unique-id-required": "Current extension id already exists.", 743 "unique-id-required": "Current extension id already exists.",
744 "type-required": "Extension type is required.", 744 "type-required": "Extension type is required.",
@@ -775,6 +775,54 @@ export default angular.module('thingsboard.locale', []) @@ -775,6 +775,54 @@ export default angular.module('thingsboard.locale', [])
775 "add-attribute": "Add attribute", 775 "add-attribute": "Add attribute",
776 "timeseries": "Timeseries", 776 "timeseries": "Timeseries",
777 "add-timeseries": "Add timeseries", 777 "add-timeseries": "Add timeseries",
  778 +
  779 + "brokers": "Brokers",
  780 + "add-broker": "Add broker",
  781 + "add-broker-prompt": "Please add broker",
  782 + "host": "Host",
  783 + "host-required": "Host is required.",
  784 + "port": "Port",
  785 + "port-required": "Port is required.",
  786 + "port-range": "Port should be in a range from 1 to 65535.",
  787 + "ssl": "Ssl",
  788 + "credentials": "Credentials",
  789 + "username": "Username",
  790 + "username-required": "Username is required.",
  791 + "password": "Password",
  792 + "password-required": "Password is required.",
  793 + "retry-interval": "Retry interval",
  794 + "retry-interval-required": "Retry interval is required.",
  795 + "anonymous": "Anonymous",
  796 + "basic": "Basic",
  797 + "pem": "PEM",
  798 + "ca-cert": "CA certificate file *",
  799 + "private-key": "Private key file *",
  800 + "cert": "Certificate file *",
  801 + "no-file": "No file selected.",
  802 + "drop-file": "Drop a file or click to select a file to upload.",
  803 + "mapping": "Mapping",
  804 + "add-map": "Add map",
  805 + "topic-filter": "Topic filter",
  806 + "topic-filter-required": "Topic filter is required.",
  807 + "converter-type": "Converter type",
  808 + "converter-type-required": "Converter type is required.",
  809 + "converter-json": "Json",
  810 + "name-expression": "Name expression",
  811 + "type-expression": "Type expression",
  812 + "json-name-expression": "Json name expression",
  813 + "json-name-expression-required": "Json name expression is required.",
  814 + "topic-name-expression": "Topic name expression",
  815 + "topic-name-expression-required": "Topic name expression is required.",
  816 + "json-type-expression": "Json type expression",
  817 + "json-type-expression-required": "Json type expression is required.",
  818 + "topic-type-expression": "Topic type expression",
  819 + "topic-type-expression-required": "Topic type expression is required.",
  820 + "topic": "Topic",
  821 + "timeout": "Timeout",
  822 + "converter-json-required": "Converter json is required.",
  823 + "converter-json-parse": "Unable to parse converter json.",
  824 + "filter-expression": "Filter expression",
  825 + "filter-expression-required": "Filter expression is required."
778 }, 826 },
779 "fullscreen": { 827 "fullscreen": {
780 "expand": "Expand to fullscreen", 828 "expand": "Expand to fullscreen",
@@ -898,7 +946,6 @@ export default angular.module('thingsboard.locale', []) @@ -898,7 +946,6 @@ export default angular.module('thingsboard.locale', [])
898 "invalid-plugin-file-error": "Unable to import plugin: Invalid plugin data structure.", 946 "invalid-plugin-file-error": "Unable to import plugin: Invalid plugin data structure.",
899 "copyId": "Copy plugin Id", 947 "copyId": "Copy plugin Id",
900 "idCopiedMessage": "Plugin Id has been copied to clipboard" 948 "idCopiedMessage": "Plugin Id has been copied to clipboard"
901 -  
902 }, 949 },
903 "position": { 950 "position": {
904 "top": "Top", 951 "top": "Top",