thingsboard-memoizeit

Rule chains UI

3/21/2018 9:56:16 AM

Details

diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainMetaData.java b/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainMetaData.java
index af141d6..8deece2 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainMetaData.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainMetaData.java
@@ -15,6 +15,7 @@
  */
 package org.thingsboard.server.common.data.rule;
 
+import com.fasterxml.jackson.databind.JsonNode;
 import lombok.Data;
 import org.thingsboard.server.common.data.id.RuleChainId;
 
@@ -47,11 +48,12 @@ public class RuleChainMetaData {
         }
         connections.add(connectionInfo);
     }
-    public void addRuleChainConnectionInfo(int fromIndex, RuleChainId targetRuleChainId, String type) {
+    public void addRuleChainConnectionInfo(int fromIndex, RuleChainId targetRuleChainId, String type, JsonNode additionalInfo) {
         RuleChainConnectionInfo connectionInfo = new RuleChainConnectionInfo();
         connectionInfo.setFromIndex(fromIndex);
         connectionInfo.setTargetRuleChainId(targetRuleChainId);
         connectionInfo.setType(type);
+        connectionInfo.setAdditionalInfo(additionalInfo);
         if (ruleChainConnections == null) {
             ruleChainConnections = new ArrayList<>();
         }
@@ -59,16 +61,17 @@ public class RuleChainMetaData {
     }
 
     @Data
-    public class NodeConnectionInfo {
+    public static class NodeConnectionInfo {
         private int fromIndex;
         private int toIndex;
         private String type;
     }
 
     @Data
-    public class RuleChainConnectionInfo {
+    public static class RuleChainConnectionInfo {
         private int fromIndex;
         private RuleChainId targetRuleChainId;
+        private JsonNode additionalInfo;
         private String type;
     }
 
diff --git a/dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java b/dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java
index 04207ec..c8c8df7 100644
--- a/dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java
+++ b/dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java
@@ -166,7 +166,7 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC
                 EntityId to = nodeToRuleChainConnection.getTargetRuleChainId();
                 String type = nodeToRuleChainConnection.getType();
                 try {
-                    createRelation(new EntityRelation(from, to, type, RelationTypeGroup.RULE_NODE));
+                    createRelation(new EntityRelation(from, to, type, RelationTypeGroup.RULE_NODE, nodeToRuleChainConnection.getAdditionalInfo()));
                 } catch (ExecutionException | InterruptedException e) {
                     log.warn("[{}] Failed to create rule node to rule chain relation. from: [{}], to: [{}]", from, to);
                     throw new RuntimeException(e);
@@ -206,7 +206,7 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC
                     ruleChainMetaData.addConnectionInfo(fromIndex, toIndex, type);
                 } else if (nodeRelation.getTo().getEntityType() == EntityType.RULE_CHAIN) {
                     RuleChainId targetRuleChainId = new RuleChainId(nodeRelation.getTo().getId());
-                    ruleChainMetaData.addRuleChainConnectionInfo(fromIndex, targetRuleChainId, type);
+                    ruleChainMetaData.addRuleChainConnectionInfo(fromIndex, targetRuleChainId, type, nodeRelation.getAdditionalInfo());
                 }
             }
         }

ui/package.json 1(+1 -0)

diff --git a/ui/package.json b/ui/package.json
index ad95ef4..b602720 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -69,6 +69,7 @@
     "moment": "^2.15.0",
     "ngclipboard": "^1.1.1",
     "ngreact": "^0.3.0",
+    "ngFlowchart": "git://github.com/ikulikov/ngFlowchart.git#master",
     "objectpath": "^1.2.1",
     "oclazyload": "^1.0.9",
     "raphael": "^2.2.7",
diff --git a/ui/src/app/api/entity.service.js b/ui/src/app/api/entity.service.js
index e4c51a2..ba1265f 100644
--- a/ui/src/app/api/entity.service.js
+++ b/ui/src/app/api/entity.service.js
@@ -22,7 +22,7 @@ export default angular.module('thingsboard.api.entity', [thingsboardTypes])
 /*@ngInject*/
 function EntityService($http, $q, $filter, $translate, $log, userService, deviceService,
                        assetService, tenantService, customerService,
-                       ruleService, pluginService, dashboardService, entityRelationService, attributeService, types, utils) {
+                       ruleService, pluginService, ruleChainService, dashboardService, entityRelationService, attributeService, types, utils) {
     var service = {
         getEntity: getEntity,
         getEntities: getEntities,
@@ -73,6 +73,9 @@ function EntityService($http, $q, $filter, $translate, $log, userService, device
             case types.entityType.user:
                 promise = userService.getUser(entityId, true, config);
                 break;
+            case types.entityType.rulechain:
+                promise = ruleChainService.getRuleChain(entityId, config);
+                break;
             case types.entityType.alarm:
                 $log.error('Get Alarm Entity is not implemented!');
                 break;
@@ -271,6 +274,9 @@ function EntityService($http, $q, $filter, $translate, $log, userService, device
             case types.entityType.plugin:
                 promise = pluginService.getAllPlugins(pageLink, config);
                 break;
+            case types.entityType.rulechain:
+                promise = ruleChainService.getRuleChains(pageLink, config);
+                break;
             case types.entityType.dashboard:
                 if (user.authority === 'CUSTOMER_USER') {
                     promise = dashboardService.getCustomerDashboards(customerId, pageLink, config);
diff --git a/ui/src/app/api/rule-chain.service.js b/ui/src/app/api/rule-chain.service.js
index 16afa74..be8e99d 100644
--- a/ui/src/app/api/rule-chain.service.js
+++ b/ui/src/app/api/rule-chain.service.js
@@ -17,7 +17,9 @@ export default angular.module('thingsboard.api.ruleChain', [])
     .factory('ruleChainService', RuleChainService).name;
 
 /*@ngInject*/
-function RuleChainService($http, $q) {
+function RuleChainService($http, $q, $filter, types) {
+
+    var ruleNodeTypes = null;
 
     var service = {
         getSystemRuleChains: getSystemRuleChains,
@@ -27,7 +29,11 @@ function RuleChainService($http, $q) {
         saveRuleChain: saveRuleChain,
         deleteRuleChain: deleteRuleChain,
         getRuleChainMetaData: getRuleChainMetaData,
-        saveRuleChainMetaData: saveRuleChainMetaData
+        saveRuleChainMetaData: saveRuleChainMetaData,
+        getRuleNodeTypes: getRuleNodeTypes,
+        getRuleNodeComponentType: getRuleNodeComponentType,
+        getRuleNodeSupportedLinks: getRuleNodeSupportedLinks,
+        resolveTargetRuleChains: resolveTargetRuleChains
     };
 
     return service;
@@ -147,4 +153,131 @@ function RuleChainService($http, $q) {
         return deferred.promise;
     }
 
+    function getRuleNodeSupportedLinks(nodeType) { //eslint-disable-line
+        //TODO:
+        var deferred = $q.defer();
+        var linkLabels = [
+            { name: 'Success', custom: false },
+            { name: 'Fail', custom: false },
+            { name: 'Custom', custom: true },
+        ];
+        deferred.resolve(linkLabels);
+        return deferred.promise;
+    }
+
+    function getRuleNodeTypes() {
+        var deferred = $q.defer();
+        if (ruleNodeTypes) {
+            deferred.resolve(ruleNodeTypes);
+        } else {
+            loadRuleNodeTypes().then(
+                (nodeTypes) => {
+                    ruleNodeTypes = nodeTypes;
+                    ruleNodeTypes.push(
+                        {
+                            nodeType: types.ruleNodeType.RULE_CHAIN.value,
+                            type: 'Rule chain'
+                        }
+                    );
+                    deferred.resolve(ruleNodeTypes);
+                },
+                () => {
+                    deferred.reject();
+                }
+            );
+        }
+        return deferred.promise;
+    }
+
+    function getRuleNodeComponentType(type) {
+        var res = $filter('filter')(ruleNodeTypes, {type: type}, true);
+        if (res && res.length) {
+            return res[0].nodeType;
+        }
+        return null;
+    }
+
+    function resolveTargetRuleChains(ruleChainConnections) {
+        var deferred = $q.defer();
+        if (ruleChainConnections && ruleChainConnections.length) {
+            var tasks = [];
+            for (var i = 0; i < ruleChainConnections.length; i++) {
+                tasks.push(getRuleChain(ruleChainConnections[i].targetRuleChainId.id));
+            }
+            $q.all(tasks).then(
+                (ruleChains) => {
+                    var ruleChainsMap = {};
+                    for (var i = 0; i < ruleChains.length; i++) {
+                        ruleChainsMap[ruleChains[i].id.id] = ruleChains[i];
+                    }
+                    deferred.resolve(ruleChainsMap);
+                },
+                () => {
+                    deferred.reject();
+                }
+            );
+        } else {
+            deferred.resolve({});
+        }
+        return deferred.promise;
+    }
+
+    function loadRuleNodeTypes() {
+        var deferred = $q.defer();
+        deferred.resolve(
+            [
+                {
+                    nodeType: types.ruleNodeType.FILTER.value,
+                    type: 'Filter'
+                },
+                {
+                    nodeType: types.ruleNodeType.FILTER.value,
+                    type: 'Switch'
+                },
+                {
+                    nodeType: types.ruleNodeType.ENRICHMENT.value,
+                    type: 'Self'
+                },
+                {
+                    nodeType: types.ruleNodeType.ENRICHMENT.value,
+                    type: 'Tenant/Customer'
+                },
+                {
+                    nodeType: types.ruleNodeType.ENRICHMENT.value,
+                    type: 'Related Entity'
+                },
+                {
+                    nodeType: types.ruleNodeType.ENRICHMENT.value,
+                    type: 'Last Telemetry'
+                },
+                {
+                    nodeType: types.ruleNodeType.TRANSFORMATION.value,
+                    type: 'Modify'
+                },
+                {
+                    nodeType: types.ruleNodeType.TRANSFORMATION.value,
+                    type: 'New/Update'
+                },
+                {
+                    nodeType: types.ruleNodeType.ACTION.value,
+                    type: 'Telemetry'
+                },
+                {
+                    nodeType: types.ruleNodeType.ACTION.value,
+                    type: 'RPC call'
+                },
+                {
+                    nodeType: types.ruleNodeType.ACTION.value,
+                    type: 'Send email'
+                },
+                {
+                    nodeType: types.ruleNodeType.ACTION.value,
+                    type: 'Alarm'
+                }
+            ]
+        );
+        return deferred.promise;
+    }
+
+
 }
diff --git a/ui/src/app/app.js b/ui/src/app/app.js
index 138b230..3131013 100644
--- a/ui/src/app/app.js
+++ b/ui/src/app/app.js
@@ -49,6 +49,7 @@ import 'material-ui';
 import 'react-schema-form';
 import react from 'ngreact';
 import '@flowjs/ng-flow/dist/ng-flow-standalone.min';
+import 'ngFlowchart/dist/ngFlowchart';
 
 import thingsboardLocales from './locale/locale.constant';
 import thingsboardLogin from './login';
@@ -86,6 +87,7 @@ import 'mdPickers/dist/mdPickers.min.css';
 import 'angular-hotkeys/build/hotkeys.min.css';
 import 'angular-carousel/dist/angular-carousel.min.css';
 import 'angular-material-expansion-panel/dist/md-expansion-panel.min.css';
+import 'ngFlowchart/dist/flowchart.css';
 import '../scss/main.scss';
 
 import AppConfig from './app.config';
@@ -113,6 +115,7 @@ angular.module('thingsboard', [
     'ngclipboard',
     react.name,
     'flow',
+    'flowchart',
     thingsboardLocales,
     thingsboardLogin,
     thingsboardDialogs,
diff --git a/ui/src/app/common/types.constant.js b/ui/src/app/common/types.constant.js
index 77c21e1..b134bf0 100644
--- a/ui/src/app/common/types.constant.js
+++ b/ui/src/app/common/types.constant.js
@@ -457,6 +457,44 @@ export default angular.module('thingsboard.types', [])
                     clientSide: false
                 }
             },
+            ruleNodeType: {
+                FILTER: {
+                    value: "FILTER",
+                    name: "rulenode.type-filter",
+                    nodeClass: "tb-filter-type",
+                    icon: "filter_list"
+                },
+                ENRICHMENT: {
+                    value: "ENRICHMENT",
+                    name: "rulenode.type-enrichment",
+                    nodeClass: "tb-enrichment-type",
+                    icon: "playlist_add"
+                },
+                TRANSFORMATION: {
+                    value: "TRANSFORMATION",
+                    name: "rulenode.type-transformation",
+                    nodeClass: "tb-transformation-type",
+                    icon: "transform"
+                },
+                ACTION: {
+                    value: "ACTION",
+                    name: "rulenode.type-action",
+                    nodeClass: "tb-action-type",
+                    icon: "flash_on"
+                },
+                RULE_CHAIN: {
+                    value: "RULE_CHAIN",
+                    name: "rulenode.type-rule-chain",
+                    nodeClass: "tb-rule-chain-type",
+                    icon: "settings_ethernet"
+                },
+                INPUT: {
+                    value: "INPUT",
+                    nodeClass: "tb-input-type",
+                    icon: "input",
+                    special: true
+                }
+            },
             valueType: {
                 string: {
                     value: "string",
diff --git a/ui/src/app/entity/entity-autocomplete.directive.js b/ui/src/app/entity/entity-autocomplete.directive.js
index c2053c0..2dfc3be 100644
--- a/ui/src/app/entity/entity-autocomplete.directive.js
+++ b/ui/src/app/entity/entity-autocomplete.directive.js
@@ -143,6 +143,12 @@ export default function EntityAutocomplete($compile, $templateCache, $q, $filter
                     scope.noEntitiesMatchingText = 'plugin.no-plugins-matching';
                     scope.entityRequiredText = 'plugin.plugin-required';
                     break;
+                case types.entityType.rulechain:
+                    scope.selectEntityText = 'rulechain.select-rulechain';
+                    scope.entityText = 'rulechain.rulechain';
+                    scope.noEntitiesMatchingText = 'rulechain.no-rulechains-matching';
+                    scope.entityRequiredText = 'rulechain.rulechain-required';
+                    break;
                 case types.entityType.tenant:
                     scope.selectEntityText = 'tenant.select-tenant';
                     scope.entityText = 'tenant.tenant';
diff --git a/ui/src/app/locale/locale.constant.js b/ui/src/app/locale/locale.constant.js
index ea576ec..4c392ca 100644
--- a/ui/src/app/locale/locale.constant.js
+++ b/ui/src/app/locale/locale.constant.js
@@ -1169,6 +1169,26 @@ export default angular.module('thingsboard.locale', [])
                     "rulechain-required": "Rule chain is required",
                     "management": "Rules management"
                 },
+                "rulenode": {
+                    "add": "Add rule node",
+                    "name": "Name",
+                    "name-required": "Name is required.",
+                    "type": "Type",
+                    "description": "Description",
+                    "delete": "Delete rule node",
+                    "rulenode-details": "Rule node details",
+                    "link-details": "Rule node link details",
+                    "add-link": "Add link",
+                    "link-label": "Link label",
+                    "link-label-required": "Link label is required.",
+                    "custom-link-label": "Custom link label",
+                    "custom-link-label-required": "Custom link label is required.",
+                    "type-filter": "Filter",
+                    "type-enrichment": "Enrichment",
+                    "type-transformation": "Transformation",
+                    "type-action": "Action",
+                    "type-rule-chain": "Rule Chain"
+                },
                 "rule-plugin": {
                     "management": "Rules and plugins management"
                 },
diff --git a/ui/src/app/rulechain/add-link.tpl.html b/ui/src/app/rulechain/add-link.tpl.html
new file mode 100644
index 0000000..42c0777
--- /dev/null
+++ b/ui/src/app/rulechain/add-link.tpl.html
@@ -0,0 +1,48 @@
+<!--
+
+    Copyright © 2016-2018 The 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.
+
+-->
+<md-dialog aria-label="{{ 'rulenode.add-link' | translate }}" tb-help="'rulechains'" help-container-id="help-container">
+    <form name="theForm" ng-submit="vm.add()">
+        <md-toolbar>
+            <div class="md-toolbar-tools">
+                <h2 translate>rulenode.add-link</h2>
+                <span flex></span>
+                <div id="help-container"></div>
+                <md-button class="md-icon-button" ng-click="vm.cancel()">
+                    <ng-md-icon icon="close" aria-label="{{ 'dialog.close' | translate }}"></ng-md-icon>
+                </md-button>
+            </div>
+        </md-toolbar>
+        <md-progress-linear class="md-warn" md-mode="indeterminate" ng-disabled="!$root.loading" ng-show="$root.loading"></md-progress-linear>
+        <span style="min-height: 5px;" flex="" ng-show="!$root.loading"></span>
+        <md-dialog-content>
+            <div class="md-dialog-content">
+                <tb-rule-node-link link="vm.link" labels="vm.labels" is-edit="true" the-form="theForm"></tb-rule-node-link>
+            </div>
+        </md-dialog-content>
+        <md-dialog-actions layout="row">
+            <span flex></span>
+            <md-button ng-disabled="$root.loading || theForm.$invalid || !theForm.$dirty" type="submit"
+                       class="md-raised md-primary">
+                {{ 'action.add' | translate }}
+            </md-button>
+            <md-button ng-disabled="$root.loading" ng-click="vm.cancel()" style="margin-right:20px;">{{ 'action.cancel' |
+                translate }}
+            </md-button>
+        </md-dialog-actions>
+    </form>
+</md-dialog>
diff --git a/ui/src/app/rulechain/add-rulenode.tpl.html b/ui/src/app/rulechain/add-rulenode.tpl.html
new file mode 100644
index 0000000..c36b43b
--- /dev/null
+++ b/ui/src/app/rulechain/add-rulenode.tpl.html
@@ -0,0 +1,48 @@
+<!--
+
+    Copyright © 2016-2018 The 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.
+
+-->
+<md-dialog aria-label="{{ 'rulenode.add' | translate }}" tb-help="'rulechains'" help-container-id="help-container" style="min-width: 650px;">
+    <form name="theForm" ng-submit="vm.add()">
+        <md-toolbar>
+            <div class="md-toolbar-tools">
+                <h2 translate>rulenode.add</h2>
+                <span flex></span>
+                <div id="help-container"></div>
+                <md-button class="md-icon-button" ng-click="vm.cancel()">
+                    <ng-md-icon icon="close" aria-label="{{ 'dialog.close' | translate }}"></ng-md-icon>
+                </md-button>
+            </div>
+        </md-toolbar>
+        <md-progress-linear class="md-warn" md-mode="indeterminate" ng-disabled="!$root.loading" ng-show="$root.loading"></md-progress-linear>
+        <span style="min-height: 5px;" flex="" ng-show="!$root.loading"></span>
+        <md-dialog-content>
+            <div class="md-dialog-content">
+                <tb-rule-node rule-node="vm.ruleNode" rule-chain-id="vm.ruleChainId" is-edit="true" the-form="theForm"></tb-rule-node>
+            </div>
+        </md-dialog-content>
+        <md-dialog-actions layout="row">
+            <span flex></span>
+            <md-button ng-disabled="$root.loading || theForm.$invalid || !theForm.$dirty" type="submit"
+                       class="md-raised md-primary">
+                {{ 'action.add' | translate }}
+            </md-button>
+            <md-button ng-disabled="$root.loading" ng-click="vm.cancel()" style="margin-right:20px;">{{ 'action.cancel' |
+                translate }}
+            </md-button>
+        </md-dialog-actions>
+    </form>
+</md-dialog>
diff --git a/ui/src/app/rulechain/index.js b/ui/src/app/rulechain/index.js
index faecc03..7306762 100644
--- a/ui/src/app/rulechain/index.js
+++ b/ui/src/app/rulechain/index.js
@@ -15,11 +15,19 @@
  */
 
 import RuleChainRoutes from './rulechain.routes';
-import RuleChainController from './rulechain.controller';
+import RuleChainsController from './rulechains.controller';
+import {RuleChainController, AddRuleNodeController, AddRuleNodeLinkController} from './rulechain.controller';
 import RuleChainDirective from './rulechain.directive';
+import RuleNodeDirective from './rulenode.directive';
+import LinkDirective from './link.directive';
 
 export default angular.module('thingsboard.ruleChain', [])
     .config(RuleChainRoutes)
+    .controller('RuleChainsController', RuleChainsController)
     .controller('RuleChainController', RuleChainController)
+    .controller('AddRuleNodeController', AddRuleNodeController)
+    .controller('AddRuleNodeLinkController', AddRuleNodeLinkController)
     .directive('tbRuleChain', RuleChainDirective)
+    .directive('tbRuleNode', RuleNodeDirective)
+    .directive('tbRuleNodeLink', LinkDirective)
     .name;
diff --git a/ui/src/app/rulechain/link.directive.js b/ui/src/app/rulechain/link.directive.js
new file mode 100644
index 0000000..b3565a3
--- /dev/null
+++ b/ui/src/app/rulechain/link.directive.js
@@ -0,0 +1,71 @@
+/*
+ * Copyright © 2016-2018 The 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.
+ */
+
+/* eslint-disable import/no-unresolved, import/default */
+
+import linkFieldsetTemplate from './link-fieldset.tpl.html';
+
+/* eslint-enable import/no-unresolved, import/default */
+
+/*@ngInject*/
+export default function LinkDirective($compile, $templateCache, $filter) {
+    var linker = function (scope, element) {
+        var template = $templateCache.get(linkFieldsetTemplate);
+        element.html(template);
+
+        scope.selectedLabel = null;
+
+        scope.$watch('link', function() {
+            scope.selectedLabel = null;
+             if (scope.link && scope.labels) {
+                 if (scope.link.label) {
+                     var result = $filter('filter')(scope.labels, {name: scope.link.label});
+                     if (result && result.length) {
+                         scope.selectedLabel = result[0];
+                     } else {
+                         result = $filter('filter')(scope.labels, {custom: true});
+                         if (result && result.length && result[0].custom) {
+                             scope.selectedLabel = result[0];
+                         }
+                     }
+                 }
+             }
+        });
+
+        scope.selectedLabelChanged = function() {
+            if (scope.link && scope.selectedLabel) {
+                if (!scope.selectedLabel.custom) {
+                    scope.link.label = scope.selectedLabel.name;
+                } else {
+                    scope.link.label = "";
+                }
+            }
+        };
+
+        $compile(element.contents())(scope);
+    }
+    return {
+        restrict: "E",
+        link: linker,
+        scope: {
+            link: '=',
+            labels: '=',
+            isEdit: '=',
+            isReadOnly: '=',
+            theForm: '='
+        }
+    };
+}
diff --git a/ui/src/app/rulechain/link-fieldset.tpl.html b/ui/src/app/rulechain/link-fieldset.tpl.html
new file mode 100644
index 0000000..13ec6c3
--- /dev/null
+++ b/ui/src/app/rulechain/link-fieldset.tpl.html
@@ -0,0 +1,39 @@
+<!--
+
+    Copyright © 2016-2018 The 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.
+
+-->
+<md-content class="md-padding tb-link" layout="column">
+    <fieldset ng-disabled="$root.loading || !isEdit || isReadOnly">
+        <md-input-container class="md-block">
+            <label translate>rulenode.link-label</label>
+            <md-select ng-model="selectedLabel" ng-change="selectedLabelChanged()">
+                <md-option ng-repeat="label in labels" ng-value="label">
+                    {{label.name}}
+                </md-option>
+            </md-select>
+            <div ng-messages="theForm.linkLabel.$error">
+                <div translate ng-message="required">rulenode.link-label-required</div>
+            </div>
+        </md-input-container>
+        <md-input-container ng-if="selectedLabel.custom" class="md-block">
+            <label translate>rulenode.link-label</label>
+            <input required name="customLinkLabel" ng-model="link.label">
+            <div ng-messages="theForm.customLinkLabel.$error">
+                <div translate ng-message="required">rulenode.custom-link-label-required</div>
+            </div>
+        </md-input-container>
+    </fieldset>
+</md-content>
diff --git a/ui/src/app/rulechain/rulechain.controller.js b/ui/src/app/rulechain/rulechain.controller.js
index 475ef4e..c854cfa 100644
--- a/ui/src/app/rulechain/rulechain.controller.js
+++ b/ui/src/app/rulechain/rulechain.controller.js
@@ -13,160 +13,582 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
+import './rulechain.scss';
+
 /* eslint-disable import/no-unresolved, import/default */
 
-import addRuleChainTemplate from './add-rulechain.tpl.html';
-import ruleChainCard from './rulechain-card.tpl.html';
+import addRuleNodeTemplate from './add-rulenode.tpl.html';
+import addRuleNodeLinkTemplate from './add-link.tpl.html';
 
 /* eslint-enable import/no-unresolved, import/default */
 
+
+const deleteKeyCode = 46;
+const ctrlKeyCode = 17;
+const aKeyCode = 65;
+const escKeyCode = 27;
+
 /*@ngInject*/
-export default function RuleChainController(ruleChainService, userService, importExport, $state, $stateParams, $filter, $translate, types) {
-
-    var ruleChainActionsList = [
-        {
-            onAction: function ($event, item) {
-                exportRuleChain($event, item);
-            },
-            name: function() { $translate.instant('action.export') },
-            details: function() { return $translate.instant('rulechain.export') },
-            icon: "file_download"
-        },
-        {
-            onAction: function ($event, item) {
-                vm.grid.deleteItem($event, item);
-            },
-            name: function() { return $translate.instant('action.delete') },
-            details: function() { return $translate.instant('rulechain.delete') },
-            icon: "delete",
-            isEnabled: isRuleChainEditable
-        }
-    ];
-
-    var ruleChainAddItemActionsList = [
-        {
-            onAction: function ($event) {
-                vm.grid.addItem($event);
-            },
-            name: function() { return $translate.instant('action.create') },
-            details: function() { return $translate.instant('rulechain.create-new-rulechain') },
-            icon: "insert_drive_file"
-        },
-        {
-            onAction: function ($event) {
-                importExport.importRuleChain($event).then(
-                    function() {
-                        vm.grid.refreshList();
-                    }
-                );
-            },
-            name: function() { return $translate.instant('action.import') },
-            details: function() { return $translate.instant('rulechain.import') },
-            icon: "file_upload"
-        }
-    ];
+export function RuleChainController($stateParams, $scope, $q, $mdUtil, $mdExpansionPanel, $document, $mdDialog, $filter, types, ruleChainService, Modelfactory, flowchartConstants, ruleChain, ruleChainMetaData) {
 
     var vm = this;
 
+    vm.$mdExpansionPanel = $mdExpansionPanel;
     vm.types = types;
 
-    vm.ruleChainGridConfig = {
+    vm.editingRuleNode = null;
+    vm.isEditingRuleNode = false;
+
+    vm.editingRuleNodeLink = null;
+    vm.isEditingRuleNodeLink = false;
 
-        refreshParamsFunc: null,
+    vm.ruleChain = ruleChain;
+    vm.ruleChainMetaData = ruleChainMetaData;
 
-        deleteItemTitleFunc: deleteRuleChainTitle,
-        deleteItemContentFunc: deleteRuleChainText,
-        deleteItemsTitleFunc: deleteRuleChainsTitle,
-        deleteItemsActionTitleFunc: deleteRuleChainsActionTitle,
-        deleteItemsContentFunc: deleteRuleChainsText,
+    vm.canvasControl = {};
 
-        fetchItemsFunc: fetchRuleChains,
-        saveItemFunc: saveRuleChain,
-        deleteItemFunc: deleteRuleChain,
+    vm.ruleChainModel = {
+        nodes: [],
+        edges: []
+    };
+
+    vm.ruleNodeTypesModel = {};
+    for (var type in types.ruleNodeType) {
+        if (!types.ruleNodeType[type].special) {
+            vm.ruleNodeTypesModel[type] = {
+                model: {
+                    nodes: [],
+                    edges: []
+                },
+                selectedObjects: []
+            };
+        }
+    }
 
-        getItemTitleFunc: getRuleChainTitle,
-        itemCardTemplateUrl: ruleChainCard,
-        parentCtl: vm,
+    vm.selectedObjects = [];
 
-        actionsList: ruleChainActionsList,
-        addItemActions: ruleChainAddItemActionsList,
+    vm.modelservice = Modelfactory(vm.ruleChainModel, vm.selectedObjects);
 
-        onGridInited: gridInited,
+    vm.ctrlDown = false;
 
-        addItemTemplateUrl: addRuleChainTemplate,
+    vm.saveRuleChain = saveRuleChain;
+    vm.revertRuleChain = revertRuleChain;
 
-        addItemText: function() { return $translate.instant('rulechain.add-rulechain-text') },
-        noItemsText: function() { return $translate.instant('rulechain.no-rulechains-text') },
-        itemDetailsText: function() { return $translate.instant('rulechain.rulechain-details') },
-        isSelectionEnabled: isRuleChainEditable,
-        isDetailsReadOnly: function(ruleChain) {
-            return !isRuleChainEditable(ruleChain);
+    vm.keyDown = function (evt) {
+        if (evt.keyCode === ctrlKeyCode) {
+            vm.ctrlDown = true;
+            evt.stopPropagation();
+            evt.preventDefault();
         }
     };
 
-    if (angular.isDefined($stateParams.items) && $stateParams.items !== null) {
-        vm.ruleChainGridConfig.items = $stateParams.items;
-    }
+    vm.keyUp = function (evt) {
 
-    if (angular.isDefined($stateParams.topIndex) && $stateParams.topIndex > 0) {
-        vm.ruleChainGridConfig.topIndex = $stateParams.topIndex;
-    }
+        if (evt.keyCode === deleteKeyCode) {
+            vm.modelservice.deleteSelected();
+        }
 
-    vm.isRuleChainEditable = isRuleChainEditable;
+        if (evt.keyCode == aKeyCode && vm.ctrlDown) {
+            vm.modelservice.selectAll();
+        }
 
-    vm.exportRuleChain = exportRuleChain;
+        if (evt.keyCode == escKeyCode) {
+            vm.modelservice.deselectAll();
+        }
 
-    function deleteRuleChainTitle(ruleChain) {
-        return $translate.instant('rulechain.delete-rulechain-title', {ruleChainName: ruleChain.name});
-    }
+        if (evt.keyCode === ctrlKeyCode) {
+            vm.ctrlDown = false;
+            evt.stopPropagation();
+            evt.preventDefault();
+        }
+    };
 
-    function deleteRuleChainText() {
-        return $translate.instant('rulechain.delete-rulechain-text');
-    }
+    vm.onEditRuleNodeClosed = function() {
+        vm.editingRuleNode = null;
+    };
+
+    vm.onEditRuleNodeLinkClosed = function() {
+        vm.editingRuleNodeLink = null;
+    };
+
+    vm.saveRuleNode = function(theForm) {
+        theForm.$setPristine();
+        vm.isEditingRuleNode = false;
+        vm.ruleChainModel.nodes[vm.editingRuleNodeIndex] = vm.editingRuleNode;
+        vm.editingRuleNode = angular.copy(vm.editingRuleNode);
+    };
+
+    vm.saveRuleNodeLink = function(theForm) {
+        theForm.$setPristine();
+        vm.isEditingRuleNodeLink = false;
+        vm.ruleChainModel.edges[vm.editingRuleNodeLinkIndex] = vm.editingRuleNodeLink;
+        vm.editingRuleNodeLink = angular.copy(vm.editingRuleNodeLink);
+    };
+
+    vm.onRevertRuleNodeEdit = function(theForm) {
+        theForm.$setPristine();
+        var node = vm.ruleChainModel.nodes[vm.editingRuleNodeIndex];
+        vm.editingRuleNode = angular.copy(node);
+    };
+
+    vm.onRevertRuleNodeLinkEdit = function(theForm) {
+        theForm.$setPristine();
+        var edge = vm.ruleChainModel.edges[vm.editingRuleNodeLinkIndex];
+        vm.editingRuleNodeLink = angular.copy(edge);
+    };
+
+    vm.editCallbacks = {
+        edgeDoubleClick: function (event, edge) {
+            var sourceNode = vm.modelservice.nodes.getNodeByConnectorId(edge.source);
+            if (sourceNode.nodeType != types.ruleNodeType.INPUT.value) {
+                ruleChainService.getRuleNodeSupportedLinks(sourceNode.type).then(
+                    (labels) => {
+                        vm.isEditingRuleNode = false;
+                        vm.editingRuleNode = null;
+                        vm.editingRuleNodeLinkLabels = labels;
+                        vm.isEditingRuleNodeLink = true;
+                        vm.editingRuleNodeLinkIndex = vm.ruleChainModel.edges.indexOf(edge);
+                        vm.editingRuleNodeLink = angular.copy(edge);
+                    }
+                );
+            }
+        },
+        nodeCallbacks: {
+            'doubleClick': function (event, node) {
+                if (node.nodeType != types.ruleNodeType.INPUT.value) {
+                    vm.isEditingRuleNodeLink = false;
+                    vm.editingRuleNodeLink = null;
+                    vm.isEditingRuleNode = true;
+                    vm.editingRuleNodeIndex = vm.ruleChainModel.nodes.indexOf(node);
+                    vm.editingRuleNode = angular.copy(node);
+                }
+            }
+        },
+        isValidEdge: function (source, destination) {
+            return source.type === flowchartConstants.rightConnectorType && destination.type === flowchartConstants.leftConnectorType;
+        },
+        createEdge: function (event, edge) {
+            var deferred = $q.defer();
+            var sourceNode = vm.modelservice.nodes.getNodeByConnectorId(edge.source);
+            if (sourceNode.nodeType == types.ruleNodeType.INPUT.value) {
+                var destNode = vm.modelservice.nodes.getNodeByConnectorId(edge.destination);
+                if (destNode.nodeType == types.ruleNodeType.RULE_CHAIN.value) {
+                    deferred.reject();
+                } else {
+                    var res = $filter('filter')(vm.ruleChainModel.edges, {source: vm.inputConnectorId});
+                    if (res && res.length) {
+                        vm.modelservice.edges.delete(res[0]);
+                    }
+                    deferred.resolve(edge);
+                }
+            } else {
+                ruleChainService.getRuleNodeSupportedLinks(sourceNode.type).then(
+                    (labels) => {
+                        addRuleNodeLink(event, edge, labels).then(
+                            (link) => {
+                                deferred.resolve(link);
+                            },
+                            () => {
+                                deferred.reject();
+                            }
+                        );
+                    },
+                    () => {
+                        deferred.reject();
+                    }
+                );
+            }
+            return deferred.promise;
+        },
+        dropNode: function (event, node) {
+            addRuleNode(event, node);
+        }
+    };
 
-    function deleteRuleChainsTitle(selectedCount) {
-        return $translate.instant('rulechain.delete-rulechains-title', {count: selectedCount}, 'messageformat');
+    loadRuleChainLibrary();
+
+    function loadRuleChainLibrary() {
+        ruleChainService.getRuleNodeTypes().then(
+            (ruleNodeTypes) => {
+                for (var i=0;i<ruleNodeTypes.length;i++) {
+                    var ruleNodeType = ruleNodeTypes[i];
+                    var nodeType = ruleNodeType.nodeType;
+                    var model = vm.ruleNodeTypesModel[nodeType].model;
+                    var node = {
+                        id: model.nodes.length,
+                        nodeType: nodeType,
+                        type: ruleNodeType.type,
+                        name: '',
+                        nodeClass: vm.types.ruleNodeType[nodeType].nodeClass,
+                        icon: vm.types.ruleNodeType[nodeType].icon,
+                        x: 30,
+                        y: 10+50*model.nodes.length,
+                        connectors: []
+                    };
+                    if (nodeType == types.ruleNodeType.RULE_CHAIN.value) {
+                        node.connectors.push(
+                            {
+                                type: flowchartConstants.leftConnectorType,
+                                id: model.nodes.length
+                            }
+                        );
+                    } else {
+                        node.connectors.push(
+                            {
+                                type: flowchartConstants.leftConnectorType,
+                                id: model.nodes.length*2
+                            }
+                        );
+                        node.connectors.push(
+                            {
+                                type: flowchartConstants.rightConnectorType,
+                                id: model.nodes.length*2+1
+                            }
+                        );
+                    }
+                    model.nodes.push(node);
+                }
+                prepareRuleChain();
+            }
+        );
     }
 
-    function deleteRuleChainsActionTitle(selectedCount) {
-        return $translate.instant('rulechain.delete-rulechains-action-title', {count: selectedCount}, 'messageformat');
+    function prepareRuleChain() {
+
+        if (vm.ruleChainWatch) {
+            vm.ruleChainWatch();
+            vm.ruleChainWatch = null;
+        }
+
+        vm.nextNodeID = 1;
+        vm.nextConnectorID = 1;
+
+        vm.selectedObjects.length = 0;
+        vm.ruleChainModel.nodes.length = 0;
+        vm.ruleChainModel.edges.length = 0;
+
+        vm.inputConnectorId = vm.nextConnectorID++;
+
+        vm.ruleChainModel.nodes.push(
+            {
+                id: vm.nextNodeID++,
+                type: "Input",
+                name: "",
+                nodeType: types.ruleNodeType.INPUT.value,
+                nodeClass: types.ruleNodeType.INPUT.nodeClass,
+                icon: types.ruleNodeType.INPUT.icon,
+                readonly: true,
+                x: 50,
+                y: 150,
+                connectors: [
+                    {
+                        type: flowchartConstants.rightConnectorType,
+                        id: vm.inputConnectorId
+                    },
+                ]
+
+            }
+        );
+        ruleChainService.resolveTargetRuleChains(vm.ruleChainMetaData.ruleChainConnections)
+            .then((ruleChainsMap) => {
+                createRuleChainModel(ruleChainsMap);
+            }
+        );
     }
 
-    function deleteRuleChainsText() {
-        return $translate.instant('rulechain.delete-rulechains-text');
+    function createRuleChainModel(ruleChainsMap) {
+        var nodes = [];
+        for (var i=0;i<vm.ruleChainMetaData.nodes.length;i++) {
+            var ruleNode = vm.ruleChainMetaData.nodes[i];
+            var nodeType = ruleChainService.getRuleNodeComponentType(ruleNode.type);
+            if (nodeType) {
+                var node = {
+                    id: vm.nextNodeID++,
+                    ruleNodeId: ruleNode.id,
+                    additionalInfo: ruleNode.additionalInfo,
+                    configuration: ruleNode.configuration,
+                    x: ruleNode.additionalInfo.layoutX,
+                    y: ruleNode.additionalInfo.layoutY,
+                    type: ruleNode.type,
+                    name: ruleNode.name,
+                    nodeType: nodeType,
+                    nodeClass: vm.types.ruleNodeType[nodeType].nodeClass,
+                    icon: vm.types.ruleNodeType[nodeType].icon,
+                    connectors: [
+                        {
+                            type: flowchartConstants.leftConnectorType,
+                            id: vm.nextConnectorID++
+                        },
+                        {
+                            type: flowchartConstants.rightConnectorType,
+                            id: vm.nextConnectorID++
+                        }
+                    ]
+                };
+                nodes.push(node);
+                vm.ruleChainModel.nodes.push(node);
+            }
+        }
+
+        if (vm.ruleChainMetaData.firstNodeIndex > -1) {
+            var destNode = nodes[vm.ruleChainMetaData.firstNodeIndex];
+            if (destNode) {
+                var connectors = vm.modelservice.nodes.getConnectorsByType(destNode, flowchartConstants.leftConnectorType);
+                if (connectors && connectors.length) {
+                    var edge = {
+                        source: vm.inputConnectorId,
+                        destination: connectors[0].id
+                    };
+                    vm.ruleChainModel.edges.push(edge);
+                }
+            }
+        }
+
+        if (vm.ruleChainMetaData.connections) {
+            for (i = 0; i < vm.ruleChainMetaData.connections.length; i++) {
+                var connection = vm.ruleChainMetaData.connections[0];
+                var sourceNode = nodes[connection.fromIndex];
+                destNode = nodes[connection.toIndex];
+                if (sourceNode && destNode) {
+                    var sourceConnectors = vm.modelservice.nodes.getConnectorsByType(sourceNode, flowchartConstants.rightConnectorType);
+                    var destConnectors = vm.modelservice.nodes.getConnectorsByType(destNode, flowchartConstants.leftConnectorType);
+                    if (sourceConnectors && sourceConnectors.length && destConnectors && destConnectors.length) {
+                        edge = {
+                            source: sourceConnectors[0].id,
+                            destination: destConnectors[0].id,
+                            label: connection.type
+                        };
+                        vm.ruleChainModel.edges.push(edge);
+                    }
+                }
+            }
+        }
+
+        if (vm.ruleChainMetaData.ruleChainConnections) {
+            var ruleChainNodesMap = {};
+            for (i = 0; i < vm.ruleChainMetaData.ruleChainConnections.length; i++) {
+                var ruleChainConnection = vm.ruleChainMetaData.ruleChainConnections[i];
+                var ruleChain = ruleChainsMap[ruleChainConnection.targetRuleChainId.id];
+                if (ruleChainConnection.additionalInfo && ruleChainConnection.additionalInfo.ruleChainNodeId) {
+                    var ruleChainNode = ruleChainNodesMap[ruleChainConnection.additionalInfo.ruleChainNodeId];
+                    if (!ruleChainNode) {
+                        ruleChainNode = {
+                            id: vm.nextNodeID++,
+                            additionalInfo: ruleChainConnection.additionalInfo,
+                            targetRuleChainId: ruleChainConnection.targetRuleChainId.id,
+                            x: ruleChainConnection.additionalInfo.layoutX,
+                            y: ruleChainConnection.additionalInfo.layoutY,
+                            type: 'Rule chain',
+                            name: ruleChain.name,
+                            nodeType: vm.types.ruleNodeType.RULE_CHAIN.value,
+                            nodeClass: vm.types.ruleNodeType.RULE_CHAIN.nodeClass,
+                            icon: vm.types.ruleNodeType.RULE_CHAIN.icon,
+                            connectors: [
+                                {
+                                    type: flowchartConstants.leftConnectorType,
+                                    id: vm.nextConnectorID++
+                                }
+                            ]
+                        };
+                        ruleChainNodesMap[ruleChainConnection.additionalInfo.ruleChainNodeId] = ruleChainNode;
+                        vm.ruleChainModel.nodes.push(ruleChainNode);
+                    }
+                    sourceNode = nodes[ruleChainConnection.fromIndex];
+                    if (sourceNode) {
+                        connectors = vm.modelservice.nodes.getConnectorsByType(sourceNode, flowchartConstants.rightConnectorType);
+                        if (connectors && connectors.length) {
+                            var ruleChainEdge = {
+                                source: connectors[0].id,
+                                destination: ruleChainNode.connectors[0].id,
+                                label: ruleChainConnection.type
+                            };
+                            vm.ruleChainModel.edges.push(ruleChainEdge);
+                        }
+                    }
+                }
+            }
+        }
+
+        vm.canvasControl.adjustCanvasSize();
+
+        vm.isDirty = false;
+
+        $mdUtil.nextTick(() => {
+            vm.ruleChainWatch = $scope.$watch('vm.ruleChainModel',
+                function (newVal, oldVal) {
+                    if (!vm.isDirty && !angular.equals(newVal, oldVal)) {
+                        vm.isDirty = true;
+                    }
+                }, true
+            );
+        });
     }
 
-    function gridInited(grid) {
-        vm.grid = grid;
+    function saveRuleChain() {
+        var ruleChainMetaData = {
+            ruleChainId: vm.ruleChain.id,
+            nodes: [],
+            connections: [],
+            ruleChainConnections: []
+        };
+
+        var nodes = [];
+
+        for (var i=0;i<vm.ruleChainModel.nodes.length;i++) {
+            var node = vm.ruleChainModel.nodes[i];
+            if (node.nodeType != types.ruleNodeType.INPUT.value && node.nodeType != types.ruleNodeType.RULE_CHAIN.value) {
+                var ruleNode = {};
+                if (node.ruleNodeId) {
+                    ruleNode.id = node.ruleNodeId;
+                }
+                ruleNode.type = node.type;
+                ruleNode.name = node.name;
+                ruleNode.configuration = node.configuration;
+                ruleNode.additionalInfo = node.additionalInfo;
+                if (!ruleNode.additionalInfo) {
+                    ruleNode.additionalInfo = {};
+                }
+                ruleNode.additionalInfo.layoutX = node.x;
+                ruleNode.additionalInfo.layoutY = node.y;
+                ruleChainMetaData.nodes.push(ruleNode);
+                nodes.push(node);
+            }
+        }
+        var res = $filter('filter')(vm.ruleChainModel.edges, {source: vm.inputConnectorId});
+        if (res && res.length) {
+            var firstNodeEdge = res[0];
+            var firstNode = vm.modelservice.nodes.getNodeByConnectorId(firstNodeEdge.destination);
+            ruleChainMetaData.firstNodeIndex = nodes.indexOf(firstNode);
+        }
+        for (i=0;i<vm.ruleChainModel.edges.length;i++) {
+            var edge = vm.ruleChainModel.edges[i];
+            var sourceNode = vm.modelservice.nodes.getNodeByConnectorId(edge.source);
+            var destNode = vm.modelservice.nodes.getNodeByConnectorId(edge.destination);
+            if (sourceNode.nodeType != types.ruleNodeType.INPUT.value) {
+                var fromIndex = nodes.indexOf(sourceNode);
+                if (destNode.nodeType == types.ruleNodeType.RULE_CHAIN.value) {
+                    var ruleChainConnection = {
+                        fromIndex: fromIndex,
+                        targetRuleChainId: {entityType: vm.types.entityType.rulechain, id: destNode.targetRuleChainId},
+                        additionalInfo: destNode.additionalInfo,
+                        type: edge.label
+                    };
+                    if (!ruleChainConnection.additionalInfo) {
+                        ruleChainConnection.additionalInfo = {};
+                    }
+                    ruleChainConnection.additionalInfo.layoutX = destNode.x;
+                    ruleChainConnection.additionalInfo.layoutY = destNode.y;
+                    ruleChainConnection.additionalInfo.ruleChainNodeId = destNode.id;
+                    ruleChainMetaData.ruleChainConnections.push(ruleChainConnection);
+                } else {
+                    var toIndex = nodes.indexOf(destNode);
+                    var nodeConnection = {
+                        fromIndex: fromIndex,
+                        toIndex: toIndex,
+                        type: edge.label
+                    };
+                    ruleChainMetaData.connections.push(nodeConnection);
+                }
+            }
+        }
+        ruleChainService.saveRuleChainMetaData(ruleChainMetaData).then(
+            (ruleChainMetaData) => {
+                vm.ruleChainMetaData = ruleChainMetaData;
+                prepareRuleChain();
+            }
+        );
     }
 
-    function fetchRuleChains(pageLink) {
-        return ruleChainService.getRuleChains(pageLink);
+    function revertRuleChain() {
+        prepareRuleChain();
     }
 
-    function saveRuleChain(ruleChain) {
-        return ruleChainService.saveRuleChain(ruleChain);
+    function addRuleNode($event, ruleNode) {
+        $mdDialog.show({
+            controller: 'AddRuleNodeController',
+            controllerAs: 'vm',
+            templateUrl: addRuleNodeTemplate,
+            parent: angular.element($document[0].body),
+            locals: {ruleNode: ruleNode, ruleChainId: vm.ruleChain.id.id},
+            fullscreen: true,
+            targetEvent: $event
+        }).then(function (ruleNode) {
+            ruleNode.id = vm.nextNodeID++;
+            ruleNode.connectors = [];
+            ruleNode.connectors.push(
+                {
+                    id: vm.nextConnectorID++,
+                    type: flowchartConstants.leftConnectorType
+                }
+            );
+            if (ruleNode.nodeType != types.ruleNodeType.RULE_CHAIN.value) {
+                ruleNode.connectors.push(
+                    {
+                        id: vm.nextConnectorID++,
+                        type: flowchartConstants.rightConnectorType
+                    }
+                );
+            }
+            vm.ruleChainModel.nodes.push(ruleNode);
+        }, function () {
+        });
     }
 
-    function deleteRuleChain(ruleChainId) {
-        return ruleChainService.deleteRuleChain(ruleChainId);
+    function addRuleNodeLink($event, link, labels) {
+        return $mdDialog.show({
+            controller: 'AddRuleNodeLinkController',
+            controllerAs: 'vm',
+            templateUrl: addRuleNodeLinkTemplate,
+            parent: angular.element($document[0].body),
+            locals: {link: link, labels: labels},
+            fullscreen: true,
+            targetEvent: $event
+        });
     }
 
-    function getRuleChainTitle(ruleChain) {
-        return ruleChain ? ruleChain.name : '';
+}
+
+/*@ngInject*/
+export function AddRuleNodeController($scope, $mdDialog, ruleNode, ruleChainId, helpLinks) {
+
+    var vm = this;
+
+    vm.helpLinks = helpLinks;
+    vm.ruleNode = ruleNode;
+    vm.ruleChainId = ruleChainId;
+
+    vm.add = add;
+    vm.cancel = cancel;
+
+    function cancel() {
+        $mdDialog.cancel();
     }
 
-    function isRuleChainEditable(ruleChain) {
-        if (userService.getAuthority() === 'TENANT_ADMIN') {
-            return ruleChain && ruleChain.tenantId.id != types.id.nullUid;
-        } else {
-            return userService.getAuthority() === 'SYS_ADMIN';
-        }
+    function add() {
+        $scope.theForm.$setPristine();
+        $mdDialog.hide(vm.ruleNode);
     }
+}
+
+/*@ngInject*/
+export function AddRuleNodeLinkController($scope, $mdDialog, link, labels, helpLinks) {
 
-    function exportRuleChain($event, ruleChain) {
-        $event.stopPropagation();
-        importExport.exportRuleChain(ruleChain.id.id);
+    var vm = this;
+
+    vm.helpLinks = helpLinks;
+    vm.link = link;
+    vm.labels = labels;
+
+    vm.add = add;
+    vm.cancel = cancel;
+
+    function cancel() {
+        $mdDialog.cancel();
     }
 
+    function add() {
+        $scope.theForm.$setPristine();
+        $mdDialog.hide(vm.link);
+    }
 }
diff --git a/ui/src/app/rulechain/rulechain.routes.js b/ui/src/app/rulechain/rulechain.routes.js
index e8d6be4..808661a 100644
--- a/ui/src/app/rulechain/rulechain.routes.js
+++ b/ui/src/app/rulechain/rulechain.routes.js
@@ -15,12 +15,16 @@
  */
 /* eslint-disable import/no-unresolved, import/default */
 
+import ruleNodeTemplate from './rulenode.tpl.html';
 import ruleChainsTemplate from './rulechains.tpl.html';
+import ruleChainTemplate from './rulechain.tpl.html';
 
 /* eslint-enable import/no-unresolved, import/default */
 
 /*@ngInject*/
-export default function RuleChainRoutes($stateProvider) {
+export default function RuleChainRoutes($stateProvider, NodeTemplatePathProvider) {
+
+    NodeTemplatePathProvider.setTemplatePath(ruleNodeTemplate);
 
     $stateProvider
         .state('home.ruleChains', {
@@ -32,7 +36,7 @@ export default function RuleChainRoutes($stateProvider) {
                 "content@home": {
                     templateUrl: ruleChainsTemplate,
                     controllerAs: 'vm',
-                    controller: 'RuleChainController'
+                    controller: 'RuleChainsController'
                 }
             },
             data: {
@@ -42,5 +46,36 @@ export default function RuleChainRoutes($stateProvider) {
             ncyBreadcrumb: {
                 label: '{"icon": "settings_ethernet", "label": "rulechain.rulechains"}'
             }
-        });
+        }).state('home.ruleChains.ruleChain', {
+            url: '/:ruleChainId',
+            reloadOnSearch: false,
+            module: 'private',
+            auth: ['SYS_ADMIN', 'TENANT_ADMIN'],
+            views: {
+                "content@home": {
+                    templateUrl: ruleChainTemplate,
+                    controller: 'RuleChainController',
+                    controllerAs: 'vm'
+                }
+            },
+            resolve: {
+                ruleChain:
+                    /*@ngInject*/
+                    function($stateParams, ruleChainService) {
+                        return ruleChainService.getRuleChain($stateParams.ruleChainId);
+                    },
+                ruleChainMetaData:
+                /*@ngInject*/
+                    function($stateParams, ruleChainService) {
+                        return ruleChainService.getRuleChainMetaData($stateParams.ruleChainId);
+                    }
+            },
+            data: {
+                searchEnabled: false,
+                pageTitle: 'rulechain.rulechain'
+            },
+            ncyBreadcrumb: {
+                label: '{"icon": "settings_ethernet", "label": "{{ vm.ruleChain.name }}", "translate": "false"}'
+            }
+    });
 }
diff --git a/ui/src/app/rulechain/rulechain.scss b/ui/src/app/rulechain/rulechain.scss
new file mode 100644
index 0000000..258e232
--- /dev/null
+++ b/ui/src/app/rulechain/rulechain.scss
@@ -0,0 +1,262 @@
+/**
+ * Copyright © 2016-2018 The 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.
+ */
+
+.tb-rulechain {
+  .tb-fullscreen-button-style {
+    z-index: 1;
+  }
+  .tb-rulechain-library {
+    width: 250px;
+    min-width: 250px;
+    overflow-y: auto;
+    overflow-x: hidden;
+
+    .tb-rulechain-library-panel-group {
+      .tb-panel-title {
+        -webkit-user-select: none;
+        -moz-user-select: none;
+        -ms-user-select: none;
+        user-select: none;
+        min-width: 180px;
+      }
+      .fc-canvas {
+        background: none;
+      }
+      md-icon.md-expansion-panel-icon {
+        margin-right: 0px;
+      }
+      md-expansion-panel-collapsed, .md-expansion-panel-header-container {
+        background: #e6e6e6;
+        border-color: #909090;
+        position: static;
+      }
+      md-expansion-panel {
+        &.md-open {
+          margin-top: 0;
+          margin-bottom: 0;
+        }
+      }
+      md-expansion-panel-content {
+        padding: 0px;
+      }
+    }
+  }
+  .tb-rulechain-graph {
+    overflow: auto;
+  }
+}
+
+.fc-canvas {
+  min-width: 100%;
+  min-height: 100%;
+  outline: none;
+}
+
+.tb-rule-node {
+  display: flex;
+  flex-direction: row;
+  min-width: 150px;
+  max-width: 150px;
+  min-height: 28px;
+  max-height: 28px;
+  padding: 5px 10px;
+  border-radius: 5px;
+  background-color: #F15B26;
+  color: #333;
+  border: solid 1px #777;
+  font-size: 12px;
+  &.tb-input-type {
+    background-color: #a3eaa9;
+    user-select: none;
+  }
+  &.tb-filter-type {
+    background-color: #f1e861;
+  }
+  &.tb-enrichment-type {
+    background-color: #cdf14e;
+  }
+  &.tb-transformation-type {
+    background-color: #79cef1;
+  }
+  &.tb-action-type {
+    background-color: #f1928f;
+  }
+  &.tb-rule-chain-type {
+    background-color: #d6c4f1;
+  }
+  md-icon {
+    font-size: 20px;
+    width: 20px;
+    height: 20px;
+    min-height: 20px;
+    min-width: 20px;
+    padding-right: 4px;
+  }
+  .tb-node-type {
+
+  }
+  .tb-node-title {
+    font-weight: 600;
+  }
+  .tb-node-type, .tb-node-title {
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+  }
+}
+
+.fc-node {
+  z-index: 1;
+  outline: none;
+  &.fc-hover, &.fc-selected {
+    -webkit-filter: brightness(70%);
+    filter: brightness(70%);
+  }
+  &.fc-dragging {
+    z-index: 10;
+  }
+  p {
+    padding: 0 15px;
+    text-align: center;
+  }
+}
+
+.fc-leftConnectors, .fc-rightConnectors {
+  position: absolute;
+  top: 0;
+  height: 100%;
+
+  display: flex;
+  flex-direction: column;
+
+  z-index: 0;
+  .fc-magnet {
+    align-items: center;
+  }
+}
+
+.fc-leftConnectors {
+  left: -20px;
+}
+
+.fc-rightConnectors {
+  right: -20px;
+}
+
+.fc-magnet {
+  display: flex;
+  flex-grow: 1;
+  height: 60px;
+  justify-content: center;
+}
+
+.fc-connector {
+  width: 14px;
+  height: 14px;
+  border: 1px solid #333;
+  margin: 10px;
+  border-radius: 5px;
+  background-color: #ccc;
+}
+
+.fc-connector.fc-hover {
+  background-color: #000;
+}
+
+.fc-edge {
+  outline: none;
+  stroke: gray;
+  stroke-width: 4;
+  fill: transparent;
+  &.fc-selected {
+    stroke: red;
+    stroke-width: 4;
+    fill: transparent;
+  }
+  &.fc-active {
+    animation: dash 3s linear infinite;
+    stroke-dasharray: 20;
+  }
+  &.fc-hover {
+    stroke: gray;
+    stroke-width: 6;
+    fill: transparent;
+  }
+  &.fc-dragging {
+    pointer-events: none;
+  }
+}
+
+.edge-endpoint {
+  fill: gray;
+}
+
+.fc-nodedelete {
+  display: none;
+}
+
+.fc-selected .fc-nodedelete {
+  outline: none;
+  display: block;
+  position: absolute;
+  right: -13px;
+  top: -16px;
+  border: solid 2px white;
+  border-radius: 50%;
+  font-weight: 600;
+  font-size: 18px;
+  line-height: 18px;
+  height: 20px;
+  padding-top: 2px;
+  width: 22px;
+  background: #494949;
+  color: #fff;
+  text-align: center;
+  vertical-align: bottom;
+  cursor: pointer;
+}
+
+.fc-edge-label {
+  position: absolute;
+  user-select: none;
+  pointer-events: none;
+  opacity: 0.8;
+}
+
+.fc-edge-label-text {
+  position: absolute;
+  left: 50%;
+  -webkit-transform: translateX(-50%);
+  transform: translateX(-50%);
+  white-space: nowrap;
+  text-align: center;
+  font-size: 14px;
+  font-weight: 600;
+  top: 5px;
+  span {
+    border: solid 2px #003a79;
+    border-radius: 10px;
+    color: #003a79;
+    background-color: #fff;
+    padding: 3px 5px;
+  }
+}
+
+@keyframes dash {
+  from {
+    stroke-dashoffset: 500;
+  }
+}
diff --git a/ui/src/app/rulechain/rulechain.tpl.html b/ui/src/app/rulechain/rulechain.tpl.html
new file mode 100644
index 0000000..9f22ebc
--- /dev/null
+++ b/ui/src/app/rulechain/rulechain.tpl.html
@@ -0,0 +1,130 @@
+<!--
+
+    Copyright © 2016-2018 The 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.
+
+-->
+
+<md-content flex tb-expand-fullscreen
+            expand-tooltip-direction="bottom" layout="column" class="tb-rulechain">
+    <section class="tb-rulechain-container" flex layout="column">
+        <div class="tb-rulechain-layout" flex layout="row">
+            <div class="tb-rulechain-library">
+                <md-expansion-panel-group class="tb-rulechain-library-panel-group" md-component-id="libraryPanelGroup" auto-expand="true" multiple>
+                    <md-expansion-panel md-component-id="{{typeId}}" id="{{typeId}}" ng-repeat="(typeId, typeModel) in vm.ruleNodeTypesModel">
+                        <md-expansion-panel-collapsed>
+                            <div class="tb-panel-title" translate>{{vm.types.ruleNodeType[typeId].name}}</div>
+                            <md-expansion-panel-icon></md-expansion-panel-icon>
+                        </md-expansion-panel-collapsed>
+                        <md-expansion-panel-expanded>
+                            <md-expansion-panel-header ng-click="vm.$mdExpansionPanel(typeId).collapse()">
+                                <div class="tb-panel-title" translate>{{vm.types.ruleNodeType[typeId].name}}</div>
+                                <md-expansion-panel-icon></md-expansion-panel-icon>
+                            </md-expansion-panel-header>
+                            <md-expansion-panel-content>
+                                <fc-canvas id="tb-rulechain-{{typeId}}"
+                                           model="vm.ruleNodeTypesModel[typeId].model" selected-objects="vm.ruleNodeTypesModel[typeId].selectedObjects"
+                                           automatic-resize="false"
+                                           node-width="170"
+                                           node-height="50"
+                                           drop-target-id="'tb-rulchain-canvas'"></fc-canvas>
+                            </md-expansion-panel-content>
+                        </md-expansion-panel-expanded>
+                    </md-expansion-panel>
+                </md-expansion-panel-group>
+            </div>
+            <div flex class="tb-rulechain-graph">
+                <fc-canvas id="tb-rulchain-canvas"
+                           ng-keydown="vm.keyDown($event)"
+                           ng-keyup="vm.keyUp($event)"
+                           model="vm.ruleChainModel"
+                           selected-objects="vm.selectedObjects"
+                           edge-style="curved"
+                           node-width="170"
+                           node-height="50"
+                           automatic-resize="true"
+                           control="vm.canvasControl"
+                           callbacks="vm.editCallbacks">
+                </fc-canvas>
+            </div>
+        </div>
+        <tb-details-sidenav class="tb-rulenode-details-sidenav"
+                            header-title="{{vm.editingRuleNode.name}}"
+                            header-subtitle="{{'rulenode.rulenode-details' | translate}}"
+                            is-read-only="false"
+                            is-open="vm.isEditingRuleNode"
+                            is-always-edit="true"
+                            on-close-details="vm.onEditRuleNodeClosed()"
+                            on-toggle-details-edit-mode="vm.onRevertRuleNodeEdit(vm.ruleNodeForm)"
+                            on-apply-details="vm.saveRuleNode(vm.ruleNodeForm)"
+                            the-form="vm.ruleNodeForm">
+            <details-buttons tb-help="vm.helpLinkIdForRuleNodeType()" help-container-id="help-container">
+                <div id="help-container"></div>
+            </details-buttons>
+            <form name="vm.ruleNodeForm" ng-if="vm.isEditingRuleNode">
+                <tb-rule-node
+                        rule-node="vm.editingRuleNode"
+                        rule-chain-id="vm.ruleChain.id.id"
+                        is-edit="true"
+                        is-read-only="false"
+                        on-delete-rule-node="vm.deleteRuleNode(event, vm.editingRuleNode)"
+                        the-form="vm.ruleNodeForm">
+                </tb-rule-node>
+            </form>
+        </tb-details-sidenav>
+        <tb-details-sidenav class="tb-rulenode-link-details-sidenav"
+                            header-title="{{vm.editingRuleNodeLink.label}}"
+                            header-subtitle="{{'rulenode.link-details' | translate}}"
+                            is-read-only="false"
+                            is-open="vm.isEditingRuleNodeLink"
+                            is-always-edit="true"
+                            on-close-details="vm.onEditRuleNodeLinkClosed()"
+                            on-toggle-details-edit-mode="vm.onRevertRuleNodeLinkEdit(vm.ruleNodeLinkForm)"
+                            on-apply-details="vm.saveRuleNodeLink(vm.ruleNodeLinkForm)"
+                            the-form="vm.ruleNodeLinkForm">
+            <details-buttons tb-help="vm.helpLinkIdForRuleNodeLink()" help-container-id="link-help-container">
+                <div id="link-help-container"></div>
+            </details-buttons>
+            <form name="vm.ruleNodeLinkForm" ng-if="vm.isEditingRuleNodeLink">
+                <tb-rule-node-link
+                        link="vm.editingRuleNodeLink"
+                        labels="vm.editingRuleNodeLinkLabels"
+                        is-edit="true"
+                        is-read-only="false"
+                        the-form="vm.ruleNodeLinkForm">
+                </tb-rule-node-link>
+            </form>
+        </tb-details-sidenav>
+    </section>
+    <section layout="row" layout-wrap class="tb-footer-buttons md-fab" layout-align="start end">
+        <md-button ng-disabled="$root.loading || !vm.isDirty"
+                   class="tb-btn-footer md-accent md-hue-2 md-fab"
+                   aria-label="{{ 'action.apply' | translate }}"
+                   ng-click="vm.saveRuleChain()">
+            <md-tooltip md-direction="top">
+                {{ 'action.apply-changes' | translate }}
+            </md-tooltip>
+            <ng-md-icon icon="done"></ng-md-icon>
+        </md-button>
+        <md-button ng-disabled="$root.loading || !vm.isDirty"
+                   class="tb-btn-footer md-accent md-hue-2 md-fab"
+                   aria-label="{{ 'action.decline-changes' | translate }}"
+                   ng-click="vm.revertRuleChain()">
+            <md-tooltip md-direction="top">
+                {{ 'action.decline-changes' | translate }}
+            </md-tooltip>
+            <ng-md-icon icon="close"></ng-md-icon>
+        </md-button>
+    </section>
+</md-content>
diff --git a/ui/src/app/rulechain/rulechain-fieldset.tpl.html b/ui/src/app/rulechain/rulechain-fieldset.tpl.html
index ec68c57..a79ba35 100644
--- a/ui/src/app/rulechain/rulechain-fieldset.tpl.html
+++ b/ui/src/app/rulechain/rulechain-fieldset.tpl.html
@@ -32,7 +32,7 @@
     </md-button>
 </div>
 
-<md-content class="md-padding tb-rulechain" layout="column">
+<md-content class="md-padding tb-rulechain-fieldset" layout="column">
     <fieldset ng-disabled="$root.loading || !isEdit || isReadOnly">
         <md-input-container class="md-block">
             <label translate>rulechain.name</label>
diff --git a/ui/src/app/rulechain/rulechains.controller.js b/ui/src/app/rulechain/rulechains.controller.js
new file mode 100644
index 0000000..2a30b0c
--- /dev/null
+++ b/ui/src/app/rulechain/rulechains.controller.js
@@ -0,0 +1,188 @@
+/*
+ * Copyright © 2016-2018 The 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.
+ */
+/* eslint-disable import/no-unresolved, import/default */
+
+import addRuleChainTemplate from './add-rulechain.tpl.html';
+import ruleChainCard from './rulechain-card.tpl.html';
+
+/* eslint-enable import/no-unresolved, import/default */
+
+/*@ngInject*/
+export default function RuleChainsController(ruleChainService, userService, importExport, $state, $stateParams, $filter, $translate, types) {
+
+    var ruleChainActionsList = [
+        {
+            onAction: function ($event, item) {
+                vm.grid.openItem($event, item);
+            },
+            name: function() { return $translate.instant('rulechain.details') },
+            details: function() { return $translate.instant('rulechain.rulechain-details') },
+            icon: "edit"
+        },
+        {
+            onAction: function ($event, item) {
+                exportRuleChain($event, item);
+            },
+            name: function() { $translate.instant('action.export') },
+            details: function() { return $translate.instant('rulechain.export') },
+            icon: "file_download"
+        },
+        {
+            onAction: function ($event, item) {
+                vm.grid.deleteItem($event, item);
+            },
+            name: function() { return $translate.instant('action.delete') },
+            details: function() { return $translate.instant('rulechain.delete') },
+            icon: "delete",
+            isEnabled: isRuleChainEditable
+        }
+    ];
+
+    var ruleChainAddItemActionsList = [
+        {
+            onAction: function ($event) {
+                vm.grid.addItem($event);
+            },
+            name: function() { return $translate.instant('action.create') },
+            details: function() { return $translate.instant('rulechain.create-new-rulechain') },
+            icon: "insert_drive_file"
+        },
+        {
+            onAction: function ($event) {
+                importExport.importRuleChain($event).then(
+                    function() {
+                        vm.grid.refreshList();
+                    }
+                );
+            },
+            name: function() { return $translate.instant('action.import') },
+            details: function() { return $translate.instant('rulechain.import') },
+            icon: "file_upload"
+        }
+    ];
+
+    var vm = this;
+
+    vm.types = types;
+
+    vm.ruleChainGridConfig = {
+
+        refreshParamsFunc: null,
+
+        deleteItemTitleFunc: deleteRuleChainTitle,
+        deleteItemContentFunc: deleteRuleChainText,
+        deleteItemsTitleFunc: deleteRuleChainsTitle,
+        deleteItemsActionTitleFunc: deleteRuleChainsActionTitle,
+        deleteItemsContentFunc: deleteRuleChainsText,
+
+        fetchItemsFunc: fetchRuleChains,
+        saveItemFunc: saveRuleChain,
+        clickItemFunc: openRuleChain,
+        deleteItemFunc: deleteRuleChain,
+
+        getItemTitleFunc: getRuleChainTitle,
+        itemCardTemplateUrl: ruleChainCard,
+        parentCtl: vm,
+
+        actionsList: ruleChainActionsList,
+        addItemActions: ruleChainAddItemActionsList,
+
+        onGridInited: gridInited,
+
+        addItemTemplateUrl: addRuleChainTemplate,
+
+        addItemText: function() { return $translate.instant('rulechain.add-rulechain-text') },
+        noItemsText: function() { return $translate.instant('rulechain.no-rulechains-text') },
+        itemDetailsText: function() { return $translate.instant('rulechain.rulechain-details') },
+        isSelectionEnabled: isRuleChainEditable,
+        isDetailsReadOnly: function(ruleChain) {
+            return !isRuleChainEditable(ruleChain);
+        }
+    };
+
+    if (angular.isDefined($stateParams.items) && $stateParams.items !== null) {
+        vm.ruleChainGridConfig.items = $stateParams.items;
+    }
+
+    if (angular.isDefined($stateParams.topIndex) && $stateParams.topIndex > 0) {
+        vm.ruleChainGridConfig.topIndex = $stateParams.topIndex;
+    }
+
+    vm.isRuleChainEditable = isRuleChainEditable;
+
+    vm.exportRuleChain = exportRuleChain;
+
+    function deleteRuleChainTitle(ruleChain) {
+        return $translate.instant('rulechain.delete-rulechain-title', {ruleChainName: ruleChain.name});
+    }
+
+    function deleteRuleChainText() {
+        return $translate.instant('rulechain.delete-rulechain-text');
+    }
+
+    function deleteRuleChainsTitle(selectedCount) {
+        return $translate.instant('rulechain.delete-rulechains-title', {count: selectedCount}, 'messageformat');
+    }
+
+    function deleteRuleChainsActionTitle(selectedCount) {
+        return $translate.instant('rulechain.delete-rulechains-action-title', {count: selectedCount}, 'messageformat');
+    }
+
+    function deleteRuleChainsText() {
+        return $translate.instant('rulechain.delete-rulechains-text');
+    }
+
+    function gridInited(grid) {
+        vm.grid = grid;
+    }
+
+    function fetchRuleChains(pageLink) {
+        return ruleChainService.getRuleChains(pageLink);
+    }
+
+    function saveRuleChain(ruleChain) {
+        return ruleChainService.saveRuleChain(ruleChain);
+    }
+
+    function openRuleChain($event, ruleChain) {
+        if ($event) {
+            $event.stopPropagation();
+        }
+        $state.go('home.ruleChains.ruleChain', {ruleChainId: ruleChain.id.id});
+    }
+
+    function deleteRuleChain(ruleChainId) {
+        return ruleChainService.deleteRuleChain(ruleChainId);
+    }
+
+    function getRuleChainTitle(ruleChain) {
+        return ruleChain ? ruleChain.name : '';
+    }
+
+    function isRuleChainEditable(ruleChain) {
+        if (userService.getAuthority() === 'TENANT_ADMIN') {
+            return ruleChain && ruleChain.tenantId.id != types.id.nullUid;
+        } else {
+            return userService.getAuthority() === 'SYS_ADMIN';
+        }
+    }
+
+    function exportRuleChain($event, ruleChain) {
+        $event.stopPropagation();
+        importExport.exportRuleChain(ruleChain.id.id);
+    }
+
+}
diff --git a/ui/src/app/rulechain/rulenode.directive.js b/ui/src/app/rulechain/rulenode.directive.js
new file mode 100644
index 0000000..7fa4a18
--- /dev/null
+++ b/ui/src/app/rulechain/rulenode.directive.js
@@ -0,0 +1,79 @@
+/*
+ * Copyright © 2016-2018 The 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.
+ */
+
+/* eslint-disable import/no-unresolved, import/default */
+
+import ruleNodeFieldsetTemplate from './rulenode-fieldset.tpl.html';
+
+/* eslint-enable import/no-unresolved, import/default */
+
+/*@ngInject*/
+export default function RuleNodeDirective($compile, $templateCache, ruleChainService, types) {
+    var linker = function (scope, element) {
+        var template = $templateCache.get(ruleNodeFieldsetTemplate);
+        element.html(template);
+
+        scope.types = types;
+
+        scope.params = {
+            targetRuleChainId: null
+        };
+
+        scope.$watch('ruleNode', function() {
+            if (scope.ruleNode && scope.ruleNode.nodeType == types.ruleNodeType.RULE_CHAIN.value) {
+                scope.params.targetRuleChainId = scope.ruleNode.targetRuleChainId;
+                watchTargetRuleChain();
+            } else {
+                if (scope.targetRuleChainWatch) {
+                    scope.targetRuleChainWatch();
+                    scope.targetRuleChainWatch = null;
+                }
+            }
+        });
+
+        function watchTargetRuleChain() {
+            scope.targetRuleChainWatch = scope.$watch('params.targetRuleChainId',
+                function(targetRuleChainId) {
+                    if (scope.ruleNode.targetRuleChainId != targetRuleChainId) {
+                        scope.ruleNode.targetRuleChainId = targetRuleChainId;
+                        if (targetRuleChainId) {
+                            ruleChainService.getRuleChain(targetRuleChainId).then(
+                                (ruleChain) => {
+                                    scope.ruleNode.name = ruleChain.name;
+                                }
+                            );
+                        } else {
+                            scope.ruleNode.name = "";
+                        }
+                    }
+                }
+            );
+        }
+        $compile(element.contents())(scope);
+    }
+    return {
+        restrict: "E",
+        link: linker,
+        scope: {
+            ruleChainId: '=',
+            ruleNode: '=',
+            isEdit: '=',
+            isReadOnly: '=',
+            theForm: '=',
+            onDeleteRuleNode: '&'
+        }
+    };
+}
diff --git a/ui/src/app/rulechain/rulenode.tpl.html b/ui/src/app/rulechain/rulenode.tpl.html
new file mode 100644
index 0000000..f1d3619
--- /dev/null
+++ b/ui/src/app/rulechain/rulenode.tpl.html
@@ -0,0 +1,45 @@
+<!--
+
+    Copyright © 2016-2018 The 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.
+
+-->
+<div
+        id="{{node.id}}"
+        ng-attr-style="position: absolute; top: {{ node.y }}px; left: {{ node.x }}px;"
+        ng-dblclick="callbacks.doubleClick($event, node)">
+    <div class="tb-rule-node {{node.nodeClass}}">
+        <md-icon aria-label="node-type-icon" flex="15"
+                 class="material-icons">{{node.icon}}</md-icon>
+        <div layout="column" flex="85" layout-align="center">
+            <span class="tb-node-type">{{ node.type }}</span>
+            <span class="tb-node-title" ng-if="node.name">{{ node.name }}</span>
+        </div>
+        <div class="{{flowchartConstants.leftConnectorClass}}">
+            <div fc-magnet
+                 ng-repeat="connector in modelservice.nodes.getConnectorsByType(node, flowchartConstants.leftConnectorType)">
+                <div fc-connector></div>
+            </div>
+        </div>
+        <div class="{{flowchartConstants.rightConnectorClass}}">
+            <div fc-magnet
+                 ng-repeat="connector in modelservice.nodes.getConnectorsByType(node, flowchartConstants.rightConnectorType)">
+                <div fc-connector></div>
+            </div>
+        </div>
+    </div>
+    <div ng-if="modelservice.isEditable() && !node.readonly" class="fc-nodedelete" ng-click="modelservice.nodes.delete(node)">
+        &times;
+    </div>
+</div>
diff --git a/ui/src/app/rulechain/rulenode-fieldset.tpl.html b/ui/src/app/rulechain/rulenode-fieldset.tpl.html
new file mode 100644
index 0000000..2b2faf8
--- /dev/null
+++ b/ui/src/app/rulechain/rulenode-fieldset.tpl.html
@@ -0,0 +1,55 @@
+<!--
+
+    Copyright © 2016-2018 The 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.
+
+-->
+<md-button ng-click="onDeleteRuleNode({event: $event})"
+           ng-show="!isEdit && !isReadOnly"
+           class="md-raised md-primary">{{ 'rulenode.delete' | translate }}</md-button>
+
+<md-content class="md-padding tb-rulenode" layout="column">
+    <fieldset ng-disabled="$root.loading || !isEdit || isReadOnly">
+        <md-input-container class="md-block">
+            <label translate>rulenode.type</label>
+            <input readonly name="type" ng-model="ruleNode.type">
+        </md-input-container>
+        <section ng-if="ruleNode.nodeType != types.ruleNodeType.RULE_CHAIN.value">
+            <md-input-container class="md-block">
+                <label translate>rulenode.name</label>
+                <input required name="name" ng-model="ruleNode.name">
+                <div ng-messages="theForm.name.$error">
+                    <div translate ng-message="required">rulenode.name-required</div>
+                </div>
+            </md-input-container>
+            <md-input-container class="md-block">
+                <label translate>rulenode.description</label>
+                <textarea ng-model="ruleNode.additionalInfo.description" rows="2"></textarea>
+            </md-input-container>
+        </section>
+        <section ng-if="ruleNode.nodeType == types.ruleNodeType.RULE_CHAIN.value">
+            <tb-entity-autocomplete the-form="theForm"
+                                    ng-disabled="$root.loading || !isEdit || isReadOnly"
+                                    tb-required="true"
+                                    exclude-entity-ids="[ruleChainId]"
+                                    entity-type="types.entityType.rulechain"
+                                    ng-model="params.targetRuleChainId">
+            </tb-entity-autocomplete>
+            <md-input-container class="md-block">
+                <label translate>rulenode.description</label>
+                <textarea ng-model="ruleNode.additionalInfo.description" rows="2"></textarea>
+            </md-input-container>
+        </section>
+    </fieldset>
+</md-content>