You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@ignite.apache.org by sb...@apache.org on 2017/12/08 08:34:40 UTC

[09/30] ignite git commit: IGNITE-6390 Web Console: Added component for cluster selection.

IGNITE-6390 Web Console: Added component for cluster selection.


Project: http://git-wip-us.apache.org/repos/asf/ignite/repo
Commit: http://git-wip-us.apache.org/repos/asf/ignite/commit/1367bc98
Tree: http://git-wip-us.apache.org/repos/asf/ignite/tree/1367bc98
Diff: http://git-wip-us.apache.org/repos/asf/ignite/diff/1367bc98

Branch: refs/heads/ignite-zk
Commit: 1367bc98eb08233f9e47ba45335f9dda1fbb7bbd
Parents: cbd69d6
Author: Dmitriy Shabalin <ds...@gridgain.com>
Authored: Wed Dec 6 10:36:42 2017 +0700
Committer: Alexey Kuznetsov <ak...@apache.org>
Committed: Wed Dec 6 10:36:42 2017 +0700

----------------------------------------------------------------------
 .../internal/visor/util/VisorTaskUtils.java     |  139 ++
 .../commands/tasks/VisorTasksCommand.scala      |    1 +
 .../scala/org/apache/ignite/visor/visor.scala   |   49 -
 modules/web-console/backend/app/agentSocket.js  |    2 +-
 .../web-console/backend/app/agentsHandler.js    |   51 +-
 .../web-console/backend/app/browsersHandler.js  |    7 +
 modules/web-console/backend/app/mongo.js        |    1 +
 modules/web-console/backend/package.json        |    3 +-
 modules/web-console/frontend/app/app.js         |    8 +-
 .../app/components/bs-select-menu/style.scss    |    4 +-
 .../cluster-select/cluster-select.controller.js |   64 -
 .../cluster-select/cluster-select.pug           |   47 -
 .../cluster-select/cluster-select.scss          |   30 -
 .../app/components/cluster-select/index.js      |   29 -
 .../components/cluster-selector/component.js    |   25 +
 .../components/cluster-selector/controller.js   |   62 +
 .../app/components/cluster-selector/index.js    |   23 +
 .../app/components/cluster-selector/style.scss  |   66 +
 .../components/cluster-selector/template.pug    |   75 +
 .../app/components/list-editable/controller.js  |    2 +-
 .../components/page-queries/Notebook.data.js    |  168 ++
 .../components/page-queries/Notebook.service.js |   74 +
 .../app/components/page-queries/controller.js   | 1938 ++++++++++++++++++
 .../app/components/page-queries/index.js        |   62 +
 .../page-queries/notebook.controller.js         |   62 +
 .../app/components/page-queries/style.scss      |   36 +
 .../components/page-queries/template.tpl.pug    |  385 ++++
 .../app/modules/agent/AgentManager.service.js   |   57 +-
 .../frontend/app/modules/sql/Notebook.data.js   |  168 --
 .../app/modules/sql/Notebook.service.js         |   74 -
 .../app/modules/sql/notebook.controller.js      |   62 -
 .../frontend/app/modules/sql/sql.controller.js  | 1887 -----------------
 .../frontend/app/modules/sql/sql.module.js      |   61 -
 .../frontend/app/primitives/switcher/index.pug  |    2 +-
 .../frontend/app/primitives/switcher/index.scss |   69 +-
 .../frontend/views/includes/header-right.pug    |    2 -
 .../web-console/frontend/views/sql/sql.tpl.pug  |  381 ----
 .../console/agent/handlers/ClusterListener.java |  178 +-
 .../ignite/console/agent/rest/RestExecutor.java |   63 +-
 39 files changed, 3477 insertions(+), 2940 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/ignite/blob/1367bc98/modules/core/src/main/java/org/apache/ignite/internal/visor/util/VisorTaskUtils.java
----------------------------------------------------------------------
diff --git a/modules/core/src/main/java/org/apache/ignite/internal/visor/util/VisorTaskUtils.java b/modules/core/src/main/java/org/apache/ignite/internal/visor/util/VisorTaskUtils.java
index ace451c..fda801c 100644
--- a/modules/core/src/main/java/org/apache/ignite/internal/visor/util/VisorTaskUtils.java
+++ b/modules/core/src/main/java/org/apache/ignite/internal/visor/util/VisorTaskUtils.java
@@ -23,8 +23,10 @@ import java.io.FileFilter;
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.RandomAccessFile;
+import java.math.BigDecimal;
 import java.net.InetAddress;
 import java.net.URL;
+import java.net.UnknownHostException;
 import java.nio.ByteBuffer;
 import java.nio.channels.FileChannel;
 import java.nio.charset.CharacterCodingException;
@@ -69,6 +71,7 @@ import org.apache.ignite.internal.visor.log.VisorLogFile;
 import org.apache.ignite.lang.IgniteClosure;
 import org.apache.ignite.lang.IgnitePredicate;
 import org.apache.ignite.spi.eventstorage.NoopEventStorageSpi;
+import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 
 import static java.lang.System.getProperty;
@@ -1113,4 +1116,140 @@ public class VisorTaskUtils {
     public static boolean joinTimedOut(String msg) {
         return msg != null && msg.startsWith("Join process timed out.");
     }
+
+    /**
+     * Special wrapper over address that can be sorted in following order:
+     *     IPv4, private IPv4, IPv4 local host, IPv6.
+     *     Lower addresses first.
+     */
+    private static class SortableAddress implements Comparable<SortableAddress> {
+        /** */
+        private int type;
+
+        /** */
+        private BigDecimal bits;
+
+        /** */
+        private String addr;
+
+        /**
+         * Constructor.
+         *
+         * @param addr Address as string.
+         */
+        private SortableAddress(String addr) {
+            this.addr = addr;
+
+            if (addr.indexOf(':') > 0)
+                type = 4; // IPv6
+            else {
+                try {
+                    InetAddress inetAddr = InetAddress.getByName(addr);
+
+                    if (inetAddr.isLoopbackAddress())
+                        type = 3;  // localhost
+                    else if (inetAddr.isSiteLocalAddress())
+                        type = 2;  // private IPv4
+                    else
+                        type = 1; // other IPv4
+                }
+                catch (UnknownHostException ignored) {
+                    type = 5;
+                }
+            }
+
+            bits = BigDecimal.valueOf(0L);
+
+            try {
+                String[] octets = addr.contains(".") ? addr.split(".") : addr.split(":");
+
+                int len = octets.length;
+
+                for (int i = 0; i < len; i++) {
+                    long oct = F.isEmpty(octets[i]) ? 0 : Long.valueOf( octets[i]);
+                    long pow = Double.valueOf(Math.pow(256, octets.length - 1 - i)).longValue();
+
+                    bits = bits.add(BigDecimal.valueOf(oct * pow));
+                }
+            }
+            catch (Exception ignore) {
+                // No-op.
+            }
+        }
+
+        /** {@inheritDoc} */
+        @Override public int compareTo(@NotNull SortableAddress o) {
+            return (type == o.type ? bits.compareTo(o.bits) : Integer.compare(type, o.type));
+        }
+
+        /** {@inheritDoc} */
+        @Override public boolean equals(Object o) {
+            if (this == o)
+                return true;
+
+            if (o == null || getClass() != o.getClass())
+                return false;
+
+            SortableAddress other = (SortableAddress)o;
+
+            return addr != null ? addr.equals(other.addr) : other.addr == null;
+        }
+
+        /** {@inheritDoc} */
+        @Override public int hashCode() {
+            return addr != null ? addr.hashCode() : 0;
+        }
+
+        /**
+         * @return Address.
+         */
+        public String address() {
+            return addr;
+        }
+    }
+
+    /**
+     * Sort addresses: IPv4 & real addresses first.
+     *
+     * @param addrs Addresses to sort.
+     * @return Sorted list.
+     */
+    public static Collection<String> sortAddresses(Collection<String> addrs) {
+        if (F.isEmpty(addrs))
+            return Collections.emptyList();
+
+        int sz = addrs.size();
+
+        List<SortableAddress> sorted = new ArrayList<>(sz);
+
+        for (String addr : addrs)
+            sorted.add(new SortableAddress(addr));
+
+        Collections.sort(sorted);
+
+        Collection<String> res = new ArrayList<>(sz);
+
+        for (SortableAddress sa : sorted)
+            res.add(sa.address());
+
+        return res;
+    }
+
+    /**
+     * Split addresses.
+     *
+     * @param s String with comma separted addresses.
+     * @return Collection of addresses.
+     */
+    public static Collection<String> splitAddresses(String s) {
+        if (F.isEmpty(s))
+            return Collections.emptyList();
+
+        String[] addrs = s.split(",");
+
+        for (int i = 0; i < addrs.length; i++)
+            addrs[i] = addrs[i].trim();
+
+        return Arrays.asList(addrs);
+    }
 }

http://git-wip-us.apache.org/repos/asf/ignite/blob/1367bc98/modules/visor-console/src/main/scala/org/apache/ignite/visor/commands/tasks/VisorTasksCommand.scala
----------------------------------------------------------------------
diff --git a/modules/visor-console/src/main/scala/org/apache/ignite/visor/commands/tasks/VisorTasksCommand.scala b/modules/visor-console/src/main/scala/org/apache/ignite/visor/commands/tasks/VisorTasksCommand.scala
index 4d9b795..0d6753e 100644
--- a/modules/visor-console/src/main/scala/org/apache/ignite/visor/commands/tasks/VisorTasksCommand.scala
+++ b/modules/visor-console/src/main/scala/org/apache/ignite/visor/commands/tasks/VisorTasksCommand.scala
@@ -32,6 +32,7 @@ import java.util.UUID
 import org.apache.ignite.internal.visor.event.{VisorGridEvent, VisorGridJobEvent, VisorGridTaskEvent}
 import org.apache.ignite.internal.visor.node.VisorNodeEventsCollectorTask
 import org.apache.ignite.internal.visor.node.VisorNodeEventsCollectorTaskArg
+import org.apache.ignite.internal.visor.util.VisorTaskUtils._
 
 import scala.collection.JavaConversions._
 import scala.language.implicitConversions

http://git-wip-us.apache.org/repos/asf/ignite/blob/1367bc98/modules/visor-console/src/main/scala/org/apache/ignite/visor/visor.scala
----------------------------------------------------------------------
diff --git a/modules/visor-console/src/main/scala/org/apache/ignite/visor/visor.scala b/modules/visor-console/src/main/scala/org/apache/ignite/visor/visor.scala
index ffc7a00..1a46316 100644
--- a/modules/visor-console/src/main/scala/org/apache/ignite/visor/visor.scala
+++ b/modules/visor-console/src/main/scala/org/apache/ignite/visor/visor.scala
@@ -2694,53 +2694,4 @@ object visor extends VisorTag {
         else
             Long.MaxValue
     }
-
-    /**
-     * Sort addresses to properly display in Visor.
-     *
-     * @param addrs Addresses to sort.
-     * @return Sorted list.
-     */
-    def sortAddresses(addrs: Iterable[String]) = {
-        def ipToLong(ip: String) = {
-            try {
-                val octets = if (ip.contains(".")) ip.split('.') else ip.split(':')
-
-                var dec = BigDecimal.valueOf(0L)
-
-                for (i <- octets.indices) dec += octets(i).toLong * math.pow(256, octets.length - 1 - i).toLong
-
-                dec
-            }
-            catch {
-                case _: Exception => BigDecimal.valueOf(0L)
-            }
-        }
-
-        /**
-         * Sort addresses to properly display in Visor.
-         *
-         * @param addr Address to detect type for.
-         * @return IP class type for sorting in order: public addresses IPv4 + private IPv4 + localhost + IPv6.
-         */
-        def addrType(addr: String) = {
-            if (addr.contains(':'))
-                4 // IPv6
-            else {
-                try {
-                    InetAddress.getByName(addr) match {
-                        case ip if ip.isLoopbackAddress => 3 // localhost
-                        case ip if ip.isSiteLocalAddress => 2 // private IPv4
-                        case _ => 1 // other IPv4
-                    }
-                }
-                catch {
-                    case ignore: UnknownHostException => 5
-                }
-            }
-        }
-
-        addrs.map(addr => (addrType(addr), ipToLong(addr), addr)).toSeq.
-            sortWith((l, r) => if (l._1 == r._1) l._2.compare(r._2) < 0 else l._1 < r._1).map(_._3)
-    }
 }

http://git-wip-us.apache.org/repos/asf/ignite/blob/1367bc98/modules/web-console/backend/app/agentSocket.js
----------------------------------------------------------------------
diff --git a/modules/web-console/backend/app/agentSocket.js b/modules/web-console/backend/app/agentSocket.js
index 75dcd53..6e4518a 100644
--- a/modules/web-console/backend/app/agentSocket.js
+++ b/modules/web-console/backend/app/agentSocket.js
@@ -88,7 +88,7 @@ module.exports.factory = function(_) {
     class AgentSocket {
         /**
          * @param {Socket} socket Socket for interaction.
-         * @param {String} tokens Active tokens.
+         * @param {Array.<String>} tokens Agent tokens.
          * @param {String} demoEnabled Demo enabled.
          */
         constructor(socket, tokens, demoEnabled) {

http://git-wip-us.apache.org/repos/asf/ignite/blob/1367bc98/modules/web-console/backend/app/agentsHandler.js
----------------------------------------------------------------------
diff --git a/modules/web-console/backend/app/agentsHandler.js b/modules/web-console/backend/app/agentsHandler.js
index 112793a..844ce1e 100644
--- a/modules/web-console/backend/app/agentsHandler.js
+++ b/modules/web-console/backend/app/agentsHandler.js
@@ -17,6 +17,8 @@
 
 'use strict';
 
+const uuid = require('uuid/v4');
+
 // Fire me up!
 
 /**
@@ -82,19 +84,14 @@ module.exports.factory = function(_, fs, path, JSZip, socketio, settings, mongo,
 
     class Cluster {
         constructor(top) {
-            let d = new Date().getTime();
-
-            this.id = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
-                const r = (d + Math.random() * 16) % 16 | 0;
-
-                d = Math.floor(d / 16);
-
-                return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
-            });
+            const clusterName = top.clusterName;
 
+            this.id = _.isEmpty(clusterName) ? `Cluster ${uuid().substring(0, 8).toUpperCase()}` : clusterName;
             this.nids = top.nids;
-
+            this.addresses = top.addresses;
+            this.clients = top.clients;
             this.clusterVersion = top.clusterVersion;
+            this.active = top.active;
         }
 
         isSameCluster(top) {
@@ -103,8 +100,18 @@ module.exports.factory = function(_, fs, path, JSZip, socketio, settings, mongo,
 
         update(top) {
             this.clusterVersion = top.clusterVersion;
-
             this.nids = top.nids;
+            this.addresses = top.addresses;
+            this.clients = top.clients;
+            this.clusterVersion = top.clusterVersion;
+            this.active = top.active;
+        }
+
+        same(top) {
+            return _.difference(this.nids, top.nids).length === 0 &&
+                _.isEqual(this.addresses, top.addresses) &&
+                this.clusterVersion === top.clusterVersion &&
+                this.active === top.active;
         }
     }
 
@@ -192,10 +199,13 @@ module.exports.factory = function(_, fs, path, JSZip, socketio, settings, mongo,
         }
 
         getOrCreateCluster(top) {
-            const cluster = _.find(this.clusters, (c) => c.isSameCluster(top));
+            let cluster = _.find(this.clusters, (c) => c.isSameCluster(top));
+
+            if (_.isNil(cluster)) {
+                cluster = new Cluster(top);
 
-            if (_.isNil(cluster))
-                this.clusters.push(new Cluster(top));
+                this.clusters.push(cluster);
+            }
 
             return cluster;
         }
@@ -230,8 +240,17 @@ module.exports.factory = function(_, fs, path, JSZip, socketio, settings, mongo,
                         this._browsersHnd.agentStats(token);
                     });
                 }
-                else
-                    cluster.update(top);
+                else {
+                    const changed = !cluster.same(top);
+
+                    if (changed) {
+                        cluster.update(top);
+
+                        _.forEach(tokens, (token) => {
+                            this._browsersHnd.clusterChanged(token, cluster);
+                        });
+                    }
+                }
             });
 
             sock.on('cluster:collector', (top) => {

http://git-wip-us.apache.org/repos/asf/ignite/blob/1367bc98/modules/web-console/backend/app/browsersHandler.js
----------------------------------------------------------------------
diff --git a/modules/web-console/backend/app/browsersHandler.js b/modules/web-console/backend/app/browsersHandler.js
index 8b1385d..7ae247b 100644
--- a/modules/web-console/backend/app/browsersHandler.js
+++ b/modules/web-console/backend/app/browsersHandler.js
@@ -124,6 +124,12 @@ module.exports = {
                     .then((stat) => _.forEach(socks, (sock) => sock.emit('agents:stat', stat)));
             }
 
+            clusterChanged(token, cluster) {
+                const socks = this._browserSockets.get(token);
+
+                _.forEach(socks, (sock) => sock.emit('cluster:changed', cluster));
+            }
+
             emitNotification(sock) {
                 sock.emit('user:notifications', this.notification);
             }
@@ -224,6 +230,7 @@ module.exports = {
                 this.registerVisorTask('queryClose', internalVisor('query.VisorQueryCleanupTask'), 'java.util.Map', 'java.util.UUID', 'java.util.Set');
                 this.registerVisorTask('queryCloseX2', internalVisor('query.VisorQueryCleanupTask'), internalVisor('query.VisorQueryCleanupTaskArg'));
 
+                this.registerVisorTask('toggleClusterState', internalVisor('misc.VisorChangeGridActiveStateTask'), internalVisor('misc.VisorChangeGridActiveStateTaskArg'));
 
                 // Return command result from grid to browser.
                 sock.on('node:visor', (clusterId, taskId, nids, ...args) => {

http://git-wip-us.apache.org/repos/asf/ignite/blob/1367bc98/modules/web-console/backend/app/mongo.js
----------------------------------------------------------------------
diff --git a/modules/web-console/backend/app/mongo.js b/modules/web-console/backend/app/mongo.js
index 81076af..e0d0a0f 100644
--- a/modules/web-console/backend/app/mongo.js
+++ b/modules/web-console/backend/app/mongo.js
@@ -79,6 +79,7 @@ const defineSchema = (passportMongo, mongoose) => {
         DUPLICATE_KEY_ERROR: 11000,
         DUPLICATE_KEY_UPDATE_ERROR: 11001
     };
+
     // Define Account model.
     result.Account = mongoose.model('Account', AccountSchema);
 

http://git-wip-us.apache.org/repos/asf/ignite/blob/1367bc98/modules/web-console/backend/package.json
----------------------------------------------------------------------
diff --git a/modules/web-console/backend/package.json b/modules/web-console/backend/package.json
index f0b2b5e..ba442f9 100644
--- a/modules/web-console/backend/package.json
+++ b/modules/web-console/backend/package.json
@@ -68,7 +68,8 @@
     "passport-local": "1.0.0",
     "passport-local-mongoose": "4.0.0",
     "passport.socketio": "3.7.0",
-    "socket.io": "1.7.3"
+    "socket.io": "1.7.3",
+    "uuid": "3.1.0"
   },
   "devDependencies": {
     "chai": "4.1.0",

http://git-wip-us.apache.org/repos/asf/ignite/blob/1367bc98/modules/web-console/frontend/app/app.js
----------------------------------------------------------------------
diff --git a/modules/web-console/frontend/app/app.js b/modules/web-console/frontend/app/app.js
index ca678fc..f367d3e 100644
--- a/modules/web-console/frontend/app/app.js
+++ b/modules/web-console/frontend/app/app.js
@@ -22,7 +22,6 @@ import './app.config';
 
 import './modules/form/form.module';
 import './modules/agent/agent.module';
-import './modules/sql/sql.module';
 import './modules/nodes/nodes.module';
 import './modules/demo/Demo.module';
 
@@ -113,7 +112,6 @@ import resetPassword from './controllers/reset-password.controller';
 // Components
 import igniteListOfRegisteredUsers from './components/list-of-registered-users';
 import IgniteActivitiesUserDialog from './components/activities-user-dialog';
-import clusterSelect from './components/cluster-select';
 import './components/input-dialog';
 import webConsoleHeader from './components/web-console-header';
 import webConsoleFooter from './components/web-console-footer';
@@ -123,12 +121,14 @@ import userNotifications from './components/user-notifications';
 import pageConfigure from './components/page-configure';
 import pageConfigureBasic from './components/page-configure-basic';
 import pageConfigureAdvanced from './components/page-configure-advanced';
+import pageQueries from './components/page-queries';
 import gridColumnSelector from './components/grid-column-selector';
 import gridItemSelected from './components/grid-item-selected';
 import bsSelectMenu from './components/bs-select-menu';
 import protectFromBsSelectRender from './components/protect-from-bs-select-render';
 import uiGridHovering from './components/ui-grid-hovering';
 import listEditable from './components/list-editable';
+import clusterSelector from './components/cluster-selector';
 
 import igniteServices from './services';
 
@@ -168,7 +168,6 @@ angular.module('ignite-console', [
     'ignite-console.branding',
     'ignite-console.socket',
     'ignite-console.agent',
-    'ignite-console.sql',
     'ignite-console.nodes',
     'ignite-console.demo',
     // States.
@@ -197,6 +196,7 @@ angular.module('ignite-console', [
     pageConfigure.name,
     pageConfigureBasic.name,
     pageConfigureAdvanced.name,
+    pageQueries.name,
     gridColumnSelector.name,
     gridItemSelected.name,
     bsSelectMenu.name,
@@ -205,6 +205,7 @@ angular.module('ignite-console', [
     AngularStrapTooltip.name,
     AngularStrapSelect.name,
     listEditable.name,
+    clusterSelector.name,
     // Ignite modules.
     IgniteModules.name
 ])
@@ -231,7 +232,6 @@ angular.module('ignite-console', [
 .directive('igniteOnFocusOut', igniteOnFocusOut)
 .directive('igniteRestoreInputFocus', igniteRestoreInputFocus)
 .directive('igniteListOfRegisteredUsers', igniteListOfRegisteredUsers)
-.directive('igniteClusterSelect', clusterSelect)
 .directive('btnIgniteLinkDashedSuccess', btnIgniteLink)
 .directive('btnIgniteLinkDashedSecondary', btnIgniteLink)
 // Services.

http://git-wip-us.apache.org/repos/asf/ignite/blob/1367bc98/modules/web-console/frontend/app/components/bs-select-menu/style.scss
----------------------------------------------------------------------
diff --git a/modules/web-console/frontend/app/components/bs-select-menu/style.scss b/modules/web-console/frontend/app/components/bs-select-menu/style.scss
index 870b1bf..ccf33a3 100644
--- a/modules/web-console/frontend/app/components/bs-select-menu/style.scss
+++ b/modules/web-console/frontend/app/components/bs-select-menu/style.scss
@@ -88,7 +88,7 @@
         }
 
         & > li > .bssm-item-button__active {
-            background-color: #eeeeee;
+            background-color: #e5f2f9;
         }
     }
-}
\ No newline at end of file
+}

http://git-wip-us.apache.org/repos/asf/ignite/blob/1367bc98/modules/web-console/frontend/app/components/cluster-select/cluster-select.controller.js
----------------------------------------------------------------------
diff --git a/modules/web-console/frontend/app/components/cluster-select/cluster-select.controller.js b/modules/web-console/frontend/app/components/cluster-select/cluster-select.controller.js
deleted file mode 100644
index a2d8e1e..0000000
--- a/modules/web-console/frontend/app/components/cluster-select/cluster-select.controller.js
+++ /dev/null
@@ -1,64 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License.  You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-export default class {
-    static $inject = ['AgentManager'];
-
-    constructor(agentMgr) {
-        const ctrl = this;
-
-        ctrl.counter = 1;
-
-        ctrl.cluster = null;
-        ctrl.clusters = [];
-
-        agentMgr.connectionSbj.subscribe({
-            next: ({cluster, clusters}) => {
-                if (_.isEmpty(clusters))
-                    return ctrl.clusters.length = 0;
-
-                const removed = _.differenceBy(ctrl.clusters, clusters, 'id');
-
-                if (_.nonEmpty(removed))
-                    _.pullAll(ctrl.clusters, removed);
-
-                const added = _.differenceBy(clusters, ctrl.clusters, 'id');
-
-                _.forEach(added, (cluster) => {
-                    ctrl.clusters.push({
-                        id: cluster.id,
-                        connected: true,
-                        click: () => {
-                            if (cluster.id === _.get(ctrl, 'cluster.id'))
-                                return;
-
-                            if (_.get(ctrl, 'cluster.connected')) {
-                                agentMgr.saveToStorage(cluster);
-
-                                window.open(window.location.href, '_blank');
-                            }
-                            else
-                                ctrl.cluster = _.find(ctrl.clusters, {id: cluster.id});
-                        }
-                    });
-                });
-
-                ctrl.cluster = cluster;
-            }
-        });
-    }
-}

http://git-wip-us.apache.org/repos/asf/ignite/blob/1367bc98/modules/web-console/frontend/app/components/cluster-select/cluster-select.pug
----------------------------------------------------------------------
diff --git a/modules/web-console/frontend/app/components/cluster-select/cluster-select.pug b/modules/web-console/frontend/app/components/cluster-select/cluster-select.pug
deleted file mode 100644
index eb46e26..0000000
--- a/modules/web-console/frontend/app/components/cluster-select/cluster-select.pug
+++ /dev/null
@@ -1,47 +0,0 @@
-//-
-    Licensed to the Apache Software Foundation (ASF) under one or more
-    contributor license agreements.  See the NOTICE file distributed with
-    this work for additional information regarding copyright ownership.
-    The ASF licenses this file to You 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.
-
--var clusterName = 'Cluster {{ ctrl.cluster.id | id8 }}'
-
-ul.nav
-    li.disabled(ng-if='ctrl.clusters.length === 0')
-        a(ng-if='!ctrl.cluster')
-            i.icon-cluster
-            label.padding-left-dflt(bs-tooltip='' data-placement='bottom' data-title='Check that Web Agent(s) started and connected to cluster(s)') No clusters available
-        a(ng-if='ctrl.cluster')
-            i.icon-danger
-            label.padding-left-dflt(bs-tooltip='' data-placement='bottom' data-title='Connection to cluster was lost') #{clusterName}
-
-    li(ng-if='ctrl.clusters.length === 1 && ctrl.cluster.connected')
-        a
-            i.icon-cluster
-            label.padding-left-dflt #{clusterName}
-
-    li(ng-if='ctrl.clusters.length > 1 || ctrl.clusters.length === 1 && !ctrl.cluster.connected')
-        a.dropdown-toggle(bs-dropdown='' data-placement='bottom-left' data-trigger='hover focus' data-container='self' ng-click='$event.stopPropagation()' aria-haspopup='true' aria-expanded='expanded')
-            i(ng-class='{"icon-cluster": ctrl.cluster.connected, "icon-danger": !ctrl.cluster.connected}')
-            label.padding-left-dflt #{clusterName}
-            span.caret
-            
-        ul.dropdown-menu(role='menu')
-            li(ng-repeat='item in ctrl.clusters' ng-class='{active: ctrl.cluster === item}')
-                div(ng-click='item.click()')
-                    i.icon-cluster.pull-left(style='margin: 0; padding-left: 10px;')
-                    div: a Cluster {{ item.id | id8 }}
-
-i.icon-help(bs-tooltip='' data-placement='bottom' data-html=true
-    data-title='Multi-Cluster Support<br/>\
-        <a href="https://apacheignite-tools.readme.io/docs/multi-cluster-support" target="_blank">More info</a>')

http://git-wip-us.apache.org/repos/asf/ignite/blob/1367bc98/modules/web-console/frontend/app/components/cluster-select/cluster-select.scss
----------------------------------------------------------------------
diff --git a/modules/web-console/frontend/app/components/cluster-select/cluster-select.scss b/modules/web-console/frontend/app/components/cluster-select/cluster-select.scss
deleted file mode 100644
index 189ef50..0000000
--- a/modules/web-console/frontend/app/components/cluster-select/cluster-select.scss
+++ /dev/null
@@ -1,30 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You 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.
- */
-
-ignite-cluster-select {
-    @import "./../../../public/stylesheets/variables.scss";
-
-    display: flex;
-    flex-direction: row;
-    align-items: center;
-
-    .icon-help {
-        margin-left: 4px;
-
-        color: $text-color;
-    }
-}

http://git-wip-us.apache.org/repos/asf/ignite/blob/1367bc98/modules/web-console/frontend/app/components/cluster-select/index.js
----------------------------------------------------------------------
diff --git a/modules/web-console/frontend/app/components/cluster-select/index.js b/modules/web-console/frontend/app/components/cluster-select/index.js
deleted file mode 100644
index 607b0db..0000000
--- a/modules/web-console/frontend/app/components/cluster-select/index.js
+++ /dev/null
@@ -1,29 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License.  You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import template from './cluster-select.pug';
-import './cluster-select.scss';
-import controller from './cluster-select.controller';
-
-export default [() => {
-    return {
-        restrict: 'E',
-        template,
-        controller,
-        controllerAs: 'ctrl'
-    };
-}];

http://git-wip-us.apache.org/repos/asf/ignite/blob/1367bc98/modules/web-console/frontend/app/components/cluster-selector/component.js
----------------------------------------------------------------------
diff --git a/modules/web-console/frontend/app/components/cluster-selector/component.js b/modules/web-console/frontend/app/components/cluster-selector/component.js
new file mode 100644
index 0000000..f6141d9
--- /dev/null
+++ b/modules/web-console/frontend/app/components/cluster-selector/component.js
@@ -0,0 +1,25 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import template from './template.pug';
+import controller from './controller';
+import './style.scss';
+
+export default {
+    template,
+    controller
+};

http://git-wip-us.apache.org/repos/asf/ignite/blob/1367bc98/modules/web-console/frontend/app/components/cluster-selector/controller.js
----------------------------------------------------------------------
diff --git a/modules/web-console/frontend/app/components/cluster-selector/controller.js b/modules/web-console/frontend/app/components/cluster-selector/controller.js
new file mode 100644
index 0000000..6a86357
--- /dev/null
+++ b/modules/web-console/frontend/app/components/cluster-selector/controller.js
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export default class {
+    static $inject = ['$scope', 'AgentManager', 'IgniteConfirm'];
+
+    constructor($scope, agentMgr, Confirm) {
+        Object.assign(this, { $scope, agentMgr, Confirm });
+
+        this.clusters = [];
+        this.isDemo = agentMgr.isDemoMode();
+    }
+
+    $onInit() {
+        this.clusters$ = this.agentMgr.connectionSbj
+            .do(({ cluster, clusters }) => {
+                this.cluster = cluster;
+                this.clusters = clusters;
+            })
+            .subscribe(() => {});
+    }
+
+    $onDestroy() {
+        this.clusters$.unsubscribe();
+    }
+
+    change() {
+        this.agentMgr.switchCluster(this.cluster);
+    }
+
+    toggle($event) {
+        $event.preventDefault();
+
+        const toggleClusterState = () => {
+            this.inProgress = true;
+
+            return this.agentMgr.toggleClusterState()
+                .finally(() => this.inProgress = false);
+        };
+
+        if (this.cluster.active) {
+            return this.Confirm.confirm('Are you sure you want to deactivate cluster?')
+                .then(() => toggleClusterState());
+        }
+
+        return toggleClusterState();
+    }
+}

http://git-wip-us.apache.org/repos/asf/ignite/blob/1367bc98/modules/web-console/frontend/app/components/cluster-selector/index.js
----------------------------------------------------------------------
diff --git a/modules/web-console/frontend/app/components/cluster-selector/index.js b/modules/web-console/frontend/app/components/cluster-selector/index.js
new file mode 100644
index 0000000..2bdbe44
--- /dev/null
+++ b/modules/web-console/frontend/app/components/cluster-selector/index.js
@@ -0,0 +1,23 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import angular from 'angular';
+import component from './component';
+
+export default angular
+    .module('ignite-console.cluster-selector', [])
+    .component('clusterSelector', component);

http://git-wip-us.apache.org/repos/asf/ignite/blob/1367bc98/modules/web-console/frontend/app/components/cluster-selector/style.scss
----------------------------------------------------------------------
diff --git a/modules/web-console/frontend/app/components/cluster-selector/style.scss b/modules/web-console/frontend/app/components/cluster-selector/style.scss
new file mode 100644
index 0000000..966be99
--- /dev/null
+++ b/modules/web-console/frontend/app/components/cluster-selector/style.scss
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+cluster-selector {
+    @import "./../../../public/stylesheets/variables.scss";
+
+    position: relative;
+    top: 2px;
+
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+
+	& > .btn-ignite {
+        border-radius: 9px;
+        min-height: 0;
+        font-size: 12px;
+        font-weight: bold;
+        line-height: 17px;
+        padding-top: 0;
+        padding-bottom: 0;
+
+        button {
+            font-weight: normal;
+            margin: 0 !important;
+        }
+    }
+
+    .cluster-selector--state {
+        width: 85px;
+    }
+
+    div {
+        margin: 0 10px 0 20px;
+        font-family: Roboto;
+        font-size: 12px;
+    }
+
+    div:last-child {
+        margin-left: 10px;
+        color: #EE2B27;
+    }
+
+    [ignite-icon='info'] {
+        margin-left: 7px;
+        color: $ignite-brand-success;
+    }
+
+    .bs-select-menu {
+        color: $text-color;
+    }
+}

http://git-wip-us.apache.org/repos/asf/ignite/blob/1367bc98/modules/web-console/frontend/app/components/cluster-selector/template.pug
----------------------------------------------------------------------
diff --git a/modules/web-console/frontend/app/components/cluster-selector/template.pug b/modules/web-console/frontend/app/components/cluster-selector/template.pug
new file mode 100644
index 0000000..c97a698
--- /dev/null
+++ b/modules/web-console/frontend/app/components/cluster-selector/template.pug
@@ -0,0 +1,75 @@
+//-
+    Licensed to the Apache Software Foundation (ASF) under one or more
+    contributor license agreements.  See the NOTICE file distributed with
+    this work for additional information regarding copyright ownership.
+    The ASF licenses this file to You 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.
+
+include /app/helpers/jade/mixins
+
+button.btn-ignite.btn-ignite--success(
+    data-ng-if='$ctrl.isDemo'
+)
+    | Demo cluster
+
+button.btn-ignite.btn-ignite--primary(
+    data-ng-if='!$ctrl.isDemo && $ctrl.clusters.length == 0'
+)
+    | No clusters available
+
+button.btn-ignite.btn-ignite--primary(
+    data-ng-if='!$ctrl.isDemo && $ctrl.clusters.length == 1'
+)
+    | {{ $ctrl.cluster.name }}
+
+div.btn-ignite.btn-ignite--primary(
+    data-ng-if='!$ctrl.isDemo && $ctrl.clusters.length > 1'
+
+    data-ng-model='$ctrl.cluster'
+
+    bs-select=''
+    bs-options='item as item.name for item in $ctrl.clusters'
+    data-trigger='hover focus'
+    data-container='self'
+
+    data-ng-change='$ctrl.change()'
+
+    protect-from-bs-select-render
+)
+    span(ng-if='!$ctrl.cluster') No clusters available
+    span(ng-if='$ctrl.cluster') {{ $ctrl.cluster.name }}
+        span.icon-right.fa.fa-caret-down
+
+svg(
+    ng-if='!$ctrl.isDemo'
+    ignite-icon='info'
+    bs-tooltip=''
+    data-title='Multi-Cluster Support<br/>\
+            <a href="https://apacheignite-tools.readme.io/docs/multi-cluster-support" target="_blank">More info</a>'
+    data-placement='bottom'
+)
+
+.cluster-selector--state(ng-if='!$ctrl.isDemo && $ctrl.cluster')
+    | Cluster {{ $ctrl.cluster.active ? 'active' : 'inactive' }}
+
++switcher()(
+    ng-if='!$ctrl.isDemo && $ctrl.cluster'
+    ng-click='$ctrl.toggle($event)'
+    ng-checked='$ctrl.cluster.active'
+    ng-disabled='$ctrl.inProgress'
+
+    tip='Toggle cluster active state'
+    is-in-progress='{{ $ctrl.inProgress }}'
+)
+
+div(ng-if='$ctrl.inProgress')
+    | {{ !$ctrl.cluster.active ? 'Activating...' : 'Deactivating...' }}

http://git-wip-us.apache.org/repos/asf/ignite/blob/1367bc98/modules/web-console/frontend/app/components/list-editable/controller.js
----------------------------------------------------------------------
diff --git a/modules/web-console/frontend/app/components/list-editable/controller.js b/modules/web-console/frontend/app/components/list-editable/controller.js
index bc864ce..7757d96 100644
--- a/modules/web-console/frontend/app/components/list-editable/controller.js
+++ b/modules/web-console/frontend/app/components/list-editable/controller.js
@@ -21,7 +21,7 @@ export default class {
     static $inject = ['$animate', '$element', '$transclude'];
 
     constructor($animate, $element, $transclude) {
-        $animate.enabled(false, $element);
+        $animate.enabled($element, false);
 
         this.hasItemView = $transclude.isSlotFilled('itemView');
 

http://git-wip-us.apache.org/repos/asf/ignite/blob/1367bc98/modules/web-console/frontend/app/components/page-queries/Notebook.data.js
----------------------------------------------------------------------
diff --git a/modules/web-console/frontend/app/components/page-queries/Notebook.data.js b/modules/web-console/frontend/app/components/page-queries/Notebook.data.js
new file mode 100644
index 0000000..3f98bed
--- /dev/null
+++ b/modules/web-console/frontend/app/components/page-queries/Notebook.data.js
@@ -0,0 +1,168 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+const DEMO_NOTEBOOK = {
+    name: 'SQL demo',
+    paragraphs: [
+        {
+            name: 'Query with refresh rate',
+            cacheName: 'CarCache',
+            pageSize: 100,
+            limit: 0,
+            query: [
+                'SELECT count(*)',
+                'FROM "CarCache".Car'
+            ].join('\n'),
+            result: 'bar',
+            timeLineSpan: '1',
+            rate: {
+                value: 3,
+                unit: 1000,
+                installed: true
+            }
+        },
+        {
+            name: 'Simple query',
+            cacheName: 'CarCache',
+            pageSize: 100,
+            limit: 0,
+            query: 'SELECT * FROM "CarCache".Car',
+            result: 'table',
+            timeLineSpan: '1',
+            rate: {
+                value: 30,
+                unit: 1000,
+                installed: false
+            }
+        },
+        {
+            name: 'Query with aggregates',
+            cacheName: 'ParkingCache',
+            pageSize: 100,
+            limit: 0,
+            query: [
+                'SELECT p.name, count(*) AS cnt',
+                'FROM "ParkingCache".Parking p',
+                'INNER JOIN "CarCache".Car c',
+                '  ON (p.id) = (c.parkingId)',
+                'GROUP BY P.NAME'
+            ].join('\n'),
+            result: 'table',
+            timeLineSpan: '1',
+            rate: {
+                value: 30,
+                unit: 1000,
+                installed: false
+            }
+        }
+    ],
+    expandedParagraphs: [0, 1, 2]
+};
+
+export default class NotebookData {
+    static $inject = ['$rootScope', '$http', '$q'];
+
+    constructor($root, $http, $q) {
+        this.demo = $root.IgniteDemoMode;
+
+        this.initLatch = null;
+        this.notebooks = null;
+
+        this.$http = $http;
+        this.$q = $q;
+    }
+
+    load() {
+        if (this.demo) {
+            if (this.initLatch)
+                return this.initLatch;
+
+            return this.initLatch = this.$q.when(this.notebooks = [DEMO_NOTEBOOK]);
+        }
+
+        return this.initLatch = this.$http.get('/api/v1/notebooks')
+            .then(({data}) => this.notebooks = data)
+            .catch(({data}) => Promise.reject(data));
+    }
+
+    read() {
+        if (this.initLatch)
+            return this.initLatch;
+
+        return this.load();
+    }
+
+    find(_id) {
+        return this.read()
+            .then(() => {
+                const notebook = this.demo ? this.notebooks[0] : _.find(this.notebooks, {_id});
+
+                if (_.isNil(notebook))
+                    return this.$q.reject('Failed to load notebook.');
+
+                return notebook;
+            });
+    }
+
+    findIndex(notebook) {
+        return this.read()
+            .then(() => _.findIndex(this.notebooks, {_id: notebook._id}));
+    }
+
+    save(notebook) {
+        if (this.demo)
+            return this.$q.when(DEMO_NOTEBOOK);
+
+        return this.$http.post('/api/v1/notebooks/save', notebook)
+            .then(({data}) => {
+                const idx = _.findIndex(this.notebooks, {_id: data._id});
+
+                if (idx >= 0)
+                    this.notebooks[idx] = data;
+                else
+                    this.notebooks.push(data);
+
+                return data;
+            })
+            .catch(({data}) => Promise.reject(data));
+    }
+
+    remove(notebook) {
+        if (this.demo)
+            return this.$q.reject(`Removing "${notebook.name}" notebook is not supported.`);
+
+        const key = {_id: notebook._id};
+
+        return this.$http.post('/api/v1/notebooks/remove', key)
+            .then(() => {
+                const idx = _.findIndex(this.notebooks, key);
+
+                if (idx >= 0) {
+                    this.notebooks.splice(idx, 1);
+
+                    if (idx < this.notebooks.length)
+                        return this.notebooks[idx];
+                }
+
+                if (this.notebooks.length > 0)
+                    return this.notebooks[this.notebooks.length - 1];
+
+                return null;
+            })
+            .catch(({data}) => Promise.reject(data));
+    }
+}

http://git-wip-us.apache.org/repos/asf/ignite/blob/1367bc98/modules/web-console/frontend/app/components/page-queries/Notebook.service.js
----------------------------------------------------------------------
diff --git a/modules/web-console/frontend/app/components/page-queries/Notebook.service.js b/modules/web-console/frontend/app/components/page-queries/Notebook.service.js
new file mode 100644
index 0000000..b0bb64f
--- /dev/null
+++ b/modules/web-console/frontend/app/components/page-queries/Notebook.service.js
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export default class Notebook {
+    static $inject = ['$state', 'IgniteConfirm', 'IgniteMessages', 'IgniteNotebookData'];
+
+    /**
+     * @param $state
+     * @param confirmModal
+     * @param Messages
+     * @param {NotebookData} NotebookData
+     */
+    constructor($state, confirmModal, Messages, NotebookData) {
+        this.$state = $state;
+        this.confirmModal = confirmModal;
+        this.Messages = Messages;
+        this.NotebookData = NotebookData;
+    }
+
+    read() {
+        return this.NotebookData.read();
+    }
+
+    create(name) {
+        return this.NotebookData.save({name});
+    }
+
+    save(notebook) {
+        return this.NotebookData.save(notebook);
+    }
+
+    find(_id) {
+        return this.NotebookData.find(_id);
+    }
+
+    _openNotebook(idx) {
+        return this.NotebookData.read()
+            .then((notebooks) => {
+                const nextNotebook = notebooks.length > idx ? notebooks[idx] : _.last(notebooks);
+
+                if (nextNotebook)
+                    this.$state.go('base.sql.notebook', {noteId: nextNotebook._id});
+                else
+                    this.$state.go('base.configuration.tabs.advanced.clusters');
+            });
+    }
+
+    remove(notebook) {
+        return this.confirmModal.confirm(`Are you sure you want to remove notebook: "${notebook.name}"?`)
+            .then(() => this.NotebookData.findIndex(notebook))
+            .then((idx) => {
+                this.NotebookData.remove(notebook)
+                    .then(() => {
+                        if (this.$state.includes('base.sql.notebook') && this.$state.params.noteId === notebook._id)
+                            return this._openNotebook(idx);
+                    })
+                    .catch(this.Messages.showError);
+            });
+    }
+}