You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@ignite.apache.org by vo...@apache.org on 2016/09/13 09:53:34 UTC

[42/69] [abbrv] ignite git commit: Web Console beta-3.

http://git-wip-us.apache.org/repos/asf/ignite/blob/6af6560a/modules/web-console/backend/services/clusters.js
----------------------------------------------------------------------
diff --git a/modules/web-console/backend/services/clusters.js b/modules/web-console/backend/services/clusters.js
new file mode 100644
index 0000000..6c2722b
--- /dev/null
+++ b/modules/web-console/backend/services/clusters.js
@@ -0,0 +1,141 @@
+/*
+ * 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.
+ */
+
+'use strict';
+
+// Fire me up!
+
+module.exports = {
+    implements: 'services/clusters',
+    inject: ['require(lodash)', 'mongo', 'services/spaces', 'errors']
+};
+
+/**
+ * @param _
+ * @param mongo
+ * @param {SpacesService} spacesService
+ * @param errors
+ * @returns {ClustersService}
+ */
+module.exports.factory = (_, mongo, spacesService, errors) => {
+    /**
+     * Convert remove status operation to own presentation.
+     * @param {RemoveResult} result - The results of remove operation.
+     */
+    const convertRemoveStatus = ({result}) => ({rowsAffected: result.n});
+
+    /**
+     * Update existing cluster
+     * @param {Object} cluster - The cluster for updating
+     * @returns {Promise.<mongo.ObjectId>} that resolves cluster id
+     */
+    const update = (cluster) => {
+        const clusterId = cluster._id;
+
+        return mongo.Cluster.update({_id: clusterId}, cluster, {upsert: true}).exec()
+            .then(() => mongo.Cache.update({_id: {$in: cluster.caches}}, {$addToSet: {clusters: clusterId}}, {multi: true}).exec())
+            .then(() => mongo.Cache.update({_id: {$nin: cluster.caches}}, {$pull: {clusters: clusterId}}, {multi: true}).exec())
+            .then(() => mongo.Igfs.update({_id: {$in: cluster.igfss}}, {$addToSet: {clusters: clusterId}}, {multi: true}).exec())
+            .then(() => mongo.Igfs.update({_id: {$nin: cluster.igfss}}, {$pull: {clusters: clusterId}}, {multi: true}).exec())
+            .then(() => cluster)
+            .catch((err) => {
+                if (err.code === mongo.errCodes.DUPLICATE_KEY_UPDATE_ERROR || err.code === mongo.errCodes.DUPLICATE_KEY_ERROR)
+                    throw new errors.DuplicateKeyException('Cluster with name: "' + cluster.name + '" already exist.');
+            });
+    };
+
+    /**
+     * Create new cluster.
+     * @param {Object} cluster - The cluster for creation.
+     * @returns {Promise.<mongo.ObjectId>} that resolves cluster id.
+     */
+    const create = (cluster) => {
+        return mongo.Cluster.create(cluster)
+            .then((savedCluster) =>
+                mongo.Cache.update({_id: {$in: savedCluster.caches}}, {$addToSet: {clusters: savedCluster._id}}, {multi: true}).exec()
+                    .then(() => mongo.Igfs.update({_id: {$in: savedCluster.igfss}}, {$addToSet: {clusters: savedCluster._id}}, {multi: true}).exec())
+                    .then(() => savedCluster)
+            )
+            .catch((err) => {
+                if (err.code === mongo.errCodes.DUPLICATE_KEY_ERROR)
+                    throw new errors.DuplicateKeyException('Cluster with name: "' + cluster.name + '" already exist.');
+            });
+    };
+
+    /**
+     * Remove all caches by space ids.
+     * @param {Number[]} spaceIds - The space ids for cache deletion.
+     * @returns {Promise.<RemoveResult>} - that resolves results of remove operation.
+     */
+    const removeAllBySpaces = (spaceIds) => {
+        return mongo.Cache.update({space: {$in: spaceIds}}, {clusters: []}, {multi: true}).exec()
+            .then(() => mongo.Igfs.update({space: {$in: spaceIds}}, {clusters: []}, {multi: true}).exec())
+            .then(() => mongo.Cluster.remove({space: {$in: spaceIds}}).exec());
+    };
+
+    class ClustersService {
+        /**
+         * Create or update cluster.
+         * @param {Object} cluster - The cluster
+         * @returns {Promise.<mongo.ObjectId>} that resolves cluster id of merge operation.
+         */
+        static merge(cluster) {
+            if (cluster._id)
+                return update(cluster);
+
+            return create(cluster);
+        }
+
+        /**
+         * Get clusters and linked objects by space.
+         * @param {mongo.ObjectId|String} spaceIds - The spaces id that own cluster.
+         * @returns {Promise.<[mongo.Cache[], mongo.Cluster[], mongo.DomainModel[], mongo.Space[]]>} - contains requested caches and array of linked objects: clusters, domains, spaces.
+         */
+        static listBySpaces(spaceIds) {
+            return mongo.Cluster.find({space: {$in: spaceIds}}).sort('name').lean().exec();
+        }
+
+        /**
+         * Remove cluster.
+         * @param {mongo.ObjectId|String} clusterId - The cluster id for remove.
+         * @returns {Promise.<{rowsAffected}>} - The number of affected rows.
+         */
+        static remove(clusterId) {
+            if (_.isNil(clusterId))
+                return Promise.reject(new errors.IllegalArgumentException('Cluster id can not be undefined or null'));
+
+            return mongo.Cache.update({clusters: {$in: [clusterId]}}, {$pull: {clusters: clusterId}}, {multi: true}).exec()
+                .then(() => mongo.Igfs.update({clusters: {$in: [clusterId]}}, {$pull: {clusters: clusterId}}, {multi: true}).exec())
+                .then(() => mongo.Cluster.remove({_id: clusterId}).exec())
+                .then(convertRemoveStatus);
+        }
+
+        /**
+         * Remove all clusters by user.
+         * @param {mongo.ObjectId|String} userId - The user id that own cluster.
+         * @param {Boolean} demo - The flag indicates that need lookup in demo space.
+         * @returns {Promise.<{rowsAffected}>} - The number of affected rows.
+         */
+        static removeAll(userId, demo) {
+            return spacesService.spaceIds(userId, demo)
+                .then(removeAllBySpaces)
+                .then(convertRemoveStatus);
+        }
+    }
+
+    return ClustersService;
+};

http://git-wip-us.apache.org/repos/asf/ignite/blob/6af6560a/modules/web-console/backend/services/configurations.js
----------------------------------------------------------------------
diff --git a/modules/web-console/backend/services/configurations.js b/modules/web-console/backend/services/configurations.js
new file mode 100644
index 0000000..7eef8a2
--- /dev/null
+++ b/modules/web-console/backend/services/configurations.js
@@ -0,0 +1,59 @@
+/*
+ * 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.
+ */
+
+'use strict';
+
+// Fire me up!
+
+module.exports = {
+    implements: 'services/configurations',
+    inject: ['require(lodash)', 'mongo', 'services/spaces', 'services/clusters', 'services/caches', 'services/domains', 'services/igfss']
+};
+
+/**
+ * @param _
+ * @param mongo
+ * @param {SpacesService} spacesService
+ * @param {ClustersService} clustersService
+ * @param {CachesService} cachesService
+ * @param {DomainsService} domainsService
+ * @param {IgfssService} igfssService
+ * @returns {ConfigurationsService}
+ */
+module.exports.factory = (_, mongo, spacesService, clustersService, cachesService, domainsService, igfssService) => {
+    class ConfigurationsService {
+        static list(userId, demo) {
+            let spaces;
+
+            return spacesService.spaces(userId, demo)
+                .then((_spaces) => {
+                    spaces = _spaces;
+
+                    return spaces.map((space) => space._id);
+                })
+                .then((spaceIds) => Promise.all([
+                    clustersService.listBySpaces(spaceIds),
+                    domainsService.listBySpaces(spaceIds),
+                    cachesService.listBySpaces(spaceIds),
+                    igfssService.listBySpaces(spaceIds)
+                ]))
+                .then(([clusters, domains, caches, igfss]) => ({clusters, domains, caches, igfss, spaces}));
+        }
+    }
+
+    return ConfigurationsService;
+};

http://git-wip-us.apache.org/repos/asf/ignite/blob/6af6560a/modules/web-console/backend/services/domains.js
----------------------------------------------------------------------
diff --git a/modules/web-console/backend/services/domains.js b/modules/web-console/backend/services/domains.js
new file mode 100644
index 0000000..3e4e129
--- /dev/null
+++ b/modules/web-console/backend/services/domains.js
@@ -0,0 +1,187 @@
+/*
+ * 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.
+ */
+
+'use strict';
+
+// Fire me up!
+
+module.exports = {
+    implements: 'services/domains',
+    inject: ['require(lodash)', 'mongo', 'services/spaces', 'services/caches', 'errors']
+};
+
+/**
+ * @param _
+ * @param mongo
+ * @param {SpacesService} spacesService
+ * @param {CachesService} cachesService
+ * @param errors
+ * @returns {DomainsService}
+ */
+module.exports.factory = (_, mongo, spacesService, cachesService, errors) => {
+    /**
+     * Convert remove status operation to own presentation.
+     * @param {RemoveResult} result - The results of remove operation.
+     */
+    const convertRemoveStatus = ({result}) => ({rowsAffected: result.n});
+
+    const _updateCacheStore = (cacheStoreChanges) =>
+        Promise.all(_.map(cacheStoreChanges, (change) => mongo.Cache.update({_id: {$eq: change.cacheId}}, change.change, {}).exec()));
+
+    /**
+     * Update existing domain
+     * @param {Object} domain - The domain for updating
+     * @param savedDomains List of saved domains.
+     * @returns {Promise.<mongo.ObjectId>} that resolves domain id
+     */
+    const update = (domain, savedDomains) => {
+        const domainId = domain._id;
+
+        return mongo.DomainModel.update({_id: domainId}, domain, {upsert: true}).exec()
+            .then(() => mongo.Cache.update({_id: {$in: domain.caches}}, {$addToSet: {domains: domainId}}, {multi: true}).exec())
+            .then(() => mongo.Cache.update({_id: {$nin: domain.caches}}, {$pull: {domains: domainId}}, {multi: true}).exec())
+            .then(() => {
+                savedDomains.push(domain);
+
+                return _updateCacheStore(domain.cacheStoreChanges);
+            })
+            .catch((err) => {
+                if (err.code === mongo.errCodes.DUPLICATE_KEY_UPDATE_ERROR || err.code === mongo.errCodes.DUPLICATE_KEY_ERROR)
+                    throw new errors.DuplicateKeyException('Domain model with value type: "' + domain.valueType + '" already exist.');
+            });
+    };
+
+    /**
+     * Create new domain.
+     * @param {Object} domain - The domain for creation.
+     * @param savedDomains List of saved domains.
+     * @returns {Promise.<mongo.ObjectId>} that resolves cluster id.
+     */
+    const create = (domain, savedDomains) => {
+        return mongo.DomainModel.create(domain)
+            .then((createdDomain) => {
+                savedDomains.push(createdDomain);
+
+                return mongo.Cache.update({_id: {$in: domain.caches}}, {$addToSet: {domains: createdDomain._id}}, {multi: true}).exec()
+                    .then(() => _updateCacheStore(domain.cacheStoreChanges));
+            })
+            .catch((err) => {
+                if (err.code === mongo.errCodes.DUPLICATE_KEY_ERROR)
+                    throw new errors.DuplicateKeyException('Domain model with value type: "' + domain.valueType + '" already exist.');
+            });
+    };
+
+    const _saveDomainModel = (domain, savedDomains) => {
+        const domainId = domain._id;
+
+        if (domainId)
+            return update(domain, savedDomains);
+
+        return create(domain, savedDomains);
+    };
+
+    const _save = (domains) => {
+        if (_.isEmpty(domains))
+            throw new errors.IllegalArgumentException('Nothing to save!');
+
+        const savedDomains = [];
+        const generatedCaches = [];
+
+        const promises = _.map(domains, (domain) => {
+            if (domain.newCache) {
+                return mongo.Cache.findOne({space: domain.space, name: domain.newCache.name}).exec()
+                    .then((cache) => {
+                        if (cache)
+                            return Promise.resolve(cache);
+
+                        // If cache not found, then create it and associate with domain model.
+                        const newCache = domain.newCache;
+                        newCache.space = domain.space;
+
+                        return cachesService.merge(newCache);
+                    })
+                    .then((cache) => {
+                        domain.caches = [cache._id];
+
+                        return _saveDomainModel(domain, savedDomains);
+                    });
+            }
+
+            return _saveDomainModel(domain, savedDomains);
+        });
+
+        return Promise.all(promises).then(() => ({savedDomains, generatedCaches}));
+    };
+
+    /**
+     * Remove all caches by space ids.
+     * @param {Array.<Number>} spaceIds - The space ids for cache deletion.
+     * @returns {Promise.<RemoveResult>} - that resolves results of remove operation.
+     */
+    const removeAllBySpaces = (spaceIds) => {
+        return mongo.Cache.update({space: {$in: spaceIds}}, {domains: []}, {multi: true}).exec()
+            .then(() => mongo.DomainModel.remove({space: {$in: spaceIds}}).exec());
+    };
+
+    class DomainsService {
+        /**
+         * Batch merging domains.
+         * @param {Array.<mongo.DomainModel>} domains
+         */
+        static batchMerge(domains) {
+            return _save(domains);
+        }
+
+        /**
+         * Get domain and linked objects by space.
+         * @param {mongo.ObjectId|String} spaceIds - The space id that own domain.
+         * @returns {Promise.<[mongo.Cache[], mongo.Cluster[], mongo.DomainModel[], mongo.Space[]]>}
+         *      contains requested domains and array of linked objects: caches, spaces.
+         */
+        static listBySpaces(spaceIds) {
+            return mongo.DomainModel.find({space: {$in: spaceIds}}).sort('valueType').lean().exec();
+        }
+
+        /**
+         * Remove domain.
+         * @param {mongo.ObjectId|String} domainId - The domain id for remove.
+         * @returns {Promise.<{rowsAffected}>} - The number of affected rows.
+         */
+        static remove(domainId) {
+            if (_.isNil(domainId))
+                return Promise.reject(new errors.IllegalArgumentException('Domain id can not be undefined or null'));
+
+            return mongo.Cache.update({domains: {$in: [domainId]}}, {$pull: {domains: domainId}}, {multi: true}).exec()
+                .then(() => mongo.DomainModel.remove({_id: domainId}).exec())
+                .then(convertRemoveStatus);
+        }
+
+        /**
+         * Remove all domains by user.
+         * @param {mongo.ObjectId|String} userId - The user id that own domain.
+         * @param {Boolean} demo - The flag indicates that need lookup in demo space.
+         * @returns {Promise.<{rowsAffected}>} - The number of affected rows.
+         */
+        static removeAll(userId, demo) {
+            return spacesService.spaceIds(userId, demo)
+                .then(removeAllBySpaces)
+                .then(convertRemoveStatus);
+        }
+    }
+
+    return DomainsService;
+};

http://git-wip-us.apache.org/repos/asf/ignite/blob/6af6560a/modules/web-console/backend/services/igfss.js
----------------------------------------------------------------------
diff --git a/modules/web-console/backend/services/igfss.js b/modules/web-console/backend/services/igfss.js
new file mode 100644
index 0000000..20f0121
--- /dev/null
+++ b/modules/web-console/backend/services/igfss.js
@@ -0,0 +1,136 @@
+/*
+ * 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.
+ */
+
+'use strict';
+
+// Fire me up!
+
+module.exports = {
+    implements: 'services/igfss',
+    inject: ['require(lodash)', 'mongo', 'services/spaces', 'errors']
+};
+
+/**
+ * @param _
+ * @param mongo
+ * @param {SpacesService} spacesService
+ * @param errors
+ * @returns {IgfssService}
+ */
+module.exports.factory = (_, mongo, spacesService, errors) => {
+    /**
+     * Convert remove status operation to own presentation.
+     * @param {RemoveResult} result - The results of remove operation.
+     */
+    const convertRemoveStatus = ({result}) => ({rowsAffected: result.n});
+
+    /**
+     * Update existing IGFS
+     * @param {Object} igfs - The IGFS for updating
+     * @returns {Promise.<mongo.ObjectId>} that resolves IGFS id
+     */
+    const update = (igfs) => {
+        const igfsId = igfs._id;
+
+        return mongo.Igfs.update({_id: igfsId}, igfs, {upsert: true}).exec()
+            .then(() => mongo.Cluster.update({_id: {$in: igfs.clusters}}, {$addToSet: {igfss: igfsId}}, {multi: true}).exec())
+            .then(() => mongo.Cluster.update({_id: {$nin: igfs.clusters}}, {$pull: {igfss: igfsId}}, {multi: true}).exec())
+            .then(() => igfs)
+            .catch((err) => {
+                if (err.code === mongo.errCodes.DUPLICATE_KEY_UPDATE_ERROR || err.code === mongo.errCodes.DUPLICATE_KEY_ERROR)
+                    throw new errors.DuplicateKeyException('IGFS with name: "' + igfs.name + '" already exist.');
+            });
+    };
+
+    /**
+     * Create new IGFS.
+     * @param {Object} igfs - The IGFS for creation.
+     * @returns {Promise.<mongo.ObjectId>} that resolves IGFS id.
+     */
+    const create = (igfs) => {
+        return mongo.Igfs.create(igfs)
+            .then((savedIgfs) =>
+                mongo.Cluster.update({_id: {$in: savedIgfs.clusters}}, {$addToSet: {igfss: savedIgfs._id}}, {multi: true}).exec()
+                    .then(() => savedIgfs)
+            )
+            .catch((err) => {
+                if (err.code === mongo.errCodes.DUPLICATE_KEY_ERROR)
+                    throw new errors.DuplicateKeyException('IGFS with name: "' + igfs.name + '" already exist.');
+            });
+    };
+
+    /**
+     * Remove all IGFSs by space ids.
+     * @param {Number[]} spaceIds - The space ids for IGFS deletion.
+     * @returns {Promise.<RemoveResult>} - that resolves results of remove operation.
+     */
+    const removeAllBySpaces = (spaceIds) => {
+        return mongo.Cluster.update({space: {$in: spaceIds}}, {igfss: []}, {multi: true}).exec()
+            .then(() => mongo.Igfs.remove({space: {$in: spaceIds}}).exec());
+    };
+
+    class IgfssService {
+        /**
+         * Create or update IGFS.
+         * @param {Object} igfs - The IGFS
+         * @returns {Promise.<mongo.ObjectId>} that resolves IGFS id of merge operation.
+         */
+        static merge(igfs) {
+            if (igfs._id)
+                return update(igfs);
+
+            return create(igfs);
+        }
+
+        /**
+         * Get IGFS by spaces.
+         * @param {mongo.ObjectId|String} spacesIds - The spaces ids that own IGFSs.
+         * @returns {Promise.<mongo.IGFS[]>} - contains requested IGFSs.
+         */
+        static listBySpaces(spacesIds) {
+            return mongo.Igfs.find({space: {$in: spacesIds}}).sort('name').lean().exec();
+        }
+
+        /**
+         * Remove IGFS.
+         * @param {mongo.ObjectId|String} igfsId - The IGFS id for remove.
+         * @returns {Promise.<{rowsAffected}>} - The number of affected rows.
+         */
+        static remove(igfsId) {
+            if (_.isNil(igfsId))
+                return Promise.reject(new errors.IllegalArgumentException('IGFS id can not be undefined or null'));
+
+            return mongo.Cluster.update({igfss: {$in: [igfsId]}}, {$pull: {igfss: igfsId}}, {multi: true}).exec()
+                .then(() => mongo.Igfs.remove({_id: igfsId}).exec())
+                .then(convertRemoveStatus);
+        }
+
+        /**
+         * Remove all IGFSes by user.
+         * @param {mongo.ObjectId|String} userId - The user id that own IGFS.
+         * @param {Boolean} demo - The flag indicates that need lookup in demo space.
+         * @returns {Promise.<{rowsAffected}>} - The number of affected rows.
+         */
+        static removeAll(userId, demo) {
+            return spacesService.spaceIds(userId, demo)
+                .then(removeAllBySpaces)
+                .then(convertRemoveStatus);
+        }
+    }
+
+    return IgfssService;
+};

http://git-wip-us.apache.org/repos/asf/ignite/blob/6af6560a/modules/web-console/backend/services/mails.js
----------------------------------------------------------------------
diff --git a/modules/web-console/backend/services/mails.js b/modules/web-console/backend/services/mails.js
new file mode 100644
index 0000000..0700985
--- /dev/null
+++ b/modules/web-console/backend/services/mails.js
@@ -0,0 +1,131 @@
+/*
+ * 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.
+ */
+
+'use strict';
+
+// Fire me up!
+
+module.exports = {
+    implements: 'services/mails',
+    inject: ['require(lodash)', 'require(nodemailer)', 'settings']
+};
+
+/**
+ * @param _
+ * @param nodemailer
+ * @param settings
+ * @returns {MailsService}
+ */
+module.exports.factory = (_, nodemailer, settings) => {
+    /**
+     * Send mail to user.
+     *
+     * @param {Account} user
+     * @param {String} subject
+     * @param {String} html
+     * @param {String} sendErr
+     * @throws {Error}
+     * @return {Promise}
+     */
+    const send = (user, subject, html, sendErr) => {
+        return new Promise((resolve, reject) => {
+            const transportConfig = settings.smtp;
+
+            if (_.isEmpty(transportConfig.service) || _.isEmpty(transportConfig.auth.user) || _.isEmpty(transportConfig.auth.pass))
+                throw new Error('Failed to send email. SMTP server is not configured. Please ask webmaster to setup SMTP server!');
+
+            const mailer = nodemailer.createTransport(transportConfig);
+
+            const sign = settings.smtp.sign ? `<br><br>--------------<br>${settings.smtp.sign}<br>` : '';
+
+            const mail = {
+                from: settings.smtp.from,
+                to: settings.smtp.address(`${user.firstName} ${user.lastName}`, user.email),
+                subject,
+                html: html + sign
+            };
+
+            mailer.sendMail(mail, (err) => {
+                if (err)
+                    return reject(sendErr ? new Error(sendErr) : err);
+
+                resolve(user);
+            });
+        });
+    };
+
+    class MailsService {
+        /**
+         * Send email to user for password reset.
+         * @param host
+         * @param user
+         */
+        static emailUserSignUp(host, user) {
+            const resetLink = `${host}/password/reset?token=${user.resetPasswordToken}`;
+
+            return send(user, `Thanks for signing up for ${settings.smtp.greeting}.`,
+                `Hello ${user.firstName} ${user.lastName}!<br><br>` +
+                `You are receiving this email because you have signed up to use <a href="${host}">${settings.smtp.greeting}</a>.<br><br>` +
+                'If you have not done the sign up and do not know what this email is about, please ignore it.<br>' +
+                'You may reset the password by clicking on the following link, or paste this into your browser:<br><br>' +
+                `<a href="${resetLink}">${resetLink}</a>`);
+        }
+
+        /**
+         * Send email to user for password reset.
+         * @param host
+         * @param user
+         */
+        static emailUserResetLink(host, user) {
+            const resetLink = `${host}/password/reset?token=${user.resetPasswordToken}`;
+
+            return send(user, 'Password Reset',
+                `Hello ${user.firstName} ${user.lastName}!<br><br>` +
+                'You are receiving this because you (or someone else) have requested the reset of the password for your account.<br><br>' +
+                'Please click on the following link, or paste this into your browser to complete the process:<br><br>' +
+                `<a href="${resetLink}">${resetLink}</a><br><br>` +
+                'If you did not request this, please ignore this email and your password will remain unchanged.',
+                'Failed to send email with reset link!');
+        }
+
+        /**
+         * Send email to user for password reset.
+         * @param host
+         * @param user
+         */
+        static emailPasswordChanged(host, user) {
+            return send(user, 'Your password has been changed',
+                `Hello ${user.firstName} ${user.lastName}!<br><br>` +
+                `This is a confirmation that the password for your account on <a href="${host}">${settings.smtp.greeting}</a> has just been changed.<br><br>`,
+                'Password was changed, but failed to send confirmation email!');
+        }
+
+        /**
+         * Send email to user when it was deleted.
+         * @param host
+         * @param user
+         */
+        static emailUserDeletion(host, user) {
+            return send(user, 'Your account was removed',
+                `Hello ${user.firstName} ${user.lastName}!<br><br>` +
+                `You are receiving this email because your account for <a href="${host}">${settings.smtp.greeting}</a> was removed.`,
+                'Account was removed, but failed to send email notification to user!');
+        }
+    }
+
+    return MailsService;
+};

http://git-wip-us.apache.org/repos/asf/ignite/blob/6af6560a/modules/web-console/backend/services/notebooks.js
----------------------------------------------------------------------
diff --git a/modules/web-console/backend/services/notebooks.js b/modules/web-console/backend/services/notebooks.js
new file mode 100644
index 0000000..8846d8e
--- /dev/null
+++ b/modules/web-console/backend/services/notebooks.js
@@ -0,0 +1,104 @@
+/*
+ * 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.
+ */
+
+'use strict';
+
+// Fire me up!
+
+module.exports = {
+    implements: 'services/notebooks',
+    inject: ['require(lodash)', 'mongo', 'services/spaces', 'errors']
+};
+
+/**
+ * @param _
+ * @param mongo
+ * @param {SpacesService} spacesService
+ * @param errors
+ * @returns {NotebooksService}
+ */
+module.exports.factory = (_, mongo, spacesService, errors) => {
+    /**
+     * Convert remove status operation to own presentation.
+     * @param {RemoveResult} result - The results of remove operation.
+     */
+    const convertRemoveStatus = ({result}) => ({rowsAffected: result.n});
+
+    /**
+     * Update existing notebook
+     * @param {Object} notebook - The notebook for updating
+     * @returns {Promise.<mongo.ObjectId>} that resolves cache id
+     */
+    const update = (notebook) => {
+        return mongo.Notebook.findOneAndUpdate({_id: notebook._id}, notebook, {new: true, upsert: true}).exec()
+            .catch((err) => {
+                if (err.code === mongo.errCodes.DUPLICATE_KEY_UPDATE_ERROR || err.code === mongo.errCodes.DUPLICATE_KEY_ERROR)
+                    throw new errors.DuplicateKeyException('Notebook with name: "' + notebook.name + '" already exist.');
+            });
+    };
+
+    /**
+     * Create new notebook.
+     * @param {Object} notebook - The notebook for creation.
+     * @returns {Promise.<mongo.ObjectId>} that resolves cache id.
+     */
+    const create = (notebook) => {
+        return mongo.Notebook.create(notebook)
+            .catch((err) => {
+                if (err.code === mongo.errCodes.DUPLICATE_KEY_ERROR)
+                    throw new errors.DuplicateKeyException('Notebook with name: "' + notebook.name + '" already exist.');
+            });
+    };
+
+    class NotebooksService {
+        /**
+         * Create or update Notebook.
+         * @param {Object} notebook - The Notebook
+         * @returns {Promise.<mongo.ObjectId>} that resolves Notebook id of merge operation.
+         */
+        static merge(notebook) {
+            if (notebook._id)
+                return update(notebook);
+
+            return create(notebook);
+        }
+
+        /**
+         * Get caches by spaces.
+         * @param {mongo.ObjectId|String} spaceIds - The spaces ids that own caches.
+         * @returns {Promise.<mongo.Cache[]>} - contains requested caches.
+         */
+        static listBySpaces(spaceIds) {
+            return mongo.Notebook.find({space: {$in: spaceIds}}).sort('name').lean().exec();
+        }
+
+        /**
+         * Remove Notebook.
+         * @param {mongo.ObjectId|String} notebookId - The Notebook id for remove.
+         * @returns {Promise.<{rowsAffected}>} - The number of affected rows.
+         */
+        static remove(notebookId) {
+            if (_.isNil(notebookId))
+                return Promise.reject(new errors.IllegalArgumentException('Notebook id can not be undefined or null'));
+
+            return mongo.Notebook.remove({_id: notebookId}).exec()
+                .then(convertRemoveStatus);
+        }
+    }
+
+    return NotebooksService;
+};

http://git-wip-us.apache.org/repos/asf/ignite/blob/6af6560a/modules/web-console/backend/services/sessions.js
----------------------------------------------------------------------
diff --git a/modules/web-console/backend/services/sessions.js b/modules/web-console/backend/services/sessions.js
new file mode 100644
index 0000000..4fa95a3
--- /dev/null
+++ b/modules/web-console/backend/services/sessions.js
@@ -0,0 +1,63 @@
+/*
+ * 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.
+ */
+
+'use strict';
+
+// Fire me up!
+
+module.exports = {
+    implements: 'services/sessions',
+    inject: ['require(lodash)', 'mongo', 'errors']
+};
+
+/**
+ * @param _
+ * @param mongo
+ * @param errors
+ * @returns {SessionsService}
+ */
+module.exports.factory = (_, mongo, errors) => {
+    class SessionsService {
+        /**
+         * Become user.
+         * @param {Session} session - current session of user.
+         * @param {mongo.ObjectId|String} viewedUserId - id of user to become.
+         */
+        static become(session, viewedUserId) {
+            return mongo.Account.findById(viewedUserId).exec()
+                .then((viewedUser) => {
+                    if (!session.req.user.admin)
+                        throw new errors.IllegalAccessError('Became this user is not permitted. Only administrators can perform this actions.');
+
+                    session.viewedUser = viewedUser;
+                });
+        }
+
+        /**
+         * Revert to your identity.
+         */
+        static revert(session) {
+            return new Promise((resolve) => {
+                delete session.viewedUser;
+
+                resolve();
+            });
+        }
+    }
+
+    return SessionsService;
+};

http://git-wip-us.apache.org/repos/asf/ignite/blob/6af6560a/modules/web-console/backend/services/spaces.js
----------------------------------------------------------------------
diff --git a/modules/web-console/backend/services/spaces.js b/modules/web-console/backend/services/spaces.js
new file mode 100644
index 0000000..863d57c
--- /dev/null
+++ b/modules/web-console/backend/services/spaces.js
@@ -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.
+ */
+
+'use strict';
+
+// Fire me up!
+
+module.exports = {
+    implements: 'services/spaces',
+    inject: ['mongo', 'errors']
+};
+
+/**
+ * @param mongo
+ * @param errors
+ * @returns {SpacesService}
+ */
+module.exports.factory = (mongo, errors) => {
+    class SpacesService {
+        /**
+         * Query for user spaces.
+         *
+         * @param {mongo.ObjectId|String} userId User ID.
+         * @param {Boolean} demo Is need use demo space.
+         * @returns {Promise}
+         */
+        static spaces(userId, demo) {
+            return mongo.Space.find({owner: userId, demo: !!demo}).lean().exec()
+                .then((spaces) => {
+                    if (!spaces.length)
+                        throw new errors.MissingResourceException('Failed to find space');
+
+                    return spaces;
+                });
+        }
+
+        /**
+         * Extract IDs from user spaces.
+         *
+         * @param {mongo.ObjectId|String} userId User ID.
+         * @param {Boolean} demo Is need use demo space.
+         * @returns {Promise}
+         */
+        static spaceIds(userId, demo) {
+            return this.spaces(userId, demo)
+                .then((spaces) => spaces.map((space) => space._id));
+        }
+
+        /**
+         * Create demo space for user
+         * @param userId - user id
+         * @returns {Promise<mongo.Space>} that resolves created demo space for user
+         */
+        static createDemoSpace(userId) {
+            return new mongo.Space({name: 'Demo space', owner: userId, demo: true}).save();
+        }
+    }
+
+    return SpacesService;
+};
+

http://git-wip-us.apache.org/repos/asf/ignite/blob/6af6560a/modules/web-console/backend/services/users.js
----------------------------------------------------------------------
diff --git a/modules/web-console/backend/services/users.js b/modules/web-console/backend/services/users.js
new file mode 100644
index 0000000..8058b25
--- /dev/null
+++ b/modules/web-console/backend/services/users.js
@@ -0,0 +1,229 @@
+/*
+ * 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.
+ */
+
+'use strict';
+
+// Fire me up!
+
+module.exports = {
+    implements: 'services/users',
+    inject: ['require(lodash)', 'mongo', 'settings', 'services/spaces', 'services/mails', 'agent-manager', 'errors']
+};
+
+/**
+ * @param _
+ * @param mongo
+ * @param settings
+ * @param {SpacesService} spacesService
+ * @param {MailsService} mailsService
+ * @param agentMgr
+ * @param errors
+ * @returns {UsersService}
+ */
+module.exports.factory = (_, mongo, settings, spacesService, mailsService, agentMgr, errors) => {
+    const _randomString = () => {
+        const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+        const possibleLen = possible.length;
+
+        let res = '';
+
+        for (let i = 0; i < settings.tokenLength; i++)
+            res += possible.charAt(Math.floor(Math.random() * possibleLen));
+
+        return res;
+    };
+
+    class UsersService {
+        /**
+         * Save profile information.
+         * @param {String} host - The host
+         * @param {Object} user - The user
+         * @returns {Promise.<mongo.ObjectId>} that resolves account id of merge operation.
+         */
+        static create(host, user) {
+            return mongo.Account.count().exec()
+                .then((cnt) => {
+                    user.admin = cnt === 0;
+
+                    user.token = _randomString();
+
+                    return new mongo.Account(user);
+                })
+                .then((created) => {
+                    return new Promise((resolve, reject) => {
+                        mongo.Account.register(created, user.password, (err, registered) => {
+                            if (err)
+                                reject(err);
+
+                            if (!registered)
+                                reject(new errors.ServerErrorException('Failed to register user.'));
+
+                            resolve(registered);
+                        });
+                    });
+                })
+                .then((registered) => {
+                    registered.resetPasswordToken = _randomString();
+
+                    return registered.save()
+                        .then(() => mongo.Space.create({name: 'Personal space', owner: registered._id}))
+                        .then(() => {
+                            mailsService.emailUserSignUp(host, registered)
+                                .catch((err) => console.error(err));
+
+                            return registered;
+                        });
+                });
+        }
+
+        /**
+         * Save user.
+         * @param {Object} changed - The user
+         * @returns {Promise.<mongo.ObjectId>} that resolves account id of merge operation.
+         */
+        static save(changed) {
+            return mongo.Account.findById(changed._id).exec()
+                .then((user) => {
+                    if (!changed.password)
+                        return Promise.resolve(user);
+
+                    return new Promise((resolve, reject) => {
+                        user.setPassword(changed.password, (err, _user) => {
+                            if (err)
+                                return reject(err);
+
+                            delete changed.password;
+
+                            resolve(_user);
+                        });
+                    });
+                })
+                .then((user) => {
+                    if (!changed.email || user.email === changed.email)
+                        return Promise.resolve(user);
+
+                    return new Promise((resolve, reject) => {
+                        mongo.Account.findOne({email: changed.email}, (err, _user) => {
+                            // TODO send error to admin
+                            if (err)
+                                reject(new Error('Failed to check email!'));
+
+                            if (_user && _user._id !== user._id)
+                                reject(new Error('User with this email already registered!'));
+
+                            resolve(user);
+                        });
+                    });
+                })
+                .then((user) => {
+                    if (changed.token && user.token !== changed.token)
+                        agentMgr.close(user._id, user.token);
+
+                    _.extend(user, changed);
+
+                    return user.save();
+                });
+        }
+
+        /**
+         * Get list of user accounts and summary information.
+         * @returns {mongo.Account[]} - returns all accounts with counters object
+         */
+        static list() {
+            return Promise.all([
+                mongo.Space.aggregate([
+                    {$match: {demo: false}},
+                    {$lookup: {from: 'clusters', localField: '_id', foreignField: 'space', as: 'clusters'}},
+                    {$lookup: {from: 'caches', localField: '_id', foreignField: 'space', as: 'caches'}},
+                    {$lookup: {from: 'domainmodels', localField: '_id', foreignField: 'space', as: 'domainmodels'}},
+                    {$lookup: {from: 'igfs', localField: '_id', foreignField: 'space', as: 'igfs'}},
+                    {
+                        $project: {
+                            owner: 1,
+                            clusters: {$size: '$clusters'},
+                            models: {$size: '$domainmodels'},
+                            caches: {$size: '$caches'},
+                            igfs: {$size: '$igfs'}
+                        }
+                    }
+                ]).exec(),
+                mongo.Account.find({}).sort('firstName lastName').lean().exec()
+            ])
+                .then(([counters, users]) => {
+                    const countersMap = _.keyBy(counters, 'owner');
+
+                    _.forEach(users, (user) => {
+                        user.counters = _.omit(countersMap[user._id], '_id', 'owner');
+                    });
+
+                    return users;
+                });
+        }
+
+        /**
+         * Remove account.
+         * @param {String} host.
+         * @param {mongo.ObjectId|String} userId - The account id for remove.
+         * @returns {Promise.<{rowsAffected}>} - The number of affected rows.
+         */
+        static remove(host, userId) {
+            return mongo.Account.findByIdAndRemove(userId).exec()
+                .then((user) => {
+                    return spacesService.spaceIds(userId)
+                        .then((spaceIds) => Promise.all([
+                            mongo.Cluster.remove({space: {$in: spaceIds}}).exec(),
+                            mongo.Cache.remove({space: {$in: spaceIds}}).exec(),
+                            mongo.DomainModel.remove({space: {$in: spaceIds}}).exec(),
+                            mongo.Igfs.remove({space: {$in: spaceIds}}).exec(),
+                            mongo.Notebook.remove({space: {$in: spaceIds}}).exec(),
+                            mongo.Space.remove({owner: userId}).exec()
+                        ]))
+                        .catch((err) => console.error(`Failed to cleanup spaces [user=${user.username}, err=${err}`))
+                        .then(() => user);
+                })
+                .then((user) => mailsService.emailUserDeletion(host, user).catch((err) => console.error(err)));
+        }
+
+        /**
+         * Get account information.
+         */
+        static get(user, viewedUser) {
+            if (_.isNil(user))
+                return Promise.reject(new errors.AuthFailedException('The user profile service failed the sign in. User profile cannot be loaded.'));
+
+            const becomeUsed = viewedUser && user.admin;
+
+            if (becomeUsed) {
+                user = viewedUser;
+
+                user.becomeUsed = true;
+            }
+            else
+                user = user.toJSON();
+
+            return mongo.Space.findOne({owner: user._id, demo: true}).exec()
+                .then((demoSpace) => {
+                    if (user && demoSpace)
+                        user.demoCreated = true;
+
+                    return user;
+                });
+        }
+    }
+
+    return UsersService;
+};

http://git-wip-us.apache.org/repos/asf/ignite/blob/6af6560a/modules/web-console/backend/test/config/settings.json
----------------------------------------------------------------------
diff --git a/modules/web-console/backend/test/config/settings.json b/modules/web-console/backend/test/config/settings.json
new file mode 100644
index 0000000..a17a777
--- /dev/null
+++ b/modules/web-console/backend/test/config/settings.json
@@ -0,0 +1,20 @@
+{
+  "server": {
+    "port": 3000,
+    "ssl": false
+  },
+  "mongodb": {
+    "url": "mongodb://localhost/console-test"
+  },
+  "agentServer": {
+    "port": 3001,
+    "ssl": false
+  },
+  "mail": {
+    "service": "",
+    "sign": "Kind regards,<br>Apache Ignite Team",
+    "from": "Apache Ignite Web Console <so...@somecompany.tld>",
+    "user": "someusername@somecompany.tld",
+    "pass": ""
+  }
+}

http://git-wip-us.apache.org/repos/asf/ignite/blob/6af6560a/modules/web-console/backend/test/data/accounts.json
----------------------------------------------------------------------
diff --git a/modules/web-console/backend/test/data/accounts.json b/modules/web-console/backend/test/data/accounts.json
new file mode 100644
index 0000000..e5b7f98
--- /dev/null
+++ b/modules/web-console/backend/test/data/accounts.json
@@ -0,0 +1,18 @@
+[
+  {
+    "_id" : "57725443e6d604c05dab9ded",
+    "salt" : "ca8b49c2eacd498a0973de30c0873c166ed99fa0605981726aedcc85bee17832",
+    "hash" : "c052c87e454cd0875332719e1ce085ccd92bedb73c8f939ba45d387f724da97128280643ad4f841d929d48de802f48f4a27b909d2dc806d957d38a1a4049468ce817490038f00ac1416aaf9f8f5a5c476730b46ea22d678421cd269869d4ba9d194f73906e5d5a4fec5229459e20ebda997fb95298067126f6c15346d886d44b67def03bf3ffe484b2e4fa449985de33a0c12e4e1da4c7d71fe7af5d138433f703d8c7eeebbb3d57f1a89659010a1f1d3cd4fbc524abab07860daabb08f08a28b8bfc64ecde2ea3c103030d0d54fc24d9c02f92ee6b3aa1bcd5c70113ab9a8045faea7dd2dc59ec4f9f69fcf634232721e9fb44012f0e8c8fdf7c6bf642db6867ef8e7877123e1bc78af7604fee2e34ad0191f8b97613ea458e0fca024226b7055e08a4bdb256fabf0a203a1e5b6a6c298fb0c60308569cefba779ce1e41fb971e5d1745959caf524ab0bedafce67157922f9c505cea033f6ed28204791470d9d08d31ce7e8003df8a3a05282d4d60bfe6e2f7de06f4b18377dac0fe764ed683c9b2553e75f8280c748aa166fef6f89190b1c6d369ab86422032171e6f9686de42ac65708e63bf018a043601d85bc5c820c7ad1d51ded32e59cdaa629a3f7ae325bbc931f9f21d90c9204effdbd53721a60c8b180dd8c236133e287a47ccc9e5072eb6593771e435e4d5196
 d50d6ddb32c226651c6503387895c5ad025f69fd3",
+    "email" : "a@a",
+    "firstName" : "TestFirstName",
+    "lastName" : "TestLastName",
+    "company" : "TestCompany",
+    "country" : "Canada",
+    "admin" : true,
+    "token" : "ppw4tPI3JUOGHva8CODO",
+    "attempts" : 0,
+    "lastLogin" : "2016-06-28T10:41:07.463Z",
+    "__v" : 0,
+    "resetPasswordToken" : "892rnLbEnVp1FP75Jgpi"
+  }
+]
\ No newline at end of file

http://git-wip-us.apache.org/repos/asf/ignite/blob/6af6560a/modules/web-console/backend/test/data/caches.json
----------------------------------------------------------------------
diff --git a/modules/web-console/backend/test/data/caches.json b/modules/web-console/backend/test/data/caches.json
new file mode 100644
index 0000000..f7a8690
--- /dev/null
+++ b/modules/web-console/backend/test/data/caches.json
@@ -0,0 +1,87 @@
+[
+  {
+    "name": "CarCache",
+    "cacheMode": "PARTITIONED",
+    "atomicityMode": "ATOMIC",
+    "readThrough": true,
+    "writeThrough": true,
+    "sqlFunctionClasses": [],
+    "cacheStoreFactory": {
+      "kind": "CacheJdbcPojoStoreFactory",
+      "CacheJdbcPojoStoreFactory": {
+        "dataSourceBean": "dsH2",
+        "dialect": "H2"
+      }
+    },
+    "domains": [],
+    "clusters": []
+  },
+  {
+    "name": "ParkingCache",
+    "cacheMode": "PARTITIONED",
+    "atomicityMode": "ATOMIC",
+    "readThrough": true,
+    "writeThrough": true,
+    "sqlFunctionClasses": [],
+    "cacheStoreFactory": {
+      "kind": "CacheJdbcPojoStoreFactory",
+      "CacheJdbcPojoStoreFactory": {
+        "dataSourceBean": "dsH2",
+        "dialect": "H2"
+      }
+    },
+    "domains": [],
+    "clusters": []
+  },
+  {
+    "name": "CountryCache",
+    "cacheMode": "PARTITIONED",
+    "atomicityMode": "ATOMIC",
+    "readThrough": true,
+    "writeThrough": true,
+    "sqlFunctionClasses": [],
+    "cacheStoreFactory": {
+      "kind": "CacheJdbcPojoStoreFactory",
+      "CacheJdbcPojoStoreFactory": {
+        "dataSourceBean": "dsH2",
+        "dialect": "H2"
+      }
+    },
+    "domains": [],
+    "clusters": []
+  },
+  {
+    "name": "DepartmentCache",
+    "cacheMode": "PARTITIONED",
+    "atomicityMode": "ATOMIC",
+    "readThrough": true,
+    "writeThrough": true,
+    "sqlFunctionClasses": [],
+    "cacheStoreFactory": {
+      "kind": "CacheJdbcPojoStoreFactory",
+      "CacheJdbcPojoStoreFactory": {
+        "dataSourceBean": "dsH2",
+        "dialect": "H2"
+      }
+    },
+    "domains": [],
+    "clusters": []
+  },
+  {
+    "name": "EmployeeCache",
+    "cacheMode": "PARTITIONED",
+    "atomicityMode": "ATOMIC",
+    "readThrough": true,
+    "writeThrough": true,
+    "sqlFunctionClasses": [],
+    "cacheStoreFactory": {
+      "kind": "CacheJdbcPojoStoreFactory",
+      "CacheJdbcPojoStoreFactory": {
+        "dataSourceBean": "dsH2",
+        "dialect": "H2"
+      }
+    },
+    "domains": [],
+    "clusters": []
+  }
+]

http://git-wip-us.apache.org/repos/asf/ignite/blob/6af6560a/modules/web-console/backend/test/data/clusters.json
----------------------------------------------------------------------
diff --git a/modules/web-console/backend/test/data/clusters.json b/modules/web-console/backend/test/data/clusters.json
new file mode 100644
index 0000000..014b519
--- /dev/null
+++ b/modules/web-console/backend/test/data/clusters.json
@@ -0,0 +1,50 @@
+[
+  {
+    "name": "cluster-igfs",
+    "connector": {
+      "noDelay": true
+    },
+    "communication": {
+      "tcpNoDelay": true
+    },
+    "igfss": [],
+    "caches": [],
+    "binaryConfiguration": {
+      "compactFooter": true,
+      "typeConfigurations": []
+    },
+    "discovery": {
+      "kind": "Multicast",
+      "Multicast": {
+        "addresses": ["127.0.0.1:47500..47510"]
+      },
+      "Vm": {
+        "addresses": ["127.0.0.1:47500..47510"]
+      }
+    }
+  },
+  {
+    "name": "cluster-caches",
+    "connector": {
+      "noDelay": true
+    },
+    "communication": {
+      "tcpNoDelay": true
+    },
+    "igfss": [],
+    "caches": [],
+    "binaryConfiguration": {
+      "compactFooter": true,
+      "typeConfigurations": []
+    },
+    "discovery": {
+      "kind": "Multicast",
+      "Multicast": {
+        "addresses": ["127.0.0.1:47500..47510"]
+      },
+      "Vm": {
+        "addresses": ["127.0.0.1:47500..47510"]
+      }
+    }
+  }
+]

http://git-wip-us.apache.org/repos/asf/ignite/blob/6af6560a/modules/web-console/backend/test/data/domains.json
----------------------------------------------------------------------
diff --git a/modules/web-console/backend/test/data/domains.json b/modules/web-console/backend/test/data/domains.json
new file mode 100644
index 0000000..980d8d1
--- /dev/null
+++ b/modules/web-console/backend/test/data/domains.json
@@ -0,0 +1,307 @@
+[
+  {
+    "keyType": "Integer",
+    "valueType": "model.Parking",
+    "queryMetadata": "Configuration",
+    "databaseSchema": "CARS",
+    "databaseTable": "PARKING",
+    "indexes": [],
+    "aliases": [],
+    "fields": [
+      {
+        "name": "name",
+        "className": "String"
+      },
+      {
+        "name": "capacity",
+        "className": "Integer"
+      }
+    ],
+    "valueFields": [
+      {
+        "databaseFieldName": "NAME",
+        "databaseFieldType": "VARCHAR",
+        "javaFieldName": "name",
+        "javaFieldType": "String"
+      },
+      {
+        "databaseFieldName": "CAPACITY",
+        "databaseFieldType": "INTEGER",
+        "javaFieldName": "capacity",
+        "javaFieldType": "int"
+      }
+    ],
+    "keyFields": [
+      {
+        "databaseFieldName": "ID",
+        "databaseFieldType": "INTEGER",
+        "javaFieldName": "id",
+        "javaFieldType": "int"
+      }
+    ],
+    "caches": []
+  },
+  {
+    "keyType": "Integer",
+    "valueType": "model.Department",
+    "queryMetadata": "Configuration",
+    "databaseSchema": "PUBLIC",
+    "databaseTable": "DEPARTMENT",
+    "indexes": [],
+    "aliases": [],
+    "fields": [
+      {
+        "name": "countryId",
+        "className": "Integer"
+      },
+      {
+        "name": "name",
+        "className": "String"
+      }
+    ],
+    "valueFields": [
+      {
+        "databaseFieldName": "COUNTRY_ID",
+        "databaseFieldType": "INTEGER",
+        "javaFieldName": "countryId",
+        "javaFieldType": "int"
+      },
+      {
+        "databaseFieldName": "NAME",
+        "databaseFieldType": "VARCHAR",
+        "javaFieldName": "name",
+        "javaFieldType": "String"
+      }
+    ],
+    "keyFields": [
+      {
+        "databaseFieldName": "ID",
+        "databaseFieldType": "INTEGER",
+        "javaFieldName": "id",
+        "javaFieldType": "int"
+      }
+    ],
+    "caches": []
+  },
+  {
+    "keyType": "Integer",
+    "valueType": "model.Employee",
+    "queryMetadata": "Configuration",
+    "databaseSchema": "PUBLIC",
+    "databaseTable": "EMPLOYEE",
+    "indexes": [
+      {
+        "name": "EMP_NAMES",
+        "indexType": "SORTED",
+        "fields": [
+          {
+            "name": "firstName",
+            "direction": true
+          },
+          {
+            "name": "lastName",
+            "direction": true
+          }
+        ]
+      },
+      {
+        "name": "EMP_SALARY",
+        "indexType": "SORTED",
+        "fields": [
+          {
+            "name": "salary",
+            "direction": true
+          }
+        ]
+      }
+    ],
+    "aliases": [],
+    "fields": [
+      {
+        "name": "departmentId",
+        "className": "Integer"
+      },
+      {
+        "name": "managerId",
+        "className": "Integer"
+      },
+      {
+        "name": "firstName",
+        "className": "String"
+      },
+      {
+        "name": "lastName",
+        "className": "String"
+      },
+      {
+        "name": "email",
+        "className": "String"
+      },
+      {
+        "name": "phoneNumber",
+        "className": "String"
+      },
+      {
+        "name": "hireDate",
+        "className": "Date"
+      },
+      {
+        "name": "job",
+        "className": "String"
+      },
+      {
+        "name": "salary",
+        "className": "Double"
+      }
+    ],
+    "valueFields": [
+      {
+        "databaseFieldName": "DEPARTMENT_ID",
+        "databaseFieldType": "INTEGER",
+        "javaFieldName": "departmentId",
+        "javaFieldType": "int"
+      },
+      {
+        "databaseFieldName": "MANAGER_ID",
+        "databaseFieldType": "INTEGER",
+        "javaFieldName": "managerId",
+        "javaFieldType": "Integer"
+      },
+      {
+        "databaseFieldName": "FIRST_NAME",
+        "databaseFieldType": "VARCHAR",
+        "javaFieldName": "firstName",
+        "javaFieldType": "String"
+      },
+      {
+        "databaseFieldName": "LAST_NAME",
+        "databaseFieldType": "VARCHAR",
+        "javaFieldName": "lastName",
+        "javaFieldType": "String"
+      },
+      {
+        "databaseFieldName": "EMAIL",
+        "databaseFieldType": "VARCHAR",
+        "javaFieldName": "email",
+        "javaFieldType": "String"
+      },
+      {
+        "databaseFieldName": "PHONE_NUMBER",
+        "databaseFieldType": "VARCHAR",
+        "javaFieldName": "phoneNumber",
+        "javaFieldType": "String"
+      },
+      {
+        "databaseFieldName": "HIRE_DATE",
+        "databaseFieldType": "DATE",
+        "javaFieldName": "hireDate",
+        "javaFieldType": "Date"
+      },
+      {
+        "databaseFieldName": "JOB",
+        "databaseFieldType": "VARCHAR",
+        "javaFieldName": "job",
+        "javaFieldType": "String"
+      },
+      {
+        "databaseFieldName": "SALARY",
+        "databaseFieldType": "DOUBLE",
+        "javaFieldName": "salary",
+        "javaFieldType": "Double"
+      }
+    ],
+    "keyFields": [
+      {
+        "databaseFieldName": "ID",
+        "databaseFieldType": "INTEGER",
+        "javaFieldName": "id",
+        "javaFieldType": "int"
+      }
+    ],
+    "caches": []
+  },
+  {
+    "keyType": "Integer",
+    "valueType": "model.Country",
+    "queryMetadata": "Configuration",
+    "databaseSchema": "PUBLIC",
+    "databaseTable": "COUNTRY",
+    "indexes": [],
+    "aliases": [],
+    "fields": [
+      {
+        "name": "name",
+        "className": "String"
+      },
+      {
+        "name": "population",
+        "className": "Integer"
+      }
+    ],
+    "valueFields": [
+      {
+        "databaseFieldName": "NAME",
+        "databaseFieldType": "VARCHAR",
+        "javaFieldName": "name",
+        "javaFieldType": "String"
+      },
+      {
+        "databaseFieldName": "POPULATION",
+        "databaseFieldType": "INTEGER",
+        "javaFieldName": "population",
+        "javaFieldType": "int"
+      }
+    ],
+    "keyFields": [
+      {
+        "databaseFieldName": "ID",
+        "databaseFieldType": "INTEGER",
+        "javaFieldName": "id",
+        "javaFieldType": "int"
+      }
+    ],
+    "caches": []
+  },
+  {
+    "keyType": "Integer",
+    "valueType": "model.Car",
+    "queryMetadata": "Configuration",
+    "databaseSchema": "CARS",
+    "databaseTable": "CAR",
+    "indexes": [],
+    "aliases": [],
+    "fields": [
+      {
+        "name": "parkingId",
+        "className": "Integer"
+      },
+      {
+        "name": "name",
+        "className": "String"
+      }
+    ],
+    "valueFields": [
+      {
+        "databaseFieldName": "PARKING_ID",
+        "databaseFieldType": "INTEGER",
+        "javaFieldName": "parkingId",
+        "javaFieldType": "int"
+      },
+      {
+        "databaseFieldName": "NAME",
+        "databaseFieldType": "VARCHAR",
+        "javaFieldName": "name",
+        "javaFieldType": "String"
+      }
+    ],
+    "keyFields": [
+      {
+        "databaseFieldName": "ID",
+        "databaseFieldType": "INTEGER",
+        "javaFieldName": "id",
+        "javaFieldType": "int"
+      }
+    ],
+    "caches": []
+  }
+]

http://git-wip-us.apache.org/repos/asf/ignite/blob/6af6560a/modules/web-console/backend/test/data/igfss.json
----------------------------------------------------------------------
diff --git a/modules/web-console/backend/test/data/igfss.json b/modules/web-console/backend/test/data/igfss.json
new file mode 100644
index 0000000..cd128a6
--- /dev/null
+++ b/modules/web-console/backend/test/data/igfss.json
@@ -0,0 +1,10 @@
+[
+  {
+    "ipcEndpointEnabled": true,
+    "fragmentizerEnabled": true,
+    "name": "igfs",
+    "dataCacheName": "igfs-data",
+    "metaCacheName": "igfs-meta",
+    "clusters": []
+  }
+]

http://git-wip-us.apache.org/repos/asf/ignite/blob/6af6560a/modules/web-console/backend/test/injector.js
----------------------------------------------------------------------
diff --git a/modules/web-console/backend/test/injector.js b/modules/web-console/backend/test/injector.js
new file mode 100644
index 0000000..8d44d31
--- /dev/null
+++ b/modules/web-console/backend/test/injector.js
@@ -0,0 +1,31 @@
+/*
+ * 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 path from 'path';
+import fireUp from 'fire-up';
+
+module.exports = fireUp.newInjector({
+    basePath: path.join(__dirname, '../'),
+    modules: [
+        './app/**/*.js',
+        './config/**/*.js',
+        './errors/**/*.js',
+        './middlewares/**/*.js',
+        './routes/**/*.js',
+        './services/**/*.js'
+    ]
+});

http://git-wip-us.apache.org/repos/asf/ignite/blob/6af6560a/modules/web-console/backend/test/unit/CacheService.test.js
----------------------------------------------------------------------
diff --git a/modules/web-console/backend/test/unit/CacheService.test.js b/modules/web-console/backend/test/unit/CacheService.test.js
new file mode 100644
index 0000000..1442775
--- /dev/null
+++ b/modules/web-console/backend/test/unit/CacheService.test.js
@@ -0,0 +1,192 @@
+/*
+ * 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 {assert} from 'chai';
+import injector from '../injector';
+import testCaches from '../data/caches.json';
+import testAccounts from '../data/accounts.json';
+
+let cacheService;
+let mongo;
+let errors;
+
+suite('CacheServiceTestsSuite', () => {
+    const prepareUserSpaces = () => {
+        return mongo.Account.create(testAccounts)
+            .then((accounts) => {
+                return Promise.all(
+                    accounts.map((account) => mongo.Space.create(
+                        [
+                            {name: 'Personal space', owner: account._id, demo: false},
+                            {name: 'Demo space', owner: account._id, demo: true}
+                        ]
+                    )))
+                    .then((spaces) => [accounts, spaces]);
+            });
+    };
+
+    suiteSetup(() => {
+        return Promise.all([injector('services/caches'),
+            injector('mongo'),
+            injector('errors')])
+            .then(([_cacheService, _mongo, _errors]) => {
+                mongo = _mongo;
+                cacheService = _cacheService;
+                errors = _errors;
+            });
+    });
+
+    setup(() => {
+        return Promise.all([
+            mongo.Cache.remove().exec(),
+            mongo.Account.remove().exec(),
+            mongo.Space.remove().exec()
+        ]);
+    });
+
+    test('Create new cache', (done) => {
+        cacheService.merge(testCaches[0])
+            .then((cache) => {
+                assert.isNotNull(cache._id);
+
+                return cache._id;
+            })
+            .then((cacheId) => mongo.Cache.findById(cacheId))
+            .then((cache) => {
+                assert.isNotNull(cache);
+            })
+            .then(done)
+            .catch(done);
+    });
+
+    test('Update existed cache', (done) => {
+        const newName = 'NewUniqueName';
+
+        cacheService.merge(testCaches[0])
+            .then((cache) => {
+                const cacheBeforeMerge = {...testCaches[0], _id: cache._id, name: newName};
+
+                return cacheService.merge(cacheBeforeMerge);
+            })
+            .then((cache) => mongo.Cache.findById(cache._id))
+            .then((cacheAfterMerge) => {
+                assert.equal(cacheAfterMerge.name, newName);
+            })
+            .then(done)
+            .catch(done);
+    });
+
+    test('Create duplicated cache', (done) => {
+        cacheService.merge(testCaches[0])
+            .then(() => cacheService.merge(testCaches[0]))
+            .catch((err) => {
+                assert.instanceOf(err, errors.DuplicateKeyException);
+
+                done();
+            });
+    });
+
+    test('Remove existed cache', (done) => {
+        cacheService.merge(testCaches[0])
+            .then((createdCache) => {
+                return mongo.Cache.findById(createdCache._id)
+                    .then((foundCache) => foundCache._id)
+                    .then(cacheService.remove)
+                    .then(({rowsAffected}) => {
+                        assert.equal(rowsAffected, 1);
+                    })
+                    .then(() => mongo.Cache.findById(createdCache._id))
+                    .then((notFoundCache) => {
+                        assert.isNull(notFoundCache);
+                    });
+            })
+            .then(done)
+            .catch(done);
+    });
+
+    test('Remove cache without identifier', (done) => {
+        cacheService.merge(testCaches[0])
+            .then(() => cacheService.remove())
+            .catch((err) => {
+                assert.instanceOf(err, errors.IllegalArgumentException);
+
+                done();
+            });
+    });
+
+    test('Remove missed cache', (done) => {
+        const validNoExistingId = 'FFFFFFFFFFFFFFFFFFFFFFFF';
+
+        cacheService.merge(testCaches[0])
+            .then(() => cacheService.remove(validNoExistingId))
+            .then(({rowsAffected}) => {
+                assert.equal(rowsAffected, 0);
+            })
+            .then(done)
+            .catch(done);
+    });
+
+    test('Remove all caches in space', (done) => {
+        prepareUserSpaces()
+            .then(([accounts, spaces]) => {
+                const currentUser = accounts[0];
+                const userCache = {...testCaches[0], space: spaces[0][0]._id};
+
+                return cacheService.merge(userCache)
+                    .then(() => cacheService.removeAll(currentUser._id, false));
+            })
+            .then(({rowsAffected}) => {
+                assert.equal(rowsAffected, 1);
+            })
+            .then(done)
+            .catch(done);
+    });
+
+    test('Get all caches by space', (done) => {
+        prepareUserSpaces()
+            .then(([accounts, spaces]) => {
+                const userCache = {...testCaches[0], space: spaces[0][0]._id};
+
+                return cacheService.merge(userCache)
+                    .then((cache) => {
+                        return cacheService.listBySpaces(spaces[0][0]._id)
+                            .then((caches) => {
+                                assert.equal(caches.length, 1);
+                                assert.equal(caches[0]._id.toString(), cache._id.toString());
+                            });
+                    });
+            })
+            .then(done)
+            .catch(done);
+    });
+
+    test('Update linked entities on update cache', (done) => {
+        // TODO IGNITE-3262 Add test.
+        done();
+    });
+
+    test('Update linked entities on remove cache', (done) => {
+        // TODO IGNITE-3262 Add test.
+        done();
+    });
+
+    test('Update linked entities on remove all caches in space', (done) => {
+        // TODO IGNITE-3262 Add test.
+        done();
+    });
+});

http://git-wip-us.apache.org/repos/asf/ignite/blob/6af6560a/modules/web-console/backend/test/unit/ClusterService.test.js
----------------------------------------------------------------------
diff --git a/modules/web-console/backend/test/unit/ClusterService.test.js b/modules/web-console/backend/test/unit/ClusterService.test.js
new file mode 100644
index 0000000..ab0e912
--- /dev/null
+++ b/modules/web-console/backend/test/unit/ClusterService.test.js
@@ -0,0 +1,190 @@
+/*
+ * 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 {assert} from 'chai';
+import injector from '../injector';
+import testClusters from '../data/clusters.json';
+import testAccounts from '../data/accounts.json';
+
+let clusterService;
+let mongo;
+let errors;
+
+suite('ClusterServiceTestsSuite', () => {
+    const prepareUserSpaces = () => {
+        return mongo.Account.create(testAccounts)
+            .then((accounts) => {
+                return Promise.all(accounts.map((account) => mongo.Space.create(
+                    [
+                        {name: 'Personal space', owner: account._id, demo: false},
+                        {name: 'Demo space', owner: account._id, demo: true}
+                    ]
+                )))
+                    .then((spaces) => [accounts, spaces]);
+            });
+    };
+
+    suiteSetup(() => {
+        return Promise.all([injector('services/clusters'),
+            injector('mongo'),
+            injector('errors')])
+            .then(([_clusterService, _mongo, _errors]) => {
+                mongo = _mongo;
+                clusterService = _clusterService;
+                errors = _errors;
+            });
+    });
+
+    setup(() => {
+        return Promise.all([
+            mongo.Cluster.remove().exec(),
+            mongo.Account.remove().exec(),
+            mongo.Space.remove().exec()
+        ]);
+    });
+
+    test('Create new cluster', (done) => {
+        clusterService.merge(testClusters[0])
+            .then((cluster) => {
+                assert.isNotNull(cluster._id);
+
+                return cluster._id;
+            })
+            .then((clusterId) => mongo.Cluster.findById(clusterId))
+            .then((cluster) => {
+                assert.isNotNull(cluster);
+            })
+            .then(done)
+            .catch(done);
+    });
+
+    test('Update existed cluster', (done) => {
+        const newName = 'NewUniqueName';
+
+        clusterService.merge(testClusters[0])
+            .then((cluster) => {
+                const clusterBeforeMerge = {...testClusters[0], _id: cluster._id, name: newName};
+
+                return clusterService.merge(clusterBeforeMerge);
+            })
+            .then((cluster) => mongo.Cluster.findById(cluster._id))
+            .then((clusterAfterMerge) => {
+                assert.equal(clusterAfterMerge.name, newName);
+            })
+            .then(done)
+            .catch(done);
+    });
+
+    test('Create duplicated cluster', (done) => {
+        clusterService.merge(testClusters[0])
+            .then(() => clusterService.merge(testClusters[0]))
+            .catch((err) => {
+                assert.instanceOf(err, errors.DuplicateKeyException);
+
+                done();
+            });
+    });
+
+    test('Remove existed cluster', (done) => {
+        clusterService.merge(testClusters[0])
+            .then((existCluster) => {
+                return mongo.Cluster.findById(existCluster._id)
+                    .then((foundCluster) => clusterService.remove(foundCluster._id))
+                    .then(({rowsAffected}) => {
+                        assert.equal(rowsAffected, 1);
+                    })
+                    .then(() => mongo.Cluster.findById(existCluster._id))
+                    .then((notFoundCluster) => {
+                        assert.isNull(notFoundCluster);
+                    });
+            })
+            .then(done)
+            .catch(done);
+    });
+
+    test('Remove cluster without identifier', (done) => {
+        clusterService.merge(testClusters[0])
+            .then(() => clusterService.remove())
+            .catch((err) => {
+                assert.instanceOf(err, errors.IllegalArgumentException);
+
+                done();
+            });
+    });
+
+    test('Remove missed cluster', (done) => {
+        const validNoExistingId = 'FFFFFFFFFFFFFFFFFFFFFFFF';
+
+        clusterService.merge(testClusters[0])
+            .then(() => clusterService.remove(validNoExistingId))
+            .then(({rowsAffected}) => {
+                assert.equal(rowsAffected, 0);
+            })
+            .then(done)
+            .catch(done);
+    });
+
+    test('Remove all clusters in space', (done) => {
+        prepareUserSpaces()
+            .then(([accounts, spaces]) => {
+                const currentUser = accounts[0];
+                const userCluster = {...testClusters[0], space: spaces[0][0]._id};
+
+                return clusterService.merge(userCluster)
+                    .then(() => clusterService.removeAll(currentUser._id, false));
+            })
+            .then(({rowsAffected}) => {
+                assert.equal(rowsAffected, 1);
+            })
+            .then(done)
+            .catch(done);
+    });
+
+    test('Get all clusters by space', (done) => {
+        prepareUserSpaces()
+            .then(([accounts, spaces]) => {
+                const userCluster = {...testClusters[0], space: spaces[0][0]._id};
+
+                return clusterService.merge(userCluster)
+                    .then((existCluster) => {
+                        return clusterService.listBySpaces(spaces[0][0]._id)
+                            .then((clusters) => {
+                                assert.equal(clusters.length, 1);
+                                assert.equal(clusters[0]._id.toString(), existCluster._id.toString());
+                            });
+                    });
+            })
+            .then(done)
+            .catch(done);
+    });
+
+    test('Update linked entities on update cluster', (done) => {
+        // TODO IGNITE-3262 Add test.
+        done();
+    });
+
+    test('Update linked entities on remove cluster', (done) => {
+        // TODO IGNITE-3262 Add test.
+        done();
+    });
+
+    test('Update linked entities on remove all clusters in space', (done) => {
+        // TODO IGNITE-3262 Add test.
+        done();
+    });
+});

http://git-wip-us.apache.org/repos/asf/ignite/blob/6af6560a/modules/web-console/backend/test/unit/DomainService.test.js
----------------------------------------------------------------------
diff --git a/modules/web-console/backend/test/unit/DomainService.test.js b/modules/web-console/backend/test/unit/DomainService.test.js
new file mode 100644
index 0000000..477b454
--- /dev/null
+++ b/modules/web-console/backend/test/unit/DomainService.test.js
@@ -0,0 +1,198 @@
+/*
+ * 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 {assert} from 'chai';
+import injector from '../injector';
+import testDomains from '../data/domains.json';
+import testAccounts from '../data/accounts.json';
+
+let domainService;
+let mongo;
+let errors;
+
+suite('DomainsServiceTestsSuite', () => {
+    const prepareUserSpaces = () => {
+        return mongo.Account.create(testAccounts)
+            .then((accounts) => {
+                return Promise.all(accounts.map((account) => mongo.Space.create(
+                    [
+                        {name: 'Personal space', owner: account._id, demo: false},
+                        {name: 'Demo space', owner: account._id, demo: true}
+                    ]
+                )))
+                    .then((spaces) => [accounts, spaces]);
+            });
+    };
+
+    suiteSetup(() => {
+        return Promise.all([injector('services/domains'),
+            injector('mongo'),
+            injector('errors')])
+            .then(([_domainService, _mongo, _errors]) => {
+                mongo = _mongo;
+                domainService = _domainService;
+                errors = _errors;
+            });
+    });
+
+    setup(() => {
+        return Promise.all([
+            mongo.DomainModel.remove().exec(),
+            mongo.Account.remove().exec(),
+            mongo.Space.remove().exec()
+        ]);
+    });
+
+    test('Create new domain', (done) => {
+        domainService.batchMerge([testDomains[0]])
+            .then((results) => {
+                const domain = results.savedDomains[0];
+
+                assert.isNotNull(domain._id);
+
+                return domain._id;
+            })
+            .then((domainId) => mongo.DomainModel.findById(domainId))
+            .then((domain) => {
+                assert.isNotNull(domain);
+            })
+            .then(done)
+            .catch(done);
+    });
+
+    test('Update existed domain', (done) => {
+        const newValType = 'value.Type';
+
+        domainService.batchMerge([testDomains[0]])
+            .then((results) => {
+                const domain = results.savedDomains[0];
+
+                const domainBeforeMerge = {...testDomains[0], _id: domain._id, valueType: newValType};
+
+                return domainService.batchMerge([domainBeforeMerge]);
+            })
+            .then((results) => mongo.DomainModel.findById(results.savedDomains[0]._id))
+            .then((domainAfterMerge) => {
+                assert.equal(domainAfterMerge.valueType, newValType);
+            })
+            .then(done)
+            .catch(done);
+    });
+
+    test('Create duplicated domain', (done) => {
+        domainService.batchMerge([testDomains[0]])
+            .then(() => domainService.batchMerge([testDomains[0]]))
+            .catch((err) => {
+                assert.instanceOf(err, errors.DuplicateKeyException);
+
+                done();
+            });
+    });
+
+    test('Remove existed domain', (done) => {
+        domainService.batchMerge([testDomains[0]])
+            .then((results) => {
+                const domain = results.savedDomains[0];
+
+                return mongo.DomainModel.findById(domain._id)
+                    .then((foundDomain) => domainService.remove(foundDomain._id))
+                    .then(({rowsAffected}) => {
+                        assert.equal(rowsAffected, 1);
+                    })
+                    .then(() => mongo.DomainModel.findById(domain._id))
+                    .then((notFoundDomain) => {
+                        assert.isNull(notFoundDomain);
+                    });
+            })
+            .then(done)
+            .catch(done);
+    });
+
+    test('Remove domain without identifier', (done) => {
+        domainService.batchMerge([testDomains[0]])
+            .then(() => domainService.remove())
+            .catch((err) => {
+                assert.instanceOf(err, errors.IllegalArgumentException);
+
+                done();
+            });
+    });
+
+    test('Remove missed domain', (done) => {
+        const validNoExistingId = 'FFFFFFFFFFFFFFFFFFFFFFFF';
+
+        domainService.batchMerge([testDomains[0]])
+            .then(() => domainService.remove(validNoExistingId))
+            .then(({rowsAffected}) => {
+                assert.equal(rowsAffected, 0);
+            })
+            .then(done)
+            .catch(done);
+    });
+
+    test('Remove all domains in space', (done) => {
+        prepareUserSpaces()
+            .then(([accounts, spaces]) => {
+                const currentUser = accounts[0];
+                const userDomain = {...testDomains[0], space: spaces[0][0]._id};
+
+                return domainService.batchMerge([userDomain])
+                    .then(() => domainService.removeAll(currentUser._id, false));
+            })
+            .then(({rowsAffected}) => {
+                assert.equal(rowsAffected, 1);
+            })
+            .then(done)
+            .catch(done);
+    });
+
+    test('Get all domains by space', (done) => {
+        prepareUserSpaces()
+            .then(([accounts, spaces]) => {
+                const userDomain = {...testDomains[0], space: spaces[0][0]._id};
+
+                return domainService.batchMerge([userDomain])
+                    .then((results) => {
+                        const domain = results.savedDomains[0];
+
+                        return domainService.listBySpaces(spaces[0][0]._id)
+                            .then((domains) => {
+                                assert.equal(domains.length, 1);
+                                assert.equal(domains[0]._id.toString(), domain._id.toString());
+                            });
+                    });
+            })
+            .then(done)
+            .catch(done);
+    });
+
+    test('Update linked entities on update domain', (done) => {
+        // TODO IGNITE-3262 Add test.
+        done();
+    });
+
+    test('Update linked entities on remove domain', (done) => {
+        // TODO IGNITE-3262 Add test.
+        done();
+    });
+
+    test('Update linked entities on remove all domains in space', (done) => {
+        // TODO IGNITE-3262 Add test.
+        done();
+    });
+});