diff --git a/.travis.yml b/.travis.yml index 48d54d34e..7eccf52bd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,14 +1,13 @@ language: node_js node_js: - 6 -sudo: required +sudo: false services: - docker git: depth: 1 before_script: - - ./bin/install-docker-travis.sh - npm run docker:up - npm install - docker logs couchdb @@ -19,6 +18,8 @@ before_script: - grunt debugDev - DIST=./dist/debug ./bin/fauxton & - sleep 30 + - docker logs couchdb + - curl http://127.0.0.1:5984 script: - travis_retry ./node_modules/.bin/grunt nightwatch after_script: diff --git a/app/addons/components/assets/less/styled-select.less b/app/addons/components/assets/less/styled-select.less index 12275273a..45e94e730 100644 --- a/app/addons/components/assets/less/styled-select.less +++ b/app/addons/components/assets/less/styled-select.less @@ -31,6 +31,13 @@ color: #333; } +//bug in firefox for text-indent +@-moz-document url-prefix() { + .styled-select select { + text-indent: 0px; + } +} + .styled-select select:-moz-focusring { color: transparent; text-shadow: 0 0 0 #000; diff --git a/app/addons/databases/components.react.jsx b/app/addons/databases/components.react.jsx index cddc6027e..58cf05301 100644 --- a/app/addons/databases/components.react.jsx +++ b/app/addons/databases/components.react.jsx @@ -182,7 +182,7 @@ var DatabaseRow = React.createClass({ + href={"#/replication/_create/" + encodedId} /> diff --git a/app/addons/replication/__tests__/actions.test.js b/app/addons/replication/__tests__/actions.test.js new file mode 100644 index 000000000..804aa6bab --- /dev/null +++ b/app/addons/replication/__tests__/actions.test.js @@ -0,0 +1,182 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); you may not +// use this file except in compliance with the License. You may obtain a copy of +// the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations under +// the License. +import utils from '../../../../test/mocha/testUtils'; +import {replicate, getReplicationStateFrom, deleteDocs} from '../actions'; +import ActionTypes from '../actiontypes'; +import fetchMock from 'fetch-mock'; +import app from '../../../app'; +import FauxtonAPI from '../../../core/api'; + +app.session = { + get () { + return 'test-user-name'; + } +}; + +Object.defineProperty(window.location, 'origin', { + writable: true, + value: 'http://dev:8000' +}); + +const assert = utils.assert; + +describe("Replication Actions", () => { + + describe('replicate', () => { + afterEach(fetchMock.restore); + + it('creates a new database if it does not exist', (done) => { + fetchMock.postOnce('/_replicator', { + status: 404, + body: { + error: "not_found", + reason: "Database does not exist." + } + }); + + fetchMock.putOnce('/_replicator', { + status: 200, + body: { + ok: true + } + }); + + const finalPost = fetchMock.postOnce('/_replicator', { + status: 200, + body: { + ok: true + } + }); + + replicate ({ + localSource: "animaldb", + localTarget: "boom123", + password: "testerpass", + remoteSource: "", + remoteTarget: "", + replicationDocName: "", + replicationSource: "REPLICATION_SOURCE_LOCAL", + replicationTarget: "REPLICATION_TARGET_NEW_LOCAL_DATABASE", + replicationType: "", + username: "tester" + }); + + //this is not pretty, and might cause some false errors. But its tricky to tell when this test has completed + setTimeout(() => { + assert.ok(finalPost.called('/_replicator')); + done(); + }, 100); + }); + }); + + describe('getReplicationStateFrom', () => { + const doc = { + "_id": "7dcea9874a8fcb13c6630a1547001559", + "_rev": "2-98d29cc74e77b6dc38f5fc0dcec0033c", + "user_ctx": { + "name": "tester", + "roles": [ + "_admin", + "_reader", + "_writer" + ] + }, + "source": { + "headers": { + "Authorization": "Basic dGVzdGVyOnRlc3RlcnBhc3M=" + }, + "url": "http://dev:8000/animaldb" + }, + "target": { + "headers": { + "Authorization": "Basic dGVzdGVyOnRlc3RlcnBhc3M=" + }, + "url": "http://dev:8000/boom123" + }, + "create_target": true, + "continuous": false, + "owner": "tester", + "_replication_id": "90ff5a45623aa6821a6b0c20f5d3b5e8" + }; + + const docState = { + "replicationDocName": "7dcea9874a8fcb13c6630a1547001559", + "replicationType": "REPLICATION_TYPE_ONE_TIME", + "replicationSource": "REPLICATION_SOURCE_LOCAL", + "localSource": "animaldb", + "replicationTarget": "REPLICATION_TARGET_EXISTING_LOCAL_DATABASE", + "localTarget": "boom123" + }; + + it('builds up correct state', (done) => { + FauxtonAPI.dispatcher.register(({type, options}) => { + if (ActionTypes.REPLICATION_SET_STATE_FROM_DOC === type) { + assert.deepEqual(docState, options); + setTimeout(done); + } + }); + + fetchMock.getOnce('/_replicator/7dcea9874a8fcb13c6630a1547001559', doc); + getReplicationStateFrom(doc._id); + }); + }); + + describe('deleteDocs', () => { + it('sends bulk doc request', (done) => { + const resp = [ + { + "ok": true, + "id": "should-fail", + "rev": "32-14e8495723c34271ef1391adf83defc2" + }, + { + "ok": true, + "id": "my-cool-id", + "rev": "3-f16f14d11708952b3d787846ef6ef8a9" + } + ]; + + const docs = [ + { + _id: "should-fail", + _rev: "31-cdc233eb8a98e3aa3a87cd72f6a86301", + raw: { + _id: "should-fail", + _rev: "31-cdc233eb8a98e3aa3a87cd72f6a86301" + }, + }, + { + _id: "my-cool-id", + _rev: "2-da6af558740409e61d563769a8044a68", + raw: { + _id: "my-cool-id", + _rev: "2-da6af558740409e61d563769a8044a68" + } + } + ]; + + fetchMock.getOnce('/_scheduler/job', 404); + fetchMock.getOnce('/_replicator/_all_docs?include_docs=true&limit=100', {rows: []}); + fetchMock.postOnce('/_replicator/_bulk_docs', { + status: 200, + body: resp + }); + deleteDocs(docs); + + FauxtonAPI.dispatcher.register(({type}) => { + if (ActionTypes.REPLICATION_CLEAR_SELECTED_DOCS === type) { + setTimeout(done); + } + }); + }); + }); +}); diff --git a/app/addons/replication/__tests__/api.tests.js b/app/addons/replication/__tests__/api.tests.js index 3ebb79273..54721c2f4 100644 --- a/app/addons/replication/__tests__/api.tests.js +++ b/app/addons/replication/__tests__/api.tests.js @@ -21,9 +21,12 @@ import { decodeFullUrl, getCredentialsFromUrl, removeCredentialsFromUrl, - removeSensitiveUrlInfo + removeSensitiveUrlInfo, + supportNewApi, + fetchReplicationDocs } from '../api'; import Constants from '../constants'; +import fetchMock from 'fetch-mock'; const assert = utils.assert; @@ -299,6 +302,146 @@ describe('Replication API', () => { const url = removeCredentialsFromUrl("https://bob:m@:/rley@my-couchdb.com/db"); assert.deepEqual(url, 'https://my-couchdb.com/db'); }); + }); + + describe('supportNewApi', () => { + afterEach(() => { + fetchMock.restore(); + }); + + it('returns true for support', () => { + fetchMock.getOnce('/_scheduler/jobs', 200); + return supportNewApi(true) + .then(resp => { + assert.ok(resp); + }); + }); + + it('returns false for no support', () => { + fetchMock.getOnce('/_scheduler/jobs', 404); + return supportNewApi(true) + .then(resp => { + assert.notOk(resp); + }); + }); + + }); + describe("fetchReplicationDocs", () => { + const _repDocs = { + "total_rows":2, + "offset":0, + "rows":[ + { + "id":"_design/_replicator", + "key":"_design/_replicator", + "value":{ + "rev":"1-1390740c4877979dbe8998382876556c" + }, + "doc":{"_id":"_design/_replicator", + "_rev":"1-1390740c4877979dbe8998382876556c", + "language":"javascript", + "validate_doc_update":"\n function(newDoc, oldDoc, userCtx) {\n function reportError(error_msg) {\n log('Error writing document `' + newDoc._id +\n '\\' to the replicator database: ' + error_msg);\n throw({forbidden: error_msg});\n }\n\n function validateEndpoint(endpoint, fieldName) {\n if ((typeof endpoint !== 'string') &&\n ((typeof endpoint !== 'object') || (endpoint === null))) {\n\n reportError('The `' + fieldName + '\\' property must exist' +\n ' and be either a string or an object.');\n }\n\n if (typeof endpoint === 'object') {\n if ((typeof endpoint.url !== 'string') || !endpoint.url) {\n reportError('The url property must exist in the `' +\n fieldName + '\\' field and must be a non-empty string.');\n }\n\n if ((typeof endpoint.auth !== 'undefined') &&\n ((typeof endpoint.auth !== 'object') ||\n endpoint.auth === null)) {\n\n reportError('`' + fieldName +\n '.auth\\' must be a non-null object.');\n }\n\n if ((typeof endpoint.headers !== 'undefined') &&\n ((typeof endpoint.headers !== 'object') ||\n endpoint.headers === null)) {\n\n reportError('`' + fieldName +\n '.headers\\' must be a non-null object.');\n }\n }\n }\n\n var isReplicator = (userCtx.roles.indexOf('_replicator') >= 0);\n var isAdmin = (userCtx.roles.indexOf('_admin') >= 0);\n\n if (oldDoc && !newDoc._deleted && !isReplicator &&\n (oldDoc._replication_state === 'triggered')) {\n reportError('Only the replicator can edit replication documents ' +\n 'that are in the triggered state.');\n }\n\n if (!newDoc._deleted) {\n validateEndpoint(newDoc.source, 'source');\n validateEndpoint(newDoc.target, 'target');\n\n if ((typeof newDoc.create_target !== 'undefined') &&\n (typeof newDoc.create_target !== 'boolean')) {\n\n reportError('The `create_target\\' field must be a boolean.');\n }\n\n if ((typeof newDoc.continuous !== 'undefined') &&\n (typeof newDoc.continuous !== 'boolean')) {\n\n reportError('The `continuous\\' field must be a boolean.');\n }\n\n if ((typeof newDoc.doc_ids !== 'undefined') &&\n !isArray(newDoc.doc_ids)) {\n\n reportError('The `doc_ids\\' field must be an array of strings.');\n }\n\n if ((typeof newDoc.selector !== 'undefined') &&\n (typeof newDoc.selector !== 'object')) {\n\n reportError('The `selector\\' field must be an object.');\n }\n\n if ((typeof newDoc.filter !== 'undefined') &&\n ((typeof newDoc.filter !== 'string') || !newDoc.filter)) {\n\n reportError('The `filter\\' field must be a non-empty string.');\n }\n\n if ((typeof newDoc.doc_ids !== 'undefined') &&\n (typeof newDoc.selector !== 'undefined')) {\n\n reportError('`doc_ids\\' field is incompatible with `selector\\'.');\n }\n\n if ( ((typeof newDoc.doc_ids !== 'undefined') ||\n (typeof newDoc.selector !== 'undefined')) &&\n (typeof newDoc.filter !== 'undefined') ) {\n\n reportError('`filter\\' field is incompatible with `selector\\' and `doc_ids\\'.');\n }\n\n if ((typeof newDoc.query_params !== 'undefined') &&\n ((typeof newDoc.query_params !== 'object') ||\n newDoc.query_params === null)) {\n\n reportError('The `query_params\\' field must be an object.');\n }\n\n if (newDoc.user_ctx) {\n var user_ctx = newDoc.user_ctx;\n\n if ((typeof user_ctx !== 'object') || (user_ctx === null)) {\n reportError('The `user_ctx\\' property must be a ' +\n 'non-null object.');\n }\n\n if (!(user_ctx.name === null ||\n (typeof user_ctx.name === 'undefined') ||\n ((typeof user_ctx.name === 'string') &&\n user_ctx.name.length > 0))) {\n\n reportError('The `user_ctx.name\\' property must be a ' +\n 'non-empty string or null.');\n }\n\n if (!isAdmin && (user_ctx.name !== userCtx.name)) {\n reportError('The given `user_ctx.name\\' is not valid');\n }\n\n if (user_ctx.roles && !isArray(user_ctx.roles)) {\n reportError('The `user_ctx.roles\\' property must be ' +\n 'an array of strings.');\n }\n\n if (!isAdmin && user_ctx.roles) {\n for (var i = 0; i < user_ctx.roles.length; i++) {\n var role = user_ctx.roles[i];\n\n if (typeof role !== 'string' || role.length === 0) {\n reportError('Roles must be non-empty strings.');\n }\n if (userCtx.roles.indexOf(role) === -1) {\n reportError('Invalid role (`' + role +\n '\\') in the `user_ctx\\'');\n }\n }\n }\n } else {\n if (!isAdmin) {\n reportError('The `user_ctx\\' property is missing (it is ' +\n 'optional for admins only).');\n }\n }\n } else {\n if (!isAdmin) {\n if (!oldDoc.user_ctx || (oldDoc.user_ctx.name !== userCtx.name)) {\n reportError('Replication documents can only be deleted by ' +\n 'admins or by the users who created them.');\n }\n }\n }\n }\n" + } + }, + { + "id":"c94d4839d1897105cb75e1251e0003ea", + "key":"c94d4839d1897105cb75e1251e0003ea", + "value":{ + "rev":"3-4559cb522de85ce03bd0e1991025e89a" + }, + "doc":{"_id":"c94d4839d1897105cb75e1251e0003ea", + "_rev":"3-4559cb522de85ce03bd0e1991025e89a", + "user_ctx":{ + "name":"tester", + "roles":["_admin", "_reader", "_writer"]}, + "source":{ + "headers":{ + "Authorization":"Basic dGVzdGVyOnRlc3RlcnBhc3M=" + }, + "url":"http://dev:5984/animaldb"}, + "target":{ + "headers":{ + "Authorization":"Basic dGVzdGVyOnRlc3RlcnBhc3M="}, + "url":"http://dev:5984/animaldb-clone" + }, + "create_target":true, + "continuous":false, + "owner":"tester", + "_replication_state":"completed", + "_replication_state_time":"2017-02-28T12:16:28+00:00", + "_replication_id":"0ce2939af29317b5dbe11c15570ddfda", + "_replication_stats":{ + "revisions_checked":14, + "missing_revisions_found":14, + "docs_read":14, + "docs_written":14, + "changes_pending":null, + "doc_write_failures":0, + "checkpointed_source_seq":"15-g1AAAAJDeJyV0N0NgjAQAOAKRnlzBJ3AcKWl9Uk20ZbSEII4gm6im-gmugke1AQJ8aFpck3u50vuakJIVIaGrJqzKSADKrYxPqixECii123bVmWoFidMLGVsqEjYtP0voTcY9f6rzHqFKcglsz5K1imHkcJTnoJVPsqxUy4jxepEioJ7KM0cI7nih9BtkDSlkAif2zjp7qRHJwW9lLNdDkZ6S08nvQZJMsNT4b_d20k_d4oVE1aK6VT1AXTajes" + } + } + } + ]}; + + const _schedDocs = { + "offset": 0, + "docs": [ + { + "database":"_replicator", + "doc_id":"c94d4839d1897105cb75e1251e0003ea", + "id":null, + "state":"completed", + "error_count":0, + "info":{ + "revisions_checked":0, + "missing_revisions_found":0, + "docs_read":0, + "docs_written":0, + "changes_pending":null, + "doc_write_failures":0, + "checkpointed_source_seq":"56-g1AAAAGweJzLYWBgYMlgTmFQTElKzi9KdUhJMjTQy00tyixJTE_VS87JL01JzCvRy0styQEqZUpkSLL___9_VgZzIm8uUIDd1NIkNSk5LYVBAW6AKXb9aLYY47ElyQFIJtVDLeIBW2ScbGJiYGJKjBloNhnisSmPBUgyNAApoGX7QbaJg21LTDEwNE8zR_aWCVGW4VCFZNkBiGVgr3GALTNLSzQ0T0xEtgyHm7MAbEaMZw"}, + "last_updated":"2017-03-07T14:46:17Z", + "start_time":"2017-03-07T14:46:16Z" + } + ], + "total": 1 + }; + + describe('old api', () => { + afterEach(() => { + fetchMock.restore(); + }); + + it("returns parsedReplicationDocs", () => { + fetchMock.getOnce('/_scheduler/jobs', 404); + fetchMock.get('/_replicator/_all_docs?include_docs=true&limit=100', _repDocs); + return supportNewApi(true) + .then(fetchReplicationDocs) + .then(docs => { + assert.deepEqual(docs.length, 1); + assert.deepEqual(docs[0]._id, "c94d4839d1897105cb75e1251e0003ea"); + }); + }); + }); + + describe('new api', () => { + afterEach(() => { + fetchMock.restore(); + }); + + it("returns parsedReplicationDocs", () => { + fetchMock.getOnce('/_scheduler/jobs', 200); + fetchMock.get('/_replicator/_all_docs?include_docs=true&limit=100', _repDocs); + fetchMock.get('/_scheduler/docs?include_docs=true', _schedDocs); + return supportNewApi(true) + .then(fetchReplicationDocs) + .then(docs => { + assert.deepEqual(docs.length, 1); + assert.deepEqual(docs[0]._id, "c94d4839d1897105cb75e1251e0003ea"); + assert.deepEqual(docs[0].stateTime, new Date('2017-03-07T14:46:17')); + }); + }); + }); }); }); diff --git a/app/addons/replication/tests/storesSpec.js b/app/addons/replication/__tests__/stores.tests.js similarity index 100% rename from app/addons/replication/tests/storesSpec.js rename to app/addons/replication/__tests__/stores.tests.js diff --git a/app/addons/replication/actions.js b/app/addons/replication/actions.js index cdf5bfb6d..83abb240e 100644 --- a/app/addons/replication/actions.js +++ b/app/addons/replication/actions.js @@ -9,12 +9,20 @@ // 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 app from '../../app'; import FauxtonAPI from '../../core/api'; import ActionTypes from './actiontypes'; import Helpers from './helpers'; import Constants from './constants'; -import {createReplicationDoc, fetchReplicationDocs, decodeFullUrl} from './api'; +import { + supportNewApi, + createReplicationDoc, + fetchReplicateInfo, + fetchReplicationDocs, + decodeFullUrl, + deleteReplicatesApi, + createReplicatorDB +} from './api'; +import 'whatwg-fetch'; function initReplicator (localSource) { @@ -26,11 +34,16 @@ function initReplicator (localSource) { } }); } - $.ajax({ - url: app.host + '/_all_dbs', - contentType: 'application/json', - dataType: 'json' - }).then((databases) => { + + fetch('/_all_dbs', { + credentials: 'include', + headers: { + 'Accept': 'application/json; charset=utf-8', + 'Content-Type': 'application/json' + }, + }) + .then(resp => resp.json()) + .then((databases) => { FauxtonAPI.dispatch({ type: ActionTypes.REPLICATION_DATABASES_LOADED, options: { @@ -40,16 +53,19 @@ function initReplicator (localSource) { }); } -function replicate (params) { +export const replicate = (params) => { const replicationDoc = createReplicationDoc(params); - const promise = $.ajax({ - url: window.location.origin + '/_replicator', - contentType: 'application/json', - type: 'POST', - dataType: 'json', - data: JSON.stringify(replicationDoc) - }); + const promise = fetch('/_replicator', { + method: 'POST', + credentials: 'include', + headers: { + 'Accept': 'application/json; charset=utf-8', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(replicationDoc) + }) + .then(res => res.json()); const source = Helpers.getDatabaseLabel(replicationDoc.source); const target = Helpers.getDatabaseLabel(replicationDoc.target); @@ -58,22 +74,37 @@ function replicate (params) { type: ActionTypes.REPLICATION_STARTING, }); - promise.then(() => { + const handleError = (json) => { FauxtonAPI.addNotification({ - msg: `Replication from ${decodeURIComponent(source)} to ${decodeURIComponent(target)} has been scheduled.`, - type: 'success', - escape: false, + msg: json.reason, + type: 'error', clear: true }); - }, (xhr) => { - const errorMessage = JSON.parse(xhr.responseText); + }; + + promise.then(json => { + if (!json.ok) { + throw json; + } + FauxtonAPI.addNotification({ - msg: errorMessage.reason, - type: 'error', + msg: `Replication from ${decodeURIComponent(source)} to ${decodeURIComponent(target)} has been scheduled.`, + type: 'success', + escape: false, clear: true }); + }) + .catch(json => { + if (json.error && json.error === "not_found") { + return createReplicatorDB().then(() => { + return replicate(params); + }) + .catch(handleError); + } + + handleError(json); }); -} +}; function updateFormField (fieldName, value) { FauxtonAPI.dispatch({ @@ -89,12 +120,12 @@ function clearReplicationForm () { FauxtonAPI.dispatch({ type: ActionTypes.REPLICATION_CLEAR_FORM }); } -const getReplicationActivity = () => { +const getReplicationActivity = (supportNewApi) => { FauxtonAPI.dispatch({ type: ActionTypes.REPLICATION_FETCHING_STATUS, }); - fetchReplicationDocs().then(docs => { + fetchReplicationDocs(supportNewApi).then(docs => { FauxtonAPI.dispatch({ type: ActionTypes.REPLICATION_STATUS, options: docs @@ -102,6 +133,27 @@ const getReplicationActivity = () => { }); }; +const getReplicateActivity = () => { + supportNewApi() + .then(newApi => { + if (!newApi) { + return; + } + + FauxtonAPI.dispatch({ + type: ActionTypes.REPLICATION_FETCHING_REPLICATE_STATUS, + }); + + fetchReplicateInfo() + .then(replicateInfo => { + FauxtonAPI.dispatch({ + type: ActionTypes.REPLICATION_REPLICATE_STATUS, + options: replicateInfo + }); + }); + }); +}; + const filterDocs = (filter) => { FauxtonAPI.dispatch({ type: ActionTypes.REPLICATION_FILTER_DOCS, @@ -109,6 +161,13 @@ const filterDocs = (filter) => { }); }; +const filterReplicate = (filter) => { + FauxtonAPI.dispatch({ + type: ActionTypes.REPLICATION_FILTER_REPLICATE, + options: filter + }); +}; + const selectAllDocs = () => { FauxtonAPI.dispatch({ type: ActionTypes.REPLICATION_TOGGLE_ALL_DOCS @@ -122,13 +181,32 @@ const selectDoc = (id) => { }); }; +const selectAllReplicates = () => { + FauxtonAPI.dispatch({ + type: ActionTypes.REPLICATION_TOGGLE_ALL_REPLICATE + }); +}; + +const selectReplicate = (id) => { + FauxtonAPI.dispatch({ + type: ActionTypes.REPLICATION_TOGGLE_REPLICATE, + options: id + }); +}; + const clearSelectedDocs = () => { FauxtonAPI.dispatch({ type: ActionTypes.REPLICATION_CLEAR_SELECTED_DOCS }); }; -const deleteDocs = (docs) => { +const clearSelectedReplicates = () => { + FauxtonAPI.dispatch({ + type: ActionTypes.REPLICATION_CLEAR_SELECTED_REPLICATES + }); +}; + +export const deleteDocs = (docs) => { const bulkDocs = docs.map(({raw: doc}) => { doc._deleted = true; return doc; @@ -141,13 +219,23 @@ const deleteDocs = (docs) => { clear: true }); - $.ajax({ - url: app.host + '/_replicator/_bulk_docs', - contentType: 'application/json', - dataType: 'json', + fetch('/_replicator/_bulk_docs', { + credentials: 'include', + headers: { + 'Accept': 'application/json; charset=utf-8', + 'Content-Type': 'application/json' + }, method: 'POST', - data: JSON.stringify({docs: bulkDocs}) - }).then(() => { + body: JSON.stringify({docs: bulkDocs}) + }) + .then(resp => { + if (!resp.ok) { + throw resp; + } + return resp.json(); + }) + .then(() => { + let msg = 'The selected documents have been deleted.'; if (docs.length === 1) { msg = `Document ${docs[0]._id} has been deleted`; @@ -159,8 +247,47 @@ const deleteDocs = (docs) => { escape: false, clear: true }); + clearSelectedDocs(); getReplicationActivity(); + }) + .catch(resp => { + resp.json() + .then(error => { + FauxtonAPI.addNotification({ + msg: error.reason, + type: 'error', + clear: true + }); + }); + + }); +}; + +const deleteReplicates = (replicates) => { + FauxtonAPI.addNotification({ + msg: `Deleting _replicate${replicates.length > 1 ? 's' : ''}.`, + type: 'success', + escape: false, + clear: true + }); + + deleteReplicatesApi(replicates) + .then(() => { + let msg = 'The selected replications have been deleted.'; + if (replicates.length === 1) { + msg = `Replication ${replicates[0]._id} has been deleted`; + } + + clearSelectedReplicates(); + getReplicateActivity(); + + FauxtonAPI.addNotification({ + msg: msg, + type: 'success', + escape: false, + clear: true + }); }, (xhr) => { const errorMessage = JSON.parse(xhr.responseText); FauxtonAPI.addNotification({ @@ -171,13 +298,20 @@ const deleteDocs = (docs) => { }); }; -const getReplicationStateFrom = (id) => { - $.ajax({ - url: `${app.host}/_replicator/${encodeURIComponent(id)}`, - contentType: 'application/json', - dataType: 'json', +export const getReplicationStateFrom = (id) => { + FauxtonAPI.dispatch({ + type: ActionTypes.REPLICATION_FETCHING_FORM_STATE + }); + + fetch(`/_replicator/${encodeURIComponent(id)}`, { + credentials: 'include', + headers: { + 'Accept': 'application/json; charset=utf-8', + }, method: 'GET' - }).then((doc) => { + }) + .then(resp => resp.json()) + .then((doc) => { const stateDoc = { replicationDocName: doc._id, replicationType: doc.continuous ? Constants.REPLICATION_TYPE.CONTINUOUS : Constants.REPLICATION_TYPE.ONE_TIME, @@ -209,10 +343,10 @@ const getReplicationStateFrom = (id) => { options: stateDoc }); - }, (xhr) => { - const errorMessage = JSON.parse(xhr.responseText); + }) + .catch(error => { FauxtonAPI.addNotification({ - msg: errorMessage.reason, + msg: error.reason, type: 'error', clear: true }); @@ -238,7 +372,28 @@ const changeActivitySort = (sort) => { }); }; +const changeTabSection = (newSection, url) => { + FauxtonAPI.dispatch({ + type: ActionTypes.REPLICATION_CHANGE_TAB_SECTION, + options: newSection + }); + + if (url) { + FauxtonAPI.navigate(url, {trigger: false}); + } +}; + +const checkForNewApi = () => { + supportNewApi().then(newApi => { + FauxtonAPI.dispatch({ + type: ActionTypes.REPLICATION_SUPPORT_NEW_API, + options: newApi + }); + }); +}; + export default { + checkForNewApi, initReplicator, replicate, updateFormField, @@ -252,5 +407,11 @@ export default { showConflictModal, hideConflictModal, changeActivitySort, - clearSelectedDocs + clearSelectedDocs, + changeTabSection, + getReplicateActivity, + filterReplicate, + selectReplicate, + selectAllReplicates, + deleteReplicates }; diff --git a/app/addons/replication/actiontypes.js b/app/addons/replication/actiontypes.js index f2bb47828..5089ea4ca 100644 --- a/app/addons/replication/actiontypes.js +++ b/app/addons/replication/actiontypes.js @@ -27,5 +27,14 @@ export default { REPLICATION_SHOW_CONFLICT_MODAL: 'REPLICATION_SHOW_CONFLICT_MODAL', REPLICATION_HIDE_CONFLICT_MODAL: 'REPLICATION_HIDE_CONFLICT_MODAL', REPLICATION_CHANGE_ACTIVITY_SORT: 'REPLICATION_CHANGE_ACTIVITY_SORT', - REPLICATION_CLEAR_SELECTED_DOCS: 'REPLICATION_CLEAR_SELECTED_DOCS' + REPLICATION_CLEAR_SELECTED_DOCS: 'REPLICATION_CLEAR_SELECTED_DOCS', + REPLICATION_CHANGE_TAB_SECTION: 'REPLICATION_CHANGE_TAB_SECTION', + REPLICATION_FETCHING_REPLICATE_STATUS: 'REPLICATION_FETCHING_REPLICATE_STATUS', + REPLICATION_REPLICATE_STATUS: 'REPLICATION_REPLICATE_STATUS', + REPLICATION_SUPPORT_NEW_API: 'REPLICATION_SUPPORT_NEW_API', + REPLICATION_FILTER_REPLICATE: 'REPLICATION_FILTER_REPLICATE', + REPLICATION_TOGGLE_ALL_REPLICATE: 'REPLICATION_TOGGLE_ALL_REPLICATE', + REPLICATION_TOGGLE_REPLICATE: 'REPLICATION_TOGGLE_REPLICATE', + REPLICATION_CLEAR_SELECTED_REPLICATES: 'REPLICATION_CLEAR_SELECTED_REPLICATES', + REPLICATION_FETCHING_FORM_STATE: 'REPLICATION_FETCHING_FORM_STATE' }; diff --git a/app/addons/replication/api.js b/app/addons/replication/api.js index 0bda51786..6f2942323 100644 --- a/app/addons/replication/api.js +++ b/app/addons/replication/api.js @@ -16,6 +16,30 @@ import app from '../../app'; import FauxtonAPI from '../../core/api'; import base64 from 'base-64'; import _ from 'lodash'; +import 'whatwg-fetch'; + +let newApiPromise = null; +export const supportNewApi = (forceCheck) => { + if (!newApiPromise || forceCheck) { + newApiPromise = new FauxtonAPI.Promise((resolve) => { + fetch('/_scheduler/jobs', { + credentials: 'include', + headers: { + 'Accept': 'application/json; charset=utf-8', + } + }) + .then(resp => { + if (resp.status > 202) { + return resolve(false); + } + + resolve(true); + }); + }); + } + + return newApiPromise; +}; export const encodeFullUrl = (fullUrl) => { if (!fullUrl) {return '';} @@ -208,6 +232,9 @@ export const removeSensitiveUrlInfo = (url) => { export const getDocUrl = (doc) => { let url = doc; + if (!doc) { + return ''; + } if (typeof doc === "object") { url = doc.url; @@ -228,34 +255,98 @@ export const parseReplicationDocs = (rows) => { status: doc._replication_state, errorMsg: doc._replication_state_reason ? doc._replication_state_reason : '', statusTime: new Date(doc._replication_state_time), - url: `#/database/_replicator/${app.utils.getSafeIdForDoc(doc._id)}`, + startTime: new Date(doc._replication_start_time), + url: `#/database/_replicator/${encodeURIComponent(doc._id)}`, raw: doc }; }); }; +export const convertState = (state) => { + if (state.toLowerCase() === 'error' || state.toLowerCase() === 'crashing') { + return 'retrying'; + } + + return state; +}; + +export const combineDocsAndScheduler = (docs, schedulerDocs) => { + return docs.map(doc => { + const schedule = schedulerDocs.find(s => s.doc_id === doc._id); + if (!schedule) { + return doc; + } + + doc.status = convertState(schedule.state); + if (schedule.start_time) { + doc.startTime = new Date(schedule.start_time); + } + + if (schedule.last_updated) { + doc.stateTime = new Date(schedule.last_updated); + } + + return doc; + }); +}; + export const fetchReplicationDocs = () => { - return $.ajax({ - type: 'GET', - url: '/_replicator/_all_docs?include_docs=true&limit=100', - contentType: 'application/json; charset=utf-8', - dataType: 'json', - }).then((res) => { - return parseReplicationDocs(res.rows.filter(row => row.id.indexOf("_design/_replicator") === -1)); + return supportNewApi() + .then(newApi => { + const docsPromise = fetch('/_replicator/_all_docs?include_docs=true&limit=100', { + credentials: 'include', + headers: { + 'Accept': 'application/json; charset=utf-8', + } + }) + .then(res => res.json()) + .then((res) => { + if (res.error) { + return []; + } + + return parseReplicationDocs(res.rows.filter(row => row.id.indexOf("_design/_replicator") === -1)); + }); + + if (!newApi) { + return docsPromise; + } + const schedulerPromise = fetchSchedulerDocs(); + return FauxtonAPI.Promise.join(docsPromise, schedulerPromise, (docs, schedulerDocs) => { + return combineDocsAndScheduler(docs, schedulerDocs); + }) + .catch(() => { + return []; + }); + }); +}; + +export const fetchSchedulerDocs = () => { + return fetch('/_scheduler/docs?include_docs=true', { + credentials: 'include', + headers: { + 'Accept': 'application/json; charset=utf-8', + } + }) + .then(res => res.json()) + .then((res) => { + if (res.error) { + return []; + } + + return res.docs; }); }; export const checkReplicationDocID = (docId) => { const promise = FauxtonAPI.Deferred(); - $.ajax({ - type: 'GET', - url: `/_replicator/${docId}`, - contentType: 'application/json; charset=utf-8', - dataType: 'json', - }).then(() => { - promise.resolve(true); - }, function (xhr) { - if (xhr.statusText === "Object Not Found") { + fetch(`/_replicator/${docId}`, { + credentials: 'include', + headers: { + 'Accept': 'application/json; charset=utf-8' + }, + }).then(resp => { + if (resp.statusText === "Object Not Found") { promise.resolve(false); return; } @@ -263,3 +354,83 @@ export const checkReplicationDocID = (docId) => { }); return promise; }; + +export const parseReplicateInfo = (resp) => { + return resp.jobs.filter(job => job.database === null).map(job => { + return { + _id: job.id, + source: getDocUrl(job.source.slice(0, job.source.length - 1)), + target: getDocUrl(job.target.slice(0, job.target.length - 1)), + startTime: new Date(job.start_time), + statusTime: new Date(job.last_updated), + //making an asumption here that the first element is the latest + status: convertState(job.history[0].type), + errorMsg: '', + selected: false, + continuous: /continuous/.test(job.id), + raw: job + }; + }); +}; + +export const fetchReplicateInfo = () => { + return supportNewApi() + .then(newApi => { + if (!newApi) { + return []; + } + + return fetch('/_scheduler/jobs', { + credentials: 'include', + headers: { + 'Accept': 'application/json; charset=utf-8' + }, + }) + .then(resp => resp.json()) + .then(resp => { + return parseReplicateInfo(resp); + }); + }); +}; + +export const deleteReplicatesApi = (replicates) => { + const promises = replicates.map(replicate => { + const data = { + replication_id: replicate._id, + cancel: true + }; + + return fetch('/_replicate', { + method: 'POST', + credentials: 'include', + headers: { + 'Accept': 'application/json; charset=utf-8', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + }) + .then(resp => resp.json()); + }); + + return FauxtonAPI.Promise.all(promises); +}; + +export const createReplicatorDB = () => { + return fetch('/_replicator', { + method: 'PUT', + credentials: 'include', + headers: { + 'Accept': 'application/json; charset=utf-8', + } + }) + .then(res => { + if (!res.ok) { + throw {reason: 'Failed to create the _replicator database.'}; + } + + return res.json(); + }) + .then(() => { + return true; + }); +}; diff --git a/app/addons/replication/assets/less/replication.less b/app/addons/replication/assets/less/replication.less index 8d3fe5f0e..828749e7c 100644 --- a/app/addons/replication/assets/less/replication.less +++ b/app/addons/replication/assets/less/replication.less @@ -44,23 +44,27 @@ div.replication__page { width: 540px; select { font-size: 14px; - width: 246px; + width: 400px; margin-bottom: 10px; background-color: white; border: 1px solid #cccccc; } .styled-select { - width: 250px; + width: 400px; } } .replication__input-react-select { font-size: 14px; + .Select .Select-menu-outer { + width: 400px; + } + .Select div.Select-control { padding: 6px; border: 1px solid #cccccc; - width: 246px; + width: 400px; .Select-value, .Select-placeholder { padding: 6px 15px 6px 10px; @@ -80,7 +84,7 @@ div.replication__page { .replication__remote-connection-url[type="text"] { font-size: 14px; - width: 100%; + width: 400px; color: #333; } @@ -91,14 +95,14 @@ div.replication__page { } .replication__new-input[type="text"] { - width: 248px; + width: 400px; font-size: 14px; color: #333; } .replication__doc-name { position: relative; - width: 250px; + width: 400px; } @@ -121,7 +125,7 @@ div.replication__page { .replication__doc-name-input[type="text"] { padding-right: 32px; font-size: 14px; - width: 248px; + width: 400px; color: #333; } @@ -270,6 +274,10 @@ input.replication__bulk-select-input[type="checkbox"] { padding-left: 8px; } +.replication__row-btn--no-left-pad { + padding-left: 0px; +} + .replication__row-btn:visited, .replication__row-btn:hover { text-decoration: none; @@ -290,6 +298,10 @@ input.replication__filter-input[type="text"] { margin-bottom: 0; } +button.replication__error-continue { + margin-left: 20px !important; //needed to override bootstrap +} + .replication__error-cancel, .replication__error-continue { background-color: #0082BF; @@ -311,3 +323,13 @@ td.replication__empty-row { .replication__remote_icon_help:hover { color: #af2d24; } + +.replication__tooltip { + .tooltip-inner { + text-align: left + } +} + +.replication__activity-caveat { + padding-left: 80px; +} diff --git a/app/addons/replication/base.js b/app/addons/replication/base.js index d9ad42a6f..02efe80f5 100644 --- a/app/addons/replication/base.js +++ b/app/addons/replication/base.js @@ -13,9 +13,18 @@ import FauxtonAPI from '../../core/api'; import replication from './route'; import './assets/less/replication.less'; +import Actions from './actions'; replication.initialize = function () { FauxtonAPI.addHeaderLink({ title: 'Replication', href: '#/replication', icon: 'fonticon-replicate' }); + FauxtonAPI.session.on('authenticated', () => { + if (!FauxtonAPI.session.isLoggedIn()) { + //don't check until user is logged in + return; + } + + Actions.checkForNewApi(); + }); }; FauxtonAPI.registerUrls('replication', { diff --git a/app/addons/replication/components/activity.js b/app/addons/replication/components/activity.js index e50f1b847..fb9c75a11 100644 --- a/app/addons/replication/components/activity.js +++ b/app/addons/replication/components/activity.js @@ -10,351 +10,9 @@ // License for the specific language governing permissions and limitations under // the License. import React from 'react'; -import {Table} from "react-bootstrap"; -import moment from 'moment'; -import {DeleteModal, ErrorModal} from './modals'; - -const formatUrl = (url) => { - const urlObj = new URL(url); - const encoded = encodeURIComponent(urlObj.pathname.slice(1)); - - if (url.indexOf(window.location.hostname) > -1) { - return ( - - {urlObj.origin + '/'} - {urlObj.pathname.slice(1)} - - ); - } - - return `${urlObj.origin}${urlObj.pathname}`; -}; - -class RowActions extends React.Component { - constructor (props) { - super(props); - this.state = { - modalVisible: false, - }; - } - - showModal () { - this.setState({modalVisible: true}); - } - - closeModal () { - this.setState({modalVisible: false}); - } - - getErrorIcon () { - if (!this.props.error) { - return null; - } - return ( -
  • - - - -
  • - ); - } - - render () { - const {_id, url, deleteDocs} = this.props; - const errorIcon = this.getErrorIcon(); - return ( - - ); - - } -}; - -RowActions.propTypes = { - _id: React.PropTypes.string.isRequired, - url: React.PropTypes.string.isRequired, - error: React.PropTypes.bool.isRequired, - errorMsg: React.PropTypes.string.isRequired, - deleteDocs: React.PropTypes.func.isRequired -}; - -const Row = ({ - _id, - source, - target, - type, - status, - statusTime, - url, - selected, - selectDoc, - errorMsg, - deleteDocs -}) => { - const momentTime = moment(statusTime); - const formattedTime = momentTime.isValid() ? momentTime.format("MMM Do, h:mm a") : ''; - - return ( - - selectDoc(_id)} /> - {formatUrl(source)} - {formatUrl(target)} - {type} - {status} - {formattedTime} - - - - - - ); -}; - -Row.propTypes = { - _id: React.PropTypes.string.isRequired, - source: React.PropTypes.string.isRequired, - target: React.PropTypes.string.isRequired, - type: React.PropTypes.string.isRequired, - status: React.PropTypes.string, - url: React.PropTypes.string.isRequired, - statusTime: React.PropTypes.object.isRequired, - selected: React.PropTypes.bool.isRequired, - selectDoc: React.PropTypes.func.isRequired, - errorMsg: React.PropTypes.string.isRequired, - deleteDocs: React.PropTypes.func.isRequired -}; - -const BulkSelectHeader = ({isSelected, deleteDocs, someDocsSelected, onCheck}) => { - const trash = someDocsSelected ? - : null; - - return ( -
    -
    - -
    - {trash} -
    - ); -}; - -BulkSelectHeader.propTypes = { - isSelected: React.PropTypes.bool.isRequired, - someDocsSelected: React.PropTypes.bool.isRequired, - onCheck: React.PropTypes.func.isRequired, - deleteDocs: React.PropTypes.func.isRequired -}; - -const EmptyRow = () => - - - There is no replicator-db activity or history to display. - - ; - -class ReplicationTable extends React.Component { - constructor (props) { - super(props); - } - - sort(column, descending, docs) { - const sorted = docs.sort((a, b) => { - if (a[column] < b[column]) { - return -1; - } - - if (a[column] > b[column]) { - return 1; - } - - return 0; - - }); - - if (!descending) { - sorted.reverse(); - } - - return sorted; - } - - renderRows () { - if (this.props.docs.length === 0) { - return ; - } - - return this.sort(this.props.column, this.props.descending, this.props.docs).map((doc, i) => { - return ; - }); - } - - iconDirection (column) { - if (column === this.props.column && !this.props.descending) { - return 'fonticon-up-dir'; - } - - return 'fonticon-down-dir'; - } - - onSort (column) { - return () => { - this.props.changeSort({ - descending: column === this.props.column ? !this.props.descending : true, - column - }); - }; - } - - isSelected (header) { - if (header === this.props.column) { - return 'replication__table--selected'; - } - - return ''; - } - - render () { - return ( - - - - - - - - - - - - - - {this.renderRows()} - -
    - - - Source - - - Target - - - Type - - - State - - - State Time - - - Actions -
    - ); - } -} - -const ReplicationFilter = ({value, onChange}) => { - return ( -
    - - {onChange(e.target.value);}} - /> -
    - ); -}; - -ReplicationFilter.propTypes = { - value: React.PropTypes.string.isRequired, - onChange: React.PropTypes.func.isRequired -}; - -const ReplicationHeader = ({filter, onFilterChange}) => { - return ( -
    -
    - - - - New Replication - -
    - ); -}; - -ReplicationHeader.propTypes = { - filter: React.PropTypes.string.isRequired, - onFilterChange: React.PropTypes.func.isRequired -}; +import {DeleteModal} from './modals'; +import {ReplicationTable} from './common-table'; +import {ReplicationHeader} from './common-activity'; export default class Activity extends React.Component { constructor (props) { @@ -412,6 +70,9 @@ export default class Activity extends React.Component { const {modalVisible} = this.state; return (
    +

    + Replications must have a replication document to display in the following table. +

    { + return ( +
    + + {onChange(e.target.value);}} + /> +
    + ); +}; + +ReplicationFilter.propTypes = { + value: React.PropTypes.string.isRequired, + onChange: React.PropTypes.func.isRequired +}; + +export const ReplicationHeader = ({filter, onFilterChange}) => { + return ( + + ); +}; + +ReplicationHeader.propTypes = { + filter: React.PropTypes.string.isRequired, + onFilterChange: React.PropTypes.func.isRequired +}; diff --git a/app/addons/replication/components/common-table.js b/app/addons/replication/components/common-table.js new file mode 100644 index 000000000..4f287c545 --- /dev/null +++ b/app/addons/replication/components/common-table.js @@ -0,0 +1,405 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); you may not +// use this file except in compliance with the License. You may obtain a copy of +// the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations under +// the License. +import React from 'react'; +import {Table, Tooltip, OverlayTrigger} from "react-bootstrap"; +import moment from 'moment'; +import {ErrorModal} from './modals'; + +const formatUrl = (url) => { + const urlObj = new URL(url); + const encoded = encodeURIComponent(urlObj.pathname.slice(1)); + + if (url.indexOf(window.location.hostname) > -1) { + return ( + + {urlObj.origin + '/'} + {urlObj.pathname.slice(1)} + + ); + } + + return `${urlObj.origin}${urlObj.pathname}`; +}; + +class RowStatus extends React.Component { + constructor (props) { + super(props); + this.state = { + modalVisible: false, + }; + } + + showModal () { + this.setState({modalVisible: true}); + } + + closeModal () { + this.setState({modalVisible: false}); + } + + getErrorIcon () { + const {status} = this.props; + if (status !== 'error' && status !== 'retrying') { + return null; + } + + return ( + + + + + + ); + } + + render () { + const {statusTime, status} = this.props; + let momentTime = moment(statusTime); + let statusValue = {status}; + + if (momentTime.isValid()) { + const formattedStatusTime = momentTime.format("MMM Do, h:mm a"); + const stateTimeTooltip = Last updated: {formattedStatusTime}; + statusValue = + + {status} + ; + } + + return ( + + {statusValue} + {this.getErrorIcon()} + + ); + } +}; + +RowStatus.propTypes = { + statusTime: React.PropTypes.any, + status: React.PropTypes.string, + errorMsg: React.PropTypes.string.isRequired, +}; + +RowStatus.defaultProps = { + status: '' +}; + +const RowActions = ({onlyDeleteAction, _id, url, deleteDocs}) => { + const actions = []; + if (!onlyDeleteAction) { + actions.push( +
  • + + +
  • + ); + actions.push( +
  • + + +
  • + ); + } + + actions.push( +
  • + deleteDocs(_id)}> + +
  • + ); + + return ( +
      + {actions} +
    + ); +}; + +RowActions.propTypes = { + _id: React.PropTypes.string.isRequired, + url: React.PropTypes.string, + error: React.PropTypes.bool.isRequired, + errorMsg: React.PropTypes.string.isRequired, + deleteDocs: React.PropTypes.func.isRequired +}; + +const Row = ({ + _id, + source, + target, + type, + startTime, + status, + statusTime, + url, + selected, + selectDoc, + errorMsg, + deleteDocs, + onlyDeleteAction, + showStateRow +}) => { + let momentTime = moment(startTime); + const formattedStartTime = momentTime.isValid() ? momentTime.format("MMM Do, h:mm a") : ''; + let stateRow = null; + + if (showStateRow) { + stateRow = ; + } + + return ( + + selectDoc(_id)} /> + {formatUrl(source)} + {formatUrl(target)} + {formattedStartTime} + {type} + {stateRow} + + + + + + ); +}; + +Row.propTypes = { + _id: React.PropTypes.string.isRequired, + source: React.PropTypes.string.isRequired, + target: React.PropTypes.string.isRequired, + type: React.PropTypes.string.isRequired, + status: React.PropTypes.string, + url: React.PropTypes.string, + statusTime: React.PropTypes.object.isRequired, + startTime: React.PropTypes.object, + selected: React.PropTypes.bool.isRequired, + selectDoc: React.PropTypes.func.isRequired, + errorMsg: React.PropTypes.string.isRequired, + deleteDocs: React.PropTypes.func.isRequired, + onlyDeleteAction: React.PropTypes.bool.isRequired, + showStateRow: React.PropTypes.bool.isRequired +}; + +const BulkSelectHeader = ({isSelected, deleteDocs, someDocsSelected, onCheck}) => { + const trash = someDocsSelected ? + : null; + + return ( +
    +
    + +
    + {trash} +
    + ); +}; + +BulkSelectHeader.propTypes = { + isSelected: React.PropTypes.bool.isRequired, + someDocsSelected: React.PropTypes.bool.isRequired, + onCheck: React.PropTypes.func.isRequired, + deleteDocs: React.PropTypes.func.isRequired +}; + +const EmptyRow = ({msg}) => { + return ( + + + {msg} + + + ); +}; + +EmptyRow.defaultProps = { + msg: "There is no replicator-db activity or history to display." +}; + + +export class ReplicationTable extends React.Component { + constructor (props) { + super(props); + } + + sort(column, descending, docs) { + const sorted = docs.sort((a, b) => { + if (a[column] < b[column]) { + return -1; + } + + if (a[column] > b[column]) { + return 1; + } + + return 0; + + }); + + if (!descending) { + sorted.reverse(); + } + + return sorted; + } + + renderRows () { + if (this.props.docs.length === 0) { + return ; + } + + return this.sort(this.props.column, this.props.descending, this.props.docs).map((doc, i) => { + return ; + }); + } + + iconDirection (column) { + if (column === this.props.column && !this.props.descending) { + return 'fonticon-up-dir'; + } + + return 'fonticon-down-dir'; + } + + onSort (column) { + return () => { + this.props.changeSort({ + descending: column === this.props.column ? !this.props.descending : true, + column + }); + }; + } + + isSelected (header) { + if (header === this.props.column) { + return 'replication__table--selected'; + } + + return ''; + } + + stateCol () { + if (this.props.showStateRow) { + return ( + + State + + + ); + } + + return null; + } + + render () { + + return ( + + + + + + + + + {this.stateCol()} + + + + + {this.renderRows()} + +
    + + + Source + + + Target + + + Start Time + + + Type + + + Actions +
    + ); + } +} + +ReplicationTable.defaultProps = { + onlyDeleteAction: false, + showStateRow: true +}; diff --git a/app/addons/replication/components/modals.js b/app/addons/replication/components/modals.js index b51616ed3..9ef7c96a9 100644 --- a/app/addons/replication/components/modals.js +++ b/app/addons/replication/components/modals.js @@ -20,17 +20,22 @@ export const DeleteModal = ({ visible, onClose, onClick, - multipleDocs + multipleDocs, + isReplicationDB }) => { if (!visible) { return null; } - let header = "You are deleting a replication document."; + let header = ""; + let btnText = `Delete ${isReplicationDB ? 'Document' : 'Replication Job'}`; + let infoSection = `Deleting a replication ${isReplicationDB ? 'document' : 'job'} stops continuous replication + and incomplete one-time replication, but does not affect replicated documents.`; if (multipleDocs > 1) { - header = `You are deleting ${multipleDocs} replication documents.`; + header = `You are deleting ${multipleDocs} replication ${isReplicationDB ? 'documents' : 'jobs'}.`; + btnText = `Delete ${isReplicationDB ? 'Documents' : 'Replication Jobs'}`; } return ( @@ -39,19 +44,14 @@ export const DeleteModal = ({ Verify Deletion -

    {header}

    -

    - Deleting a replication document stops continuous replication - and incomplete one-time replication, but does not affect replicated documents. -

    -

    - Replication jobs that do not have replication documents do not appear in Replicator DB Activity. -

    +

    +

    {infoSection}

    Cancel @@ -61,26 +61,40 @@ export const DeleteModal = ({ DeleteModal.propTypes = { visible: React.PropTypes.bool.isRequired, + isReplicationDB: React.PropTypes.bool.isRequired, onClick: React.PropTypes.func.isRequired, onClose: React.PropTypes.func.isRequired, multipleDocs: React.PropTypes.number.isRequired }; -export const ErrorModal = ({visible, onClose, errorMsg}) => { +DeleteModal.defaultProps = { + isReplicationDB: true +}; + +export const ErrorModal = ({visible, onClose, errorMsg, status}) => { if (!visible) { return null; } + let title = "Replication Error"; + let warning =

    The replication job will be tried at increasing intervals

    ; + + if (status.toLowerCase() === 'failed') { + title = "Replication Error - Failed"; + warning = null; + } + return ( onClose()}> - Replication Error + {title}

    {errorMsg}

    + {warning}
    @@ -104,7 +118,7 @@ export const ConflictModal = ({visible, docId, onClose, onClick}) => { return ( onClose()}> - Fix Document Conflict + Custom ID Conflict

    @@ -123,7 +137,6 @@ export const ConflictModal = ({visible, docId, onClose, onClick}) => { Change Document ID diff --git a/app/addons/replication/components/remoteexample.js b/app/addons/replication/components/remoteexample.js index 1992b325a..ddde70a70 100644 --- a/app/addons/replication/components/remoteexample.js +++ b/app/addons/replication/components/remoteexample.js @@ -13,7 +13,7 @@ import React from 'react'; import {OverlayTrigger, Tooltip} from 'react-bootstrap'; const tooltipExisting = ( - +

    If you know the credentials for the remote account, you can use that remote username and password.

    @@ -21,13 +21,13 @@ const tooltipExisting = ( If a remote database granted permissions to your local account, you can use the local-account username and password.

    - If the remote database granted permissions to "everybody," you do not need to enter a username and password. + If the remote database granted permissions to unauthenticated connections, you do not need to enter a username or password.

    ); const tooltipNew = ( - + Enter the username and password of the remote account. ); diff --git a/app/addons/replication/components/replicate-activity.js b/app/addons/replication/components/replicate-activity.js new file mode 100644 index 000000000..11967933c --- /dev/null +++ b/app/addons/replication/components/replicate-activity.js @@ -0,0 +1,116 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); you may not +// use this file except in compliance with the License. You may obtain a copy of +// the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations under +// the License. +import React from 'react'; +import {DeleteModal} from './modals'; +import {ReplicationTable} from './common-table'; +import {ReplicationHeader} from './common-activity'; + +export default class Activity extends React.Component { + constructor (props) { + super(props); + this.state = { + modalVisible: false, + unconfirmedDeleteDocId: null + }; + } + + closeModal () { + this.setState({ + modalVisible: false, + unconfirmedDeleteDocId: null + }); + } + + showModal (docId) { + this.setState({ + modalVisible: true, + unconfirmedDeleteDocId: docId + }); + } + + confirmDeleteDocs () { + let docs = []; + if (this.state.unconfirmedDeleteDocId) { + const doc = this.props.docs.find(doc => doc._id === this.state.unconfirmedDeleteDocId); + docs.push(doc); + } else { + docs = this.props.docs.filter(doc => doc.selected); + } + + this.props.deleteDocs(docs); + this.closeModal(); + } + + numDocsSelected () { + return this.props.docs.filter(doc => doc.selected).length; + } + + render () { + const { + onFilterChange, + activitySort, + changeActivitySort, + docs, + filter, + selectAllDocs, + someDocsSelected, + allDocsSelected, + selectDoc + } = this.props; + + const {modalVisible} = this.state; + return ( +
    +

    + Active _replicate jobs are displayed. Completed and failed jobs are not. +

    + + + +
    + ); + } +} + +Activity.propTypes = { + docs: React.PropTypes.array.isRequired, + filter: React.PropTypes.string.isRequired, + selectAllDocs: React.PropTypes.func.isRequired, + allDocsSelected: React.PropTypes.bool.isRequired, + someDocsSelected: React.PropTypes.bool.isRequired, + selectDoc: React.PropTypes.func.isRequired, + onFilterChange: React.PropTypes.func.isRequired, + deleteDocs: React.PropTypes.func.isRequired, + activitySort: React.PropTypes.object.isRequired, + changeActivitySort: React.PropTypes.func.isRequired +}; diff --git a/app/addons/replication/components/submit.js b/app/addons/replication/components/submit.js index cc921b3be..f82fd9254 100644 --- a/app/addons/replication/components/submit.js +++ b/app/addons/replication/components/submit.js @@ -17,6 +17,7 @@ const {ConfirmButton} = Components; export const ReplicationSubmit = ({onClear, disabled, onClick}) =>
    ; } @@ -141,11 +157,30 @@ export default class ReplicationController extends React.Component { />; } + if (tabSection === '_replicate') { + if (replicateLoading) { + return ; + } + + return ; + } + if (activityLoading) { return ; } - return ; } - getCrumbs () { - if (this.props.section === 'new replication') { - return [ - {name: 'Replication', link: 'replication'}, - {name: 'New Replication'} - ]; - } - - return [ - {name: 'Replication'} - ]; - } - getHeaderComponents () { - if (this.props.section === 'new replication') { + if (this.state.tabSection === 'new replication') { return null; } @@ -186,25 +208,79 @@ export default class ReplicationController extends React.Component { max={600} startValue={300} stepSize={60} - onPoll={Actions.getReplicationActivity} + onPoll={this.getAllActivity.bind(this)} />
    ); } + getTabElements () { + const {tabSection} = this.state; + const elements = [ + + ]; + + if (this.state.supportNewApi) { + elements.push( + + ); + } + + return elements; + } + + onTabChange (section, url) { + Actions.changeTabSection(section, url); + } + + getCrumbs () { + if (this.state.tabSection === 'new replication') { + return [{'name': 'Job Configuration'}]; + } + + return []; + } + + getTabs () { + if (this.state.tabSection === 'new replication') { + return null; + } + + return ( + + {this.getTabElements()} + + ); + } + render () { + const { checkingAPI } = this.state; + + if (checkingAPI) { + return ; + } + return ( - + {this.getHeaderComponents()}
    + {this.getTabs()}
    {this.showSection()}
    diff --git a/app/addons/replication/route.js b/app/addons/replication/route.js index 70b66a429..95719b482 100644 --- a/app/addons/replication/route.js +++ b/app/addons/replication/route.js @@ -13,13 +13,15 @@ import React from 'react'; import FauxtonAPI from '../../core/api'; import ReplicationController from './controller'; +import Actions from './actions'; const ReplicationRouteObject = FauxtonAPI.RouteObject.extend({ routes: { 'replication/_create': 'defaultView', - 'replication/:dbname': 'defaultView', + 'replication/_create/:dbname': 'defaultView', 'replication/id/:id': 'fromId', - 'replication': 'activityView' + 'replication': 'activityView', + 'replication/_replicate': 'replicateView' }, selectedHeader: 'Replication', @@ -40,24 +42,30 @@ const ReplicationRouteObject = FauxtonAPI.RouteObject.extend({ defaultView: function (databaseName) { const localSource = databaseName || ''; + Actions.changeTabSection('new replication'); + Actions.clearReplicationForm(); return ; }, fromId: function (replicationId) { + Actions.clearReplicationForm(); + Actions.changeTabSection('new replication'); return ; }, activityView: function () { - return ; + Actions.changeTabSection('activity'); + return ; + }, + + replicateView: function () { + Actions.changeTabSection('_replicate'); + return ; } }); diff --git a/app/addons/replication/stores.js b/app/addons/replication/stores.js index 715ea23ea..19badc7d4 100644 --- a/app/addons/replication/stores.js +++ b/app/addons/replication/stores.js @@ -16,6 +16,20 @@ import Constants from './constants'; import AccountActionTypes from '../auth/actiontypes'; import _ from 'lodash'; +// I know this could be done by just adding the _ prefix to the passed field name, I just don't much like relying +// on the var names like that... +const validFieldMap = { + remoteSource: '_remoteSource', + remoteTarget: '_remoteTarget', + localTarget: '_localTarget', + replicationType: '_replicationType', + replicationDocName: '_replicationDocName', + replicationSource: '_replicationSource', + replicationTarget: '_replicationTarget', + localSource: '_localSource', + replicationDocName: '_replicationDocName' +}; + const ReplicationStore = FauxtonAPI.Store.extend({ initialize () { this.reset(); @@ -46,12 +60,30 @@ const ReplicationStore = FauxtonAPI.Store.extend({ this._statusDocs = []; this._statusFilteredStatusDocs = []; this._statusFilter = ''; + this._replicateFilter = ''; this._allDocsSelected = false; + this._allReplicateSelected = false; this._username = ''; this._password = ''; this._activityLoading = false; + this._tabSection = 'new replication'; + this._supportNewApi = true; this.loadActivitySort(); + + this._fetchingReplicateInfo = false; + this._replicateInfo = []; + + this._checkingAPI = true; + this._supportNewApi = false; + }, + + supportNewApi () { + return this._supportNewApi; + }, + + checkingAPI () { + return this._checkingAPI; }, getActivitySort () { @@ -59,13 +91,14 @@ const ReplicationStore = FauxtonAPI.Store.extend({ }, loadActivitySort () { - let sort = app.utils.localStorageGet('replication-activity-sort'); - if (!sort) { - sort = { + const defaultSort = { descending: false, column: 'statusTime' }; + let sort = app.utils.localStorageGet('replication-activity-sort'); + if (!sort) { + sort = defaultSort; this.setActivitySort(sort); } @@ -77,6 +110,23 @@ const ReplicationStore = FauxtonAPI.Store.extend({ this._activitySort = sort; }, + isReplicateInfoLoading () { + return this._fetchingReplicateInfo; + }, + + getReplicateInfo () { + return this._replicateInfo.filter(doc => { + return _.values(doc).filter(item => { + if (!item) {return false;} + return item.toString().toLowerCase().match(this._replicateFilter); + }).length > 0; + }); + }, + + setReplicateInfo (info) { + this._replicateInfo = info; + }, + setCredentials (username, password) { this._username = username; this._password = password; @@ -177,6 +227,29 @@ const ReplicationStore = FauxtonAPI.Store.extend({ this._allDocsSelected = false; }, + selectReplicate (id) { + const doc = this._replicateInfo.find(doc => doc._id === id); + if (!doc) { + return; + } + + doc.selected = !doc.selected; + this._allReplicateSelected = false; + }, + + selectAllReplicate () { + this._allReplicateSelected = !this._allReplicateSelected; + this.getReplicateInfo().forEach(doc => doc.selected = this._allReplicateSelected); + }, + + someReplicateSelected () { + return this.getReplicateInfo().some(doc => doc.selected); + }, + + getAllReplicateSelected () { + return this._allReplicateSelected; + }, + selectAllDocs () { this._allDocsSelected = !this._allDocsSelected; this.getFilteredReplicationStatus().forEach(doc => doc.selected = this._allDocsSelected); @@ -197,25 +270,23 @@ const ReplicationStore = FauxtonAPI.Store.extend({ getStatusFilter () { return this._statusFilter; }, - // to cut down on boilerplate - updateFormField (fieldName, value) { - // I know this could be done by just adding the _ prefix to the passed field name, I just don't much like relying - // on the var names like that... - var validFieldMap = { - remoteSource: '_remoteSource', - remoteTarget: '_remoteTarget', - localTarget: '_localTarget', - replicationType: '_replicationType', - replicationDocName: '_replicationDocName', - replicationSource: '_replicationSource', - replicationTarget: '_replicationTarget', - localSource: '_localSource' - }; + setReplicateFilter (filter) { + this._replicateFilter = filter; + }, + getReplicateFilter () { + return this._replicateFilter; + }, + // to cut down on boilerplate + updateFormField (fieldName, value) { this[validFieldMap[fieldName]] = value; }, + clearReplicationForm () { + _.values(validFieldMap).forEach(fieldName => this[fieldName] = ''); + }, + getRemoteSource () { return this._remoteSource; }, @@ -242,6 +313,10 @@ const ReplicationStore = FauxtonAPI.Store.extend({ }); }, + getTabSection () { + return this._tabSection; + }, + dispatch ({type, options}) { switch (type) { @@ -263,13 +338,17 @@ const ReplicationStore = FauxtonAPI.Store.extend({ this._loading = false; break; + case ActionTypes.REPLICATION_FETCHING_FORM_STATE: + this._loading = true; + break; + case ActionTypes.REPLICATION_UPDATE_FORM_FIELD: this.changeAfterSubmit(); this.updateFormField(options.fieldName, options.value); break; case ActionTypes.REPLICATION_CLEAR_FORM: - this.reset(); + this.clearReplicationForm(); break; case ActionTypes.REPLICATION_STARTING: @@ -289,6 +368,10 @@ const ReplicationStore = FauxtonAPI.Store.extend({ this.setStatusFilter(options); break; + case ActionTypes.REPLICATION_FILTER_REPLICATE: + this.setReplicateFilter(options); + break; + case ActionTypes.REPLICATION_TOGGLE_DOC: this.selectDoc(options); break; @@ -297,7 +380,16 @@ const ReplicationStore = FauxtonAPI.Store.extend({ this.selectAllDocs(); break; + case ActionTypes.REPLICATION_TOGGLE_REPLICATE: + this.selectReplicate(options); + break; + + case ActionTypes.REPLICATION_TOGGLE_ALL_REPLICATE: + this.selectAllReplicate(); + break; + case ActionTypes.REPLICATION_SET_STATE_FROM_DOC: + this._loading = false; this.setStateFromDoc(options); break; @@ -317,6 +409,32 @@ const ReplicationStore = FauxtonAPI.Store.extend({ this._allDocsSelected = false; break; + case ActionTypes.REPLICATION_CHANGE_TAB_SECTION: + this._tabSection = options; + break; + + case ActionTypes.REPLICATION_CLEAR_SELECTED_DOCS: + this._allDocsSelected = false; + break; + + case ActionTypes.REPLICATION_SUPPORT_NEW_API: + this._checkingAPI = false; + this._supportNewApi = options; + break; + + case ActionTypes.REPLICATION_FETCHING_REPLICATE_STATUS: + this._fetchingReplicateInfo = true; + break; + + case ActionTypes.REPLICATION_REPLICATE_STATUS: + this._fetchingReplicateInfo = false; + this.setReplicateInfo(options); + break; + + case ActionTypes.REPLICATION_CLEAR_SELECTED_REPLICATES: + this._allReplicateSelected = false; + break; + case AccountActionTypes.AUTH_SHOW_PASSWORD_MODAL: this._isPasswordModalVisible = true; break; diff --git a/app/addons/replication/tests/nightwatch/replication.js b/app/addons/replication/tests/nightwatch/replication.js index ba852d58b..c523c8fa3 100644 --- a/app/addons/replication/tests/nightwatch/replication.js +++ b/app/addons/replication/tests/nightwatch/replication.js @@ -46,13 +46,13 @@ module.exports = { .checkForDatabaseCreated(newDatabaseName1, waitTime) .createDocument(docName1, newDatabaseName1) .loginToGUI() - .url(baseUrl + '/#replication/_create') + .url(baseUrl + '/#/replication/_create') .waitForElementVisible('button#replicate', waitTime, true) .waitForElementVisible('#replication-source', waitTime, true) // select LOCAL as the source .clickWhenVisible('#replication-source') - .keys(['\uE006']) + .keys(['\uE015', '\uE006']) .waitForElementVisible('.replication__input-react-select', waitTime, true) // enter our source DB @@ -91,13 +91,13 @@ module.exports = { // now login and fill in the replication form .loginToGUI() - .url(baseUrl + '/#replication/_create') + .url(baseUrl + '/#/replication/_create') .waitForElementVisible('button#replicate', waitTime, true) .waitForElementVisible('#replication-source', waitTime, true) // select the LOCAL db as the source .clickWhenVisible('#replication-source') - .keys(['\uE006']) + .keys(['\uE015', '\uE006']) .waitForElementVisible('.replication__input-react-select', waitTime, true) .setValue('.replication__input-react-select .Select-input input', [newDatabaseName1, client.Keys.ENTER]) @@ -142,13 +142,13 @@ module.exports = { // now login and fill in the replication form .loginToGUI() - .url(baseUrl + '/#replication/_create') + .url(baseUrl + '/#/replication/_create') .waitForElementVisible('button#replicate', waitTime, true) .waitForElementVisible('#replication-source', waitTime, true) // select the LOCAL db as the source .clickWhenVisible('#replication-source') - .keys(['\uE006']) + .keys(['\uE015', '\uE006']) .waitForElementVisible('.replication__input-react-select', waitTime, true) .setValue('.replication__input-react-select .Select-input input', [newDatabaseName1, client.Keys.ENTER]) diff --git a/app/core/api.js b/app/core/api.js index efcb7f9bc..20c9f0d89 100644 --- a/app/core/api.js +++ b/app/core/api.js @@ -20,6 +20,7 @@ import Flux from "flux"; import $ from "jquery"; import Backbone from "backbone"; import _ from "lodash"; +import Promise from "bluebird"; Backbone.$ = $; Backbone.ajax = function () { @@ -32,7 +33,8 @@ Object.assign(FauxtonAPI, { utils: utils, Store: Store, Events: _.extend({}, Backbone.Events), - dispatcher: new Flux.Dispatcher() + dispatcher: new Flux.Dispatcher(), + Promise: Promise }); // Pass along all constants diff --git a/assets/less/notification-center.less b/assets/less/notification-center.less index 07935b3b9..ae606414f 100644 --- a/assets/less/notification-center.less +++ b/assets/less/notification-center.less @@ -145,6 +145,7 @@ body #dashboard #notification-center-btn { } p { margin-bottom: 0; + overflow-wrap: break-word; } div.flex-body { overflow: hidden; diff --git a/docker/dc.selenium.yml b/docker/dc.selenium.yml index 41ddd2bef..5d1c3855b 100644 --- a/docker/dc.selenium.yml +++ b/docker/dc.selenium.yml @@ -11,4 +11,4 @@ services: image: klaemo/couchdb:2.0-dev@sha256:e9b71abaff6aeaa34ee28604c3aeb78f3a7c789ad74a7b88148e2ef78f1e3b21 command: '--with-haproxy -a tester:testerpass' ports: - - "5984:5984" + - "5984:5984" \ No newline at end of file diff --git a/jest-setup.js b/jest-setup.js index 5b28e7780..ec630562d 100644 --- a/jest-setup.js +++ b/jest-setup.js @@ -17,4 +17,9 @@ window.$ = window.jQuery = require('jquery'); window._ = require('lodash'); window.Backbone = require('backbone'); +Object.defineProperty(window.location, 'origin', { + writable: true, + value: 'http://dev:8000' +}); +