From aa20cf8b8345f62f4f74a74ad724929996fa7975 Mon Sep 17 00:00:00 2001 From: Garren Smith Date: Tue, 17 Jan 2017 09:25:19 +0200 Subject: [PATCH 01/21] basic setup replication rework --- app/addons/replication/__tests__/api.tests.js | 223 +++++------- .../storesSpec.js => __tests__/stores.tests.js} | 0 app/addons/replication/actions.js | 47 ++- app/addons/replication/actiontypes.js | 6 +- app/addons/replication/api.js | 88 ++++- .../replication/assets/less/replication.less | 2 +- app/addons/replication/base.js | 2 + app/addons/replication/components/activity.js | 348 +------------------ .../replication/components/common-activity.js | 102 ++++++ app/addons/replication/components/common-table.js | 375 +++++++++++++++++++++ app/addons/replication/components/modals.js | 20 +- .../replication/components/replicateActivity.js | 111 ++++++ app/addons/replication/constants.js | 1 - app/addons/replication/controller.js | 103 ++++-- app/addons/replication/route.js | 19 +- app/addons/replication/stores.js | 87 ++++- app/core/api.js | 4 +- 17 files changed, 1001 insertions(+), 537 deletions(-) rename app/addons/replication/{tests/storesSpec.js => __tests__/stores.tests.js} (100%) create mode 100644 app/addons/replication/components/common-activity.js create mode 100644 app/addons/replication/components/common-table.js create mode 100644 app/addons/replication/components/replicateActivity.js diff --git a/app/addons/replication/__tests__/api.tests.js b/app/addons/replication/__tests__/api.tests.js index 3ebb79273..4051b66a5 100644 --- a/app/addons/replication/__tests__/api.tests.js +++ b/app/addons/replication/__tests__/api.tests.js @@ -17,11 +17,7 @@ import { createTarget, addDocIdAndRev, getDocUrl, - encodeFullUrl, - decodeFullUrl, - getCredentialsFromUrl, - removeCredentialsFromUrl, - removeSensitiveUrlInfo + combineDocsAndScheduler } from '../api'; import Constants from '../constants'; @@ -29,26 +25,6 @@ const assert = utils.assert; describe('Replication API', () => { - describe("removeSensiteiveUrlInfo", () => { - it('removes password username', () => { - const url = 'http://tester:testerpass@127.0.0.1/fancy/db/name'; - - const res = removeSensitiveUrlInfo(url); - - expect(res).toBe('http://127.0.0.1/fancy/db/name'); - }); - - // see https://issues.apache.org/jira/browse/COUCHDB-3257 - // CouchDB accepts and returns invalid urls - it('does not throw on invalid urls', () => { - const url = 'http://tester:tes#terpass@127.0.0.1/fancy/db/name'; - - const res = removeSensitiveUrlInfo(url); - - expect(res).toBe('http://tester:tes#terpass@127.0.0.1/fancy/db/name'); - }); - }); - describe('getSource', () => { it('encodes remote db', () => { @@ -58,7 +34,7 @@ describe('Replication API', () => { remoteSource }); - assert.deepEqual(source.url, 'http://remote-couchdb.com/my%2Fdb%2Fhere'); + assert.deepEqual(source, 'http://remote-couchdb.com/my%2Fdb%2Fhere'); }); it('returns local source with auth info and encoded', () => { @@ -69,24 +45,11 @@ describe('Replication API', () => { localSource, username: 'the-user', password: 'password' - }, {origin: 'http://dev:6767'}); + }); assert.deepEqual(source.headers, {Authorization:"Basic dGhlLXVzZXI6cGFzc3dvcmQ="}); assert.ok(/my%2Fdb/.test(source.url)); }); - - it('returns remote source url and auth header', () => { - const source = getSource({ - replicationSource: Constants.REPLICATION_SOURCE.REMOTE, - remoteSource: 'http://eddie:my-password@my-couchdb.com/my-db', - localSource: "local", - username: 'the-user', - password: 'password' - }, {origin: 'http://dev:6767'}); - - assert.deepEqual(source.headers, {Authorization:"Basic ZWRkaWU6bXktcGFzc3dvcmQ="}); - assert.deepEqual('http://my-couchdb.com/my-db', source.url); - }); }); describe('getTarget', () => { @@ -97,20 +60,7 @@ describe('Replication API', () => { assert.deepEqual("http://remote-couchdb.com/my%2Fdb", getTarget({ replicationTarget: Constants.REPLICATION_TARGET.NEW_REMOTE_DATABASE, remoteTarget: remoteTarget - }).url); - }); - - it("encodes username and password for remote", () => { - const remoteTarget = 'http://jimi:my-password@remote-couchdb.com/my/db'; - const target = getTarget({ - replicationTarget: Constants.REPLICATION_TARGET.NEW_REMOTE_DATABASE, - remoteTarget: remoteTarget, - username: 'fake', - password: 'fake' - }); - - assert.deepEqual(target.url, 'http://remote-couchdb.com/my%2Fdb'); - assert.deepEqual(target.headers, {Authorization:"Basic amltaTpteS1wYXNzd29yZA=="}); + })); }); it('returns existing local database', () => { @@ -145,33 +95,11 @@ describe('Replication API', () => { localTarget: 'my-new/db', username: 'the-user', password: 'password' - }, {origin: 'http://dev:5555'}); - - assert.deepEqual(target.headers, {Authorization:"Basic dGhlLXVzZXI6cGFzc3dvcmQ="}); - assert.ok(/my-new%2Fdb/.test(target.url)); - }); - - it("doesn't encode username and password if it is not supplied", () => { - const location = { - host: "dev:8000", - hostname: "dev", - href: "http://dev:8000/#database/animaldb/_all_docs", - origin: "http://dev:8000", - pathname: "/", - port: "8000", - protocol: "http:", - }; - - const target = getTarget({ - replicationTarget: Constants.REPLICATION_TARGET.NEW_LOCAL_DATABASE, - replicationSource: Constants.REPLICATION_SOURCE.REMOTE, - localTarget: 'my-new/db' - }, location); + }); - assert.deepEqual("http://dev:8000/my-new%2Fdb", target.url); - assert.deepEqual({}, target.headers); + assert.ok(/the-user:password@/.test(target)); + assert.ok(/my-new%2Fdb/.test(target)); }); - }); describe('continuous', () => { @@ -238,67 +166,86 @@ describe('Replication API', () => { }); }); - describe("encodeFullUrl", () => { - it("encodes db correctly", () => { - const url = "http://dev:5984/boom/aaaa"; - const encodedUrl = encodeFullUrl(url); - - assert.deepEqual("http://dev:5984/boom%2Faaaa", encodedUrl); - }); - - }); - - describe("decodeFullUrl", () => { - - it("encodes db correctly", () => { - const url = "http://dev:5984/boom%2Faaaa"; - const encodedUrl = decodeFullUrl(url); - - assert.deepEqual("http://dev:5984/boom/aaaa", encodedUrl); - }); - - }); - - describe("getCredentialsFromUrl", () => { - - it("can get username and password", () => { - const {username, password } = getCredentialsFromUrl("https://bob:marley@my-couchdb.com/db"); - - assert.deepEqual(username, 'bob'); - assert.deepEqual(password, 'marley'); - }); - - it("can get username and password with special characters", () => { - const {username, password } = getCredentialsFromUrl("http://bob:m@:/rley@my-couchdb.com/db"); - - assert.deepEqual(username, 'bob'); - assert.deepEqual(password, 'm@:/rley'); - }); + describe("combine docs and schedulerDocs", () => { + const docs = [ + { + "_id": "4cb18c43bf16b2f953026d4fd100073e", + "_rev": "5-1163fecc6516aae02e031153ab4d5d19", + "selected": false, + "source": "https://examples.couchdb.com/db1", + "target": "https://examples.couchdb.com/db1-rep", + "createTarget": true, + "continuous": false, + "status": "error", + "errorMsg": "unauthorized: unauthorized to access or create database https://examples.couchdb.com/db1/", + "statusTime": null, + "url": "#/database/_replicator/4cb18c43bf16b2f953026d4fd100073e", + }, + { + "_id": "c6540e08dafbcbb3800c25ab12000e75", + "_rev": "3-386a0e83182f1ebff5c269e141e796b2", + "selected": false, + "source": "https://examples.couchdb.com/animaldb", + "target": "https://examples.couchdb.com/animaldb-rep", + "createTarget": true, + "continuous": false, + "status": "completed", + "errorMsg": "", + "statusTime": null, + "url": "#/database/_replicator/c6540e08dafbcbb3800c25ab12000e75" + }]; + + const schedulerDocs = [{ + "database": "_replicator", + "doc_id": "4cb18c43bf16b2f953026d4fd100073e", + "id": "d5e24c90d8578f5c6eb043be147b3e39+create_target", + "node": "node@couchdb", + "source": "https://examples.couchdb.com/db1/", + "target": "https://examples.couchdb.com/db1-rep", + "state": "crashing", + "info": "unauthorized: unauthorized to access or create database https://examples.couchdb.com/db1/", + "error_count": 10, + "last_updated": "2017-01-23T06:17:06Z", + "start_time": "2017-01-16T11:38:24Z", + "proxy": null + }, + { + "database": "_replicator", + "doc_id": "c6540e08dafbcbb3800c25ab12000e75", + "id": null, + "state": "completed", + "error_count": 0, + "info": { + "revisions_checked": 15, + "missing_revisions_found": 15, + "docs_read": 15, + "docs_written": 15, + "changes_pending": null, + "doc_write_failures": 0, + "checkpointed_source_seq": "44-g1AAAAGweJytz10KgkAQB_AhhYLqDnUBURyVnvIqM7trJn6AbM91s7rZtm0RIiI99DIDw8z_x9QA4JeehL1k0fUqlxyFQaP6s6aTCkTdXSS1OmiVru3qgoCPxpiq9Gjb2MEyOaBiUUjYfQOS6fuREs8onNvK1w-0cVAsEENMfskYSTgjtb6tcLPNYveXtnYaypQL5OFb2Z-wxxtzr60clhYUZURDLJqOqZ6FHoyT" + }, + "last_updated": "2017-01-16T11:38:04Z", + "start_time": "2017-01-16T11:38:02Z" + } + ]; + + it("should parse and join the docs correctly", () => { + const clonedDocs = docs.map(d => Object.assign({}, d)); + const [doc1, doc2] = combineDocsAndScheduler(clonedDocs, schedulerDocs); + + assert.deepEqual(doc2.status, "completed"); + + assert.deepEqual(doc1.status, "crashing"); + assert.deepEqual(doc1.startTime, new Date("2017-01-16T11:38:24Z")); + assert.deepEqual(doc1.stateTime, new Date("2017-01-23T06:17:06Z")); + }); - it("returns nothing for no username and password", () => { - const {username, password } = getCredentialsFromUrl("http://my-couchdb.com/db"); + it("won't crash on no scheduler docs", () => { + const [doc1, doc2] = combineDocsAndScheduler(docs, []); - assert.deepEqual(username, ''); - assert.deepEqual(password, ''); - }); + assert.deepEqual(doc2.status, "completed"); + assert.deepEqual(doc1.status, "error"); + }); }); - describe("removeCredentialsFromUrl", () => { - - it("can remove username and password", () => { - const url = removeCredentialsFromUrl("https://bob:marley@my-couchdb.com/db"); - assert.deepEqual(url, 'https://my-couchdb.com/db'); - }); - - it("returns url if no password", () => { - const url = removeCredentialsFromUrl("https://my-couchdb.com/db"); - assert.deepEqual(url, 'https://my-couchdb.com/db'); - }); - - it("can remove username and password with special characters", () => { - const url = removeCredentialsFromUrl("https://bob:m@:/rley@my-couchdb.com/db"); - assert.deepEqual(url, 'https://my-couchdb.com/db'); - }); - - }); }); 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..103dae8c3 100644 --- a/app/addons/replication/actions.js +++ b/app/addons/replication/actions.js @@ -14,7 +14,8 @@ 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} from './api'; +import $ from 'jquery'; function initReplicator (localSource) { @@ -89,12 +90,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 +103,20 @@ const getReplicationActivity = () => { }); }; +const getReplicateActivity = () => { + FauxtonAPI.dispatch({ + type: ActionTypes.REPLICATION_FETCHING_REPLICATE_STATUS, + }); + + fetchReplicateInfo().then(replicateInfo => { + console.log('replicate info', replicateInfo); + FauxtonAPI.dispatch({ + type: ActionTypes.REPLICATION_REPLICATE_STATUS, + options: replicateInfo + }); + }); +}; + const filterDocs = (filter) => { FauxtonAPI.dispatch({ type: ActionTypes.REPLICATION_FILTER_DOCS, @@ -238,7 +253,29 @@ 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 => { + console.log('new api', newApi); + FauxtonAPI.dispatch({ + type: ActionTypes.REPLICATION_SUPPORT_NEW_API, + options: newApi + }); + }); +}; + export default { + checkForNewApi, initReplicator, replicate, updateFormField, @@ -252,5 +289,7 @@ export default { showConflictModal, hideConflictModal, changeActivitySort, - clearSelectedDocs + clearSelectedDocs, + changeTabSection, + getReplicateActivity }; diff --git a/app/addons/replication/actiontypes.js b/app/addons/replication/actiontypes.js index f2bb47828..305fc276c 100644 --- a/app/addons/replication/actiontypes.js +++ b/app/addons/replication/actiontypes.js @@ -27,5 +27,9 @@ 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' }; diff --git a/app/addons/replication/api.js b/app/addons/replication/api.js index 0bda51786..0156a3eee 100644 --- a/app/addons/replication/api.js +++ b/app/addons/replication/api.js @@ -208,6 +208,9 @@ export const removeSensitiveUrlInfo = (url) => { export const getDocUrl = (doc) => { let url = doc; + if (!doc) { + return ''; + } if (typeof doc === "object") { url = doc.url; @@ -234,8 +237,36 @@ export const parseReplicationDocs = (rows) => { }); }; +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({ + const docsPromise = $.ajax({ type: 'GET', url: '/_replicator/_all_docs?include_docs=true&limit=100', contentType: 'application/json; charset=utf-8', @@ -243,6 +274,23 @@ export const fetchReplicationDocs = () => { }).then((res) => { return parseReplicationDocs(res.rows.filter(row => row.id.indexOf("_design/_replicator") === -1)); }); + + const schedulerPromise = fetchSchedulerDocs(); + return FauxtonAPI.Promise.join(docsPromise, schedulerPromise, (docs, schedulerDocs) => { + return combineDocsAndScheduler(docs, schedulerDocs); + }); +}; + +export const fetchSchedulerDocs = () => { + return $.ajax({ + type: 'GET', + url: '/_scheduler/docs?include_docs=true', + contentType: 'application/json; charset=utf-8', + dataType: 'json', + }).then((res) => { + console.log('new', res); + return res.docs; + }); }; export const checkReplicationDocID = (docId) => { @@ -263,3 +311,41 @@ export const checkReplicationDocID = (docId) => { }); return promise; }; + +let newApi = null; +export const supportNewApi = () => { + return new FauxtonAPI.Promise((resolve) => { + if (newApi !== null) { + return resolve(newApi); + } + + $.ajax({ + url: app.host, + contentType: 'application/json; charset=utf-8', + dataType: 'json', + method: 'GET' + }).then(resp => { + console.log('new support api todo', resp); + newApi = true; + resolve(true); + }, () => { + resolve(true); + newApi = true; + }); + }); +}; + +export const parseReplicateInfo = (resp) => { + return resp.jobs.filter(job => job.database === null); +}; + +export const fetchReplicateInfo = () => { + return $.ajax({ + type: 'GET', + url: `/_scheduler/jobs`, + contentType: 'application/json; charset=utf-8', + dataType: 'json', + }).then(resp => { + return parseReplicateInfo(resp); + }); +}; diff --git a/app/addons/replication/assets/less/replication.less b/app/addons/replication/assets/less/replication.less index 8d3fe5f0e..0a8c3fbad 100644 --- a/app/addons/replication/assets/less/replication.less +++ b/app/addons/replication/assets/less/replication.less @@ -215,7 +215,7 @@ td.replication__row-status { .replication__activity_header { display: flex; - justify-content: space-between; + justify-content: center; align-items: center; } diff --git a/app/addons/replication/base.js b/app/addons/replication/base.js index d9ad42a6f..c335ee4d1 100644 --- a/app/addons/replication/base.js +++ b/app/addons/replication/base.js @@ -13,9 +13,11 @@ 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' }); + Actions.checkForNewApi(); }; FauxtonAPI.registerUrls('replication', { diff --git a/app/addons/replication/components/activity.js b/app/addons/replication/components/activity.js index e50f1b847..a6ea0307b 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 ( - - ); -}; - -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) { diff --git a/app/addons/replication/components/common-activity.js b/app/addons/replication/components/common-activity.js new file mode 100644 index 000000000..4e2245bc1 --- /dev/null +++ b/app/addons/replication/components/common-activity.js @@ -0,0 +1,102 @@ +// 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'; + +export class BulkDeleteController extends React.Component { + constructor (props) { + super(props); + this.state = { + modalVisible: false, + unconfirmedDeleteDocId: null + }; + } + + closeModal () { + this.setState({ + modalVisible: false, + unconfirmedDeleteDocId: null + }); + } + + showModal (doc) { + this.setState({ + modalVisible: true, + unconfirmedDeleteDocId: doc + }); + } + + 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 {modalVisible} = this.state; + return ; + } +} + +BulkDeleteController.propTypes = { + docs: React.PropTypes.array.isRequired, + deleteDocs: React.PropTypes.func.isRequired +}; + +export const ReplicationFilter = ({value, onChange}) => { + 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..ab18a44ff --- /dev/null +++ b/app/addons/replication/components/common-table.js @@ -0,0 +1,375 @@ +// 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); + const formattedStatusTime = momentTime.isValid() ? momentTime.format("MMM Do, h:mm a") : ''; + const stateTimeTooltip = Last updated: {formattedStatusTime}; + + return ( + + + {status} + + {this.getErrorIcon()} + + ); + } +}; + +RowStatus.propTypes = { + statusTime: React.PropTypes.any.isRequired, + status: React.PropTypes.string.isRequired, + errorMsg: React.PropTypes.string.isRequired, +}; + +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.isRequired, + 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 +}) => { + let momentTime = moment(startTime); + const formattedStartTime = momentTime.isValid() ? momentTime.format("MMM Do, h:mm a") : ''; + + return ( + + selectDoc(_id)} /> + {formatUrl(source)} + {formatUrl(target)} + {formattedStartTime} + {type} + + + + + + + ); +}; + +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, + startTime: React.PropTypes.object.isRequired, + 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 +}; + +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 ''; + } + + render () { + return ( + + + + + + + + + + + + + + {this.renderRows()} + +
    + + + Source + + + Target + + + Start Time + + + Type + + + State + + + Actions +
    + ); + } +} + +ReplicationTable.defaultProps = { + onlyDeleteAction: false +}; diff --git a/app/addons/replication/components/modals.js b/app/addons/replication/components/modals.js index b51616ed3..8b411a203 100644 --- a/app/addons/replication/components/modals.js +++ b/app/addons/replication/components/modals.js @@ -28,9 +28,11 @@ export const DeleteModal = ({ } let header = "You are deleting a replication document."; + let btnText = "Delete Document"; if (multipleDocs > 1) { header = `You are deleting ${multipleDocs} replication documents.`; + btnText = "Delete Documents"; } return ( @@ -51,7 +53,7 @@ export const DeleteModal = ({ Cancel @@ -66,21 +68,30 @@ DeleteModal.propTypes = { multipleDocs: React.PropTypes.number.isRequired }; -export const ErrorModal = ({visible, onClose, errorMsg}) => { +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 +115,7 @@ export const ConflictModal = ({visible, docId, onClose, onClick}) => { return ( onClose()}> - Fix Document Conflict + Custom ID Conflict

    @@ -123,7 +134,6 @@ export const ConflictModal = ({visible, docId, onClose, onClick}) => { Change Document ID diff --git a/app/addons/replication/components/replicateActivity.js b/app/addons/replication/components/replicateActivity.js new file mode 100644 index 000000000..4dedefe8c --- /dev/null +++ b/app/addons/replication/components/replicateActivity.js @@ -0,0 +1,111 @@ +// 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 (doc) { + this.setState({ + modalVisible: true, + unconfirmedDeleteDocId: doc + }); + } + + 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 ( +

    +

    Only active jobs triggered through the _replicate endpoint

    + + + +
    + ); + } +} + +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/constants.js b/app/addons/replication/constants.js index 7538f2067..b9e699fd0 100644 --- a/app/addons/replication/constants.js +++ b/app/addons/replication/constants.js @@ -10,7 +10,6 @@ // License for the specific language governing permissions and limitations under // the License. - export default { REPLICATION_SOURCE: { LOCAL: 'REPLICATION_SOURCE_LOCAL', diff --git a/app/addons/replication/controller.js b/app/addons/replication/controller.js index 1ac4fb275..ed339fa3f 100644 --- a/app/addons/replication/controller.js +++ b/app/addons/replication/controller.js @@ -18,6 +18,8 @@ import NewReplication from './components/newreplication'; import Activity from './components/activity'; import {checkReplicationDocID} from './api'; import {OnePane, OnePaneHeader, OnePaneContent} from '../components/layouts'; +import {TabElementWrapper, TabElement} from '../components/components/tabelement'; +import ReplicateActivity from './components/replicateactivity'; const {LoadLines, Polling, RefreshBtn} = Components; @@ -61,13 +63,19 @@ export default class ReplicationController extends React.Component { someDocsSelected: store.someDocsSelected(), username: store.getUsername(), password: store.getPassword(), - activitySort: store.getActivitySort() + activitySort: store.getActivitySort(), + tabSection: store.getTabSection(), + checkingApi: store.checkingAPI(), + supportNewApi: store.supportNewApi(), + replicateLoading: store.isReplicateInfoLoading(), + replicateInfo: store.getReplicateInfo() }; } loadReplicationInfo (props, oldProps) { Actions.initReplicator(props.localSource); Actions.getReplicationActivity(); + Actions.getReplicateActivity(); if (props.replicationId && props.replicationId !== oldProps.replicationId) { Actions.clearReplicationForm(); Actions.getReplicationStateFrom(props.replicationId); @@ -98,10 +106,11 @@ export default class ReplicationController extends React.Component { passwordModalVisible, databases, localSource, remoteSource, remoteTarget, localTarget, statusDocs, statusFilter, loading, allDocsSelected, someDocsSelected, showConflictModal, localSourceKnown, localTargetKnown, - username, password, authenticated, activityLoading, submittedNoChange, activitySort + username, password, authenticated, activityLoading, submittedNoChange, activitySort, tabSection, + replicateInfo, replicateLoading } = this.state; - if (this.props.section === 'new replication') { + if (tabSection === 'new replication') { if (loading) { return ; } @@ -141,11 +150,31 @@ export default class ReplicationController extends React.Component { />; } + if (tabSection === '_replicate') { + if (replicateLoading) { + return ; + } + + console.log('rrrrrr', replicateInfo); + 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; } @@ -195,16 +211,61 @@ export default class ReplicationController extends React.Component { ); } + getTabElements () { + const {tabSection} = this.state; + const elements = [ + + ]; + + if (this.state.supportNewApi) { + elements.push( + + ); + } + + elements.push( + + ); + + return elements; + } + + onTabChange (section, url) { + Actions.changeTabSection(section, url); + } + render () { + const { checkingAPI } = this.state; + + if (checkingAPI) { + return ; + } + return ( - + {this.getHeaderComponents()}
    + + {this.getTabElements()} +
    {this.showSection()}
    diff --git a/app/addons/replication/route.js b/app/addons/replication/route.js index 70b66a429..6edf26e8c 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/id/:id': 'fromId', - 'replication': 'activityView' + 'replication': 'activityView', + 'replication/_replicate': 'replicateView' }, selectedHeader: 'Replication', @@ -40,24 +42,29 @@ const ReplicationRouteObject = FauxtonAPI.RouteObject.extend({ defaultView: function (databaseName) { const localSource = databaseName || ''; + Actions.changeTabSection('new replication'); return ; }, fromId: function (replicationId) { + Actions.changeTabSection('new replication'); + console.log('re', replicationId); 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..e356816fd 100644 --- a/app/addons/replication/stores.js +++ b/app/addons/replication/stores.js @@ -16,12 +16,27 @@ 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(); }, reset () { + console.log('re'); this._loading = false; this._databases = []; this._authenticated = false; @@ -50,8 +65,24 @@ const ReplicationStore = FauxtonAPI.Store.extend({ 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 () { @@ -77,6 +108,18 @@ const ReplicationStore = FauxtonAPI.Store.extend({ this._activitySort = sort; }, + isReplicateInfoLoading () { + return this._fetchingReplicateInfo; + }, + + getReplicateInfo () { + return this._replicateInfo; + }, + + setReplicateInfo (info) { + this._replicateInfo = info; + }, + setCredentials (username, password) { this._username = username; this._password = password; @@ -200,22 +243,14 @@ const ReplicationStore = FauxtonAPI.Store.extend({ // 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' - }; this[validFieldMap[fieldName]] = value; }, + clearReplicationForm () { + Object.values(validFieldMap).forEach(fieldName => this[fieldName] = ''); + }, + getRemoteSource () { return this._remoteSource; }, @@ -242,6 +277,10 @@ const ReplicationStore = FauxtonAPI.Store.extend({ }); }, + getTabSection () { + return this._tabSection; + }, + dispatch ({type, options}) { switch (type) { @@ -269,7 +308,7 @@ const ReplicationStore = FauxtonAPI.Store.extend({ break; case ActionTypes.REPLICATION_CLEAR_FORM: - this.reset(); + this.clearReplicationForm(); break; case ActionTypes.REPLICATION_STARTING: @@ -317,6 +356,28 @@ 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 AccountActionTypes.AUTH_SHOW_PASSWORD_MODAL: this._isPasswordModalVisible = true; break; 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 From e6e3f728800a924ce92d231f18e28c6675d88f4a Mon Sep 17 00:00:00 2001 From: Garren Smith Date: Thu, 2 Mar 2017 11:59:00 +0200 Subject: [PATCH 02/21] working _replicates section --- app/addons/replication/actions.js | 70 ++++++++++++- app/addons/replication/actiontypes.js | 6 +- app/addons/replication/api.js | 113 ++++++++++++++------- app/addons/replication/base.js | 4 +- app/addons/replication/components/common-table.js | 24 +++-- .../replication/components/replicateActivity.js | 6 +- app/addons/replication/controller.js | 34 ++++--- app/addons/replication/stores.js | 64 +++++++++++- 8 files changed, 250 insertions(+), 71 deletions(-) diff --git a/app/addons/replication/actions.js b/app/addons/replication/actions.js index 103dae8c3..89c36061c 100644 --- a/app/addons/replication/actions.js +++ b/app/addons/replication/actions.js @@ -14,7 +14,7 @@ import FauxtonAPI from '../../core/api'; import ActionTypes from './actiontypes'; import Helpers from './helpers'; import Constants from './constants'; -import {supportNewApi, createReplicationDoc, fetchReplicateInfo, fetchReplicationDocs, decodeFullUrl} from './api'; +import {supportNewApi, createReplicationDoc, fetchReplicateInfo, fetchReplicationDocs, decodeFullUrl, deleteReplicatesApi} from './api'; import $ from 'jquery'; @@ -109,7 +109,6 @@ const getReplicateActivity = () => { }); fetchReplicateInfo().then(replicateInfo => { - console.log('replicate info', replicateInfo); FauxtonAPI.dispatch({ type: ActionTypes.REPLICATION_REPLICATE_STATUS, options: replicateInfo @@ -124,6 +123,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 @@ -137,12 +143,31 @@ 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 clearSelectedReplicates = () => { + FauxtonAPI.dispatch({ + type: ActionTypes.REPLICATION_CLEAR_SELECTED_REPLICATES + }); +}; + const deleteDocs = (docs) => { const bulkDocs = docs.map(({raw: doc}) => { doc._deleted = true; @@ -186,6 +211,41 @@ const deleteDocs = (docs) => { }); }; +const deleteReplicates = (replicates) => { + console.log('ss', 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({ + msg: errorMessage.reason, + type: 'error', + clear: true + }); + }); +}; + const getReplicationStateFrom = (id) => { $.ajax({ url: `${app.host}/_replicator/${encodeURIComponent(id)}`, @@ -291,5 +351,9 @@ export default { changeActivitySort, clearSelectedDocs, changeTabSection, - getReplicateActivity + getReplicateActivity, + filterReplicate, + selectReplicate, + selectAllReplicates, + deleteReplicates }; diff --git a/app/addons/replication/actiontypes.js b/app/addons/replication/actiontypes.js index 305fc276c..2b10cc1ea 100644 --- a/app/addons/replication/actiontypes.js +++ b/app/addons/replication/actiontypes.js @@ -31,5 +31,9 @@ export default { 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_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' }; diff --git a/app/addons/replication/api.js b/app/addons/replication/api.js index 0156a3eee..07446a5b9 100644 --- a/app/addons/replication/api.js +++ b/app/addons/replication/api.js @@ -17,6 +17,26 @@ import FauxtonAPI from '../../core/api'; import base64 from 'base-64'; import _ from 'lodash'; +let newApiPromise = null; +export const supportNewApi = () => { + if (!newApiPromise) { + newApiPromise = new FauxtonAPI.Promise((resolve) => { + $.ajax({ + url: '/_scheduler/jobs', + contentType: 'application/json; charset=utf-8', + dataType: 'json', + method: 'GET' + }).then(() => { + resolve(true); + }, () => { + resolve(false); + }); + }); + } + + return newApiPromise; +}; + export const encodeFullUrl = (fullUrl) => { if (!fullUrl) {return '';} const url = new URL(fullUrl); @@ -231,7 +251,8 @@ 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 }; }); @@ -266,18 +287,27 @@ export const combineDocsAndScheduler = (docs, schedulerDocs) => { }; export const fetchReplicationDocs = () => { - const docsPromise = $.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 = $.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)); + }); - const schedulerPromise = fetchSchedulerDocs(); - return FauxtonAPI.Promise.join(docsPromise, schedulerPromise, (docs, schedulerDocs) => { - return combineDocsAndScheduler(docs, schedulerDocs); + if (!newApi) { + return docsPromise; + } + const schedulerPromise = fetchSchedulerDocs(); + return FauxtonAPI.Promise.join(docsPromise, schedulerPromise, (docs, schedulerDocs) => { + return combineDocsAndScheduler(docs, schedulerDocs); + }) + .catch(err => { + console.log('err', err); + }); }); }; @@ -288,7 +318,6 @@ export const fetchSchedulerDocs = () => { contentType: 'application/json; charset=utf-8', dataType: 'json', }).then((res) => { - console.log('new', res); return res.docs; }); }; @@ -312,31 +341,21 @@ export const checkReplicationDocID = (docId) => { return promise; }; -let newApi = null; -export const supportNewApi = () => { - return new FauxtonAPI.Promise((resolve) => { - if (newApi !== null) { - return resolve(newApi); - } - - $.ajax({ - url: app.host, - contentType: 'application/json; charset=utf-8', - dataType: 'json', - method: 'GET' - }).then(resp => { - console.log('new support api todo', resp); - newApi = true; - resolve(true); - }, () => { - resolve(true); - newApi = true; - }); - }); -}; - export const parseReplicateInfo = (resp) => { - return resp.jobs.filter(job => job.database === null); + 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, + raw: job + }; + }); }; export const fetchReplicateInfo = () => { @@ -349,3 +368,23 @@ export const fetchReplicateInfo = () => { return parseReplicateInfo(resp); }); }; + +export const deleteReplicatesApi = (replicates) => { + const promises = replicates.map(replicate => { + const data = { + replication_id: replicate._id, + cancel: true + }; + + console.log(data); + return $.ajax({ + type: 'POST', + url: '/_replicate', + contentType: 'application/json; charset=utf-8', + dataType: 'json', + data: JSON.stringify(data) + }); + }); + + return FauxtonAPI.Promise.all(promises); +}; diff --git a/app/addons/replication/base.js b/app/addons/replication/base.js index c335ee4d1..6143126eb 100644 --- a/app/addons/replication/base.js +++ b/app/addons/replication/base.js @@ -17,7 +17,9 @@ import Actions from './actions'; replication.initialize = function () { FauxtonAPI.addHeaderLink({ title: 'Replication', href: '#/replication', icon: 'fonticon-replicate' }); - Actions.checkForNewApi(); + FauxtonAPI.session.on('authenticated', () => { + Actions.checkForNewApi(); + }); }; FauxtonAPI.registerUrls('replication', { diff --git a/app/addons/replication/components/common-table.js b/app/addons/replication/components/common-table.js index ab18a44ff..2bc8a304d 100644 --- a/app/addons/replication/components/common-table.js +++ b/app/addons/replication/components/common-table.js @@ -74,14 +74,20 @@ class RowStatus extends React.Component { render () { const {statusTime, status} = this.props; let momentTime = moment(statusTime); - const formattedStatusTime = momentTime.isValid() ? momentTime.format("MMM Do, h:mm a") : ''; - const stateTimeTooltip = Last updated: {formattedStatusTime}; + let statusValue = {status}; - return ( - + if (momentTime.isValid()) { + const formattedStatusTime = momentTime.format("MMM Do, h:mm a"); + const stateTimeTooltip = Last updated: {formattedStatusTime}; + statusValue = {status} - + ; + } + + return ( + + {statusValue} {this.getErrorIcon()} ); @@ -89,7 +95,7 @@ class RowStatus extends React.Component { }; RowStatus.propTypes = { - statusTime: React.PropTypes.any.isRequired, + statusTime: React.PropTypes.any, status: React.PropTypes.string.isRequired, errorMsg: React.PropTypes.string.isRequired, }; @@ -140,7 +146,7 @@ const RowActions = ({onlyDeleteAction, _id, url, deleteDocs}) => { RowActions.propTypes = { _id: React.PropTypes.string.isRequired, - url: React.PropTypes.string.isRequired, + url: React.PropTypes.string, error: React.PropTypes.bool.isRequired, errorMsg: React.PropTypes.string.isRequired, deleteDocs: React.PropTypes.func.isRequired @@ -197,9 +203,9 @@ Row.propTypes = { target: React.PropTypes.string.isRequired, type: React.PropTypes.string.isRequired, status: React.PropTypes.string, - url: React.PropTypes.string.isRequired, + url: React.PropTypes.string, statusTime: React.PropTypes.object.isRequired, - startTime: React.PropTypes.object.isRequired, + startTime: React.PropTypes.object, selected: React.PropTypes.bool.isRequired, selectDoc: React.PropTypes.func.isRequired, errorMsg: React.PropTypes.string.isRequired, diff --git a/app/addons/replication/components/replicateActivity.js b/app/addons/replication/components/replicateActivity.js index 4dedefe8c..4eb69c344 100644 --- a/app/addons/replication/components/replicateActivity.js +++ b/app/addons/replication/components/replicateActivity.js @@ -70,12 +70,16 @@ export default class Activity extends React.Component { const {modalVisible} = this.state; return (
    -

    Only active jobs triggered through the _replicate endpoint

    +

    + Only active jobs triggered through the _replicate endpoint are displayed below. +  Jobs that have completed or failed are not displayed +

    ; } - console.log('rrrrrr', replicateInfo); return ; } @@ -202,10 +208,10 @@ export default class ReplicationController extends React.Component { max={600} startValue={300} stepSize={60} - onPoll={Actions.getReplicationActivity} + onPoll={this.getAllActivity.bind(this)} />
    ); diff --git a/app/addons/replication/stores.js b/app/addons/replication/stores.js index e356816fd..d46f47255 100644 --- a/app/addons/replication/stores.js +++ b/app/addons/replication/stores.js @@ -36,7 +36,6 @@ const ReplicationStore = FauxtonAPI.Store.extend({ }, reset () { - console.log('re'); this._loading = false; this._databases = []; this._authenticated = false; @@ -61,7 +60,9 @@ 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; @@ -90,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); } @@ -113,7 +115,12 @@ const ReplicationStore = FauxtonAPI.Store.extend({ }, getReplicateInfo () { - return this._replicateInfo; + return this._replicateInfo.filter(doc => { + return _.values(doc).filter(item => { + if (!item) {return null;} + return item.toString().toLowerCase().match(this._replicateFilter); + }).length > 0; + }); }, setReplicateInfo (info) { @@ -220,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); @@ -240,6 +270,14 @@ const ReplicationStore = FauxtonAPI.Store.extend({ getStatusFilter () { return this._statusFilter; }, + + setReplicateFilter (filter) { + this._replicateFilter = filter; + }, + + getReplicateFilter () { + return this._replicateFilter; + }, // to cut down on boilerplate updateFormField (fieldName, value) { @@ -328,6 +366,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; @@ -336,6 +378,14 @@ 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.setStateFromDoc(options); break; @@ -378,6 +428,10 @@ const ReplicationStore = FauxtonAPI.Store.extend({ this.setReplicateInfo(options); break; + case ActionTypes.REPLICATION_CLEAR_SELECTED_REPLICATES: + this._allReplicateSelected = false; + break; + case AccountActionTypes.AUTH_SHOW_PASSWORD_MODAL: this._isPasswordModalVisible = true; break; From 1c852e6dca490c51e9dc2a023476329a22b822d8 Mon Sep 17 00:00:00 2001 From: Garren Smith Date: Thu, 2 Mar 2017 14:20:32 +0200 Subject: [PATCH 03/21] clear forms and loading for new replication --- app/addons/replication/actions.js | 5 ++++- app/addons/replication/actiontypes.js | 3 ++- app/addons/replication/components/common-table.js | 6 +++++- app/addons/replication/route.js | 3 ++- app/addons/replication/stores.js | 7 +++++-- 5 files changed, 18 insertions(+), 6 deletions(-) diff --git a/app/addons/replication/actions.js b/app/addons/replication/actions.js index 89c36061c..0d7703de5 100644 --- a/app/addons/replication/actions.js +++ b/app/addons/replication/actions.js @@ -247,6 +247,10 @@ const deleteReplicates = (replicates) => { }; const getReplicationStateFrom = (id) => { + FauxtonAPI.dispatch({ + type: ActionTypes.REPLICATION_FETCHING_FORM_STATE + }); + $.ajax({ url: `${app.host}/_replicator/${encodeURIComponent(id)}`, contentType: 'application/json', @@ -326,7 +330,6 @@ const changeTabSection = (newSection, url) => { const checkForNewApi = () => { supportNewApi().then(newApi => { - console.log('new api', newApi); FauxtonAPI.dispatch({ type: ActionTypes.REPLICATION_SUPPORT_NEW_API, options: newApi diff --git a/app/addons/replication/actiontypes.js b/app/addons/replication/actiontypes.js index 2b10cc1ea..5089ea4ca 100644 --- a/app/addons/replication/actiontypes.js +++ b/app/addons/replication/actiontypes.js @@ -35,5 +35,6 @@ export default { 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_CLEAR_SELECTED_REPLICATES: 'REPLICATION_CLEAR_SELECTED_REPLICATES', + REPLICATION_FETCHING_FORM_STATE: 'REPLICATION_FETCHING_FORM_STATE' }; diff --git a/app/addons/replication/components/common-table.js b/app/addons/replication/components/common-table.js index 2bc8a304d..b1554ca03 100644 --- a/app/addons/replication/components/common-table.js +++ b/app/addons/replication/components/common-table.js @@ -96,10 +96,14 @@ class RowStatus extends React.Component { RowStatus.propTypes = { statusTime: React.PropTypes.any, - status: React.PropTypes.string.isRequired, + status: React.PropTypes.string, errorMsg: React.PropTypes.string.isRequired, }; +RowStatus.defaultProps = { + status: '' +}; + const RowActions = ({onlyDeleteAction, _id, url, deleteDocs}) => { const actions = []; if (!onlyDeleteAction) { diff --git a/app/addons/replication/route.js b/app/addons/replication/route.js index 6edf26e8c..b98eb4bf7 100644 --- a/app/addons/replication/route.js +++ b/app/addons/replication/route.js @@ -43,6 +43,7 @@ const ReplicationRouteObject = FauxtonAPI.RouteObject.extend({ defaultView: function (databaseName) { const localSource = databaseName || ''; Actions.changeTabSection('new replication'); + Actions.clearReplicationForm(); return ; diff --git a/app/addons/replication/stores.js b/app/addons/replication/stores.js index d46f47255..56d9d754c 100644 --- a/app/addons/replication/stores.js +++ b/app/addons/replication/stores.js @@ -280,8 +280,6 @@ const ReplicationStore = FauxtonAPI.Store.extend({ }, // to cut down on boilerplate updateFormField (fieldName, value) { - - this[validFieldMap[fieldName]] = value; }, @@ -340,6 +338,10 @@ 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); @@ -387,6 +389,7 @@ const ReplicationStore = FauxtonAPI.Store.extend({ break; case ActionTypes.REPLICATION_SET_STATE_FROM_DOC: + this._loading = false; this.setStateFromDoc(options); break; From 275942f3d39b1917433b2db7b43e9e1aacdaffa6 Mon Sep 17 00:00:00 2001 From: Garren Smith Date: Thu, 2 Mar 2017 14:24:28 +0200 Subject: [PATCH 04/21] fix tests --- app/addons/replication/__tests__/api.tests.js | 225 ++++++++++++++++---------- 1 file changed, 139 insertions(+), 86 deletions(-) diff --git a/app/addons/replication/__tests__/api.tests.js b/app/addons/replication/__tests__/api.tests.js index 4051b66a5..3125619b3 100644 --- a/app/addons/replication/__tests__/api.tests.js +++ b/app/addons/replication/__tests__/api.tests.js @@ -17,7 +17,11 @@ import { createTarget, addDocIdAndRev, getDocUrl, - combineDocsAndScheduler + encodeFullUrl, + decodeFullUrl, + getCredentialsFromUrl, + removeCredentialsFromUrl, + removeSensitiveUrlInfo } from '../api'; import Constants from '../constants'; @@ -25,6 +29,26 @@ const assert = utils.assert; describe('Replication API', () => { + describe("removeSensiteiveUrlInfo", () => { + it('removes password username', () => { + const url = 'http://tester:testerpass@127.0.0.1/fancy/db/name'; + + const res = removeSensitiveUrlInfo(url); + + expect(res).toBe('http://127.0.0.1/fancy/db/name'); + }); + + // see https://issues.apache.org/jira/browse/COUCHDB-3257 + // CouchDB accepts and returns invalid urls + it('does not throw on invalid urls', () => { + const url = 'http://tester:tes#terpass@127.0.0.1/fancy/db/name'; + + const res = removeSensitiveUrlInfo(url); + + expect(res).toBe('http://tester:tes#terpass@127.0.0.1/fancy/db/name'); + }); + }); + describe('getSource', () => { it('encodes remote db', () => { @@ -34,7 +58,7 @@ describe('Replication API', () => { remoteSource }); - assert.deepEqual(source, 'http://remote-couchdb.com/my%2Fdb%2Fhere'); + assert.deepEqual(source.url, 'http://remote-couchdb.com/my%2Fdb%2Fhere'); }); it('returns local source with auth info and encoded', () => { @@ -45,11 +69,24 @@ describe('Replication API', () => { localSource, username: 'the-user', password: 'password' - }); + }, {origin: 'http://dev:6767'}); assert.deepEqual(source.headers, {Authorization:"Basic dGhlLXVzZXI6cGFzc3dvcmQ="}); assert.ok(/my%2Fdb/.test(source.url)); }); + + it('returns remote source url and auth header', () => { + const source = getSource({ + replicationSource: Constants.REPLICATION_SOURCE.REMOTE, + remoteSource: 'http://eddie:my-password@my-couchdb.com/my-db', + localSource: "local", + username: 'the-user', + password: 'password' + }, {origin: 'http://dev:6767'}); + + assert.deepEqual(source.headers, {Authorization:"Basic ZWRkaWU6bXktcGFzc3dvcmQ="}); + assert.deepEqual('http://my-couchdb.com/my-db', source.url); + }); }); describe('getTarget', () => { @@ -60,7 +97,20 @@ describe('Replication API', () => { assert.deepEqual("http://remote-couchdb.com/my%2Fdb", getTarget({ replicationTarget: Constants.REPLICATION_TARGET.NEW_REMOTE_DATABASE, remoteTarget: remoteTarget - })); + }).url); + }); + + it("encodes username and password for remote", () => { + const remoteTarget = 'http://jimi:my-password@remote-couchdb.com/my/db'; + const target = getTarget({ + replicationTarget: Constants.REPLICATION_TARGET.NEW_REMOTE_DATABASE, + remoteTarget: remoteTarget, + username: 'fake', + password: 'fake' + }); + + assert.deepEqual(target.url, 'http://remote-couchdb.com/my%2Fdb'); + assert.deepEqual(target.headers, {Authorization:"Basic amltaTpteS1wYXNzd29yZA=="}); }); it('returns existing local database', () => { @@ -95,11 +145,33 @@ describe('Replication API', () => { localTarget: 'my-new/db', username: 'the-user', password: 'password' - }); + }, {origin: 'http://dev:5555'}); + + assert.deepEqual(target.headers, {Authorization:"Basic dGhlLXVzZXI6cGFzc3dvcmQ="}); + assert.ok(/my-new%2Fdb/.test(target.url)); + }); + + it("doesn't encode username and password if it is not supplied", () => { + const location = { + host: "dev:8000", + hostname: "dev", + href: "http://dev:8000/#database/animaldb/_all_docs", + origin: "http://dev:8000", + pathname: "/", + port: "8000", + protocol: "http:", + }; + + const target = getTarget({ + replicationTarget: Constants.REPLICATION_TARGET.NEW_LOCAL_DATABASE, + replicationSource: Constants.REPLICATION_SOURCE.REMOTE, + localTarget: 'my-new/db' + }, location); - assert.ok(/the-user:password@/.test(target)); - assert.ok(/my-new%2Fdb/.test(target)); + assert.deepEqual("http://dev:8000/my-new%2Fdb", target.url); + assert.deepEqual({}, target.headers); }); + }); describe('continuous', () => { @@ -166,86 +238,67 @@ describe('Replication API', () => { }); }); - describe("combine docs and schedulerDocs", () => { - const docs = [ - { - "_id": "4cb18c43bf16b2f953026d4fd100073e", - "_rev": "5-1163fecc6516aae02e031153ab4d5d19", - "selected": false, - "source": "https://examples.couchdb.com/db1", - "target": "https://examples.couchdb.com/db1-rep", - "createTarget": true, - "continuous": false, - "status": "error", - "errorMsg": "unauthorized: unauthorized to access or create database https://examples.couchdb.com/db1/", - "statusTime": null, - "url": "#/database/_replicator/4cb18c43bf16b2f953026d4fd100073e", - }, - { - "_id": "c6540e08dafbcbb3800c25ab12000e75", - "_rev": "3-386a0e83182f1ebff5c269e141e796b2", - "selected": false, - "source": "https://examples.couchdb.com/animaldb", - "target": "https://examples.couchdb.com/animaldb-rep", - "createTarget": true, - "continuous": false, - "status": "completed", - "errorMsg": "", - "statusTime": null, - "url": "#/database/_replicator/c6540e08dafbcbb3800c25ab12000e75" - }]; - - const schedulerDocs = [{ - "database": "_replicator", - "doc_id": "4cb18c43bf16b2f953026d4fd100073e", - "id": "d5e24c90d8578f5c6eb043be147b3e39+create_target", - "node": "node@couchdb", - "source": "https://examples.couchdb.com/db1/", - "target": "https://examples.couchdb.com/db1-rep", - "state": "crashing", - "info": "unauthorized: unauthorized to access or create database https://examples.couchdb.com/db1/", - "error_count": 10, - "last_updated": "2017-01-23T06:17:06Z", - "start_time": "2017-01-16T11:38:24Z", - "proxy": null - }, - { - "database": "_replicator", - "doc_id": "c6540e08dafbcbb3800c25ab12000e75", - "id": null, - "state": "completed", - "error_count": 0, - "info": { - "revisions_checked": 15, - "missing_revisions_found": 15, - "docs_read": 15, - "docs_written": 15, - "changes_pending": null, - "doc_write_failures": 0, - "checkpointed_source_seq": "44-g1AAAAGweJytz10KgkAQB_AhhYLqDnUBURyVnvIqM7trJn6AbM91s7rZtm0RIiI99DIDw8z_x9QA4JeehL1k0fUqlxyFQaP6s6aTCkTdXSS1OmiVru3qgoCPxpiq9Gjb2MEyOaBiUUjYfQOS6fuREs8onNvK1w-0cVAsEENMfskYSTgjtb6tcLPNYveXtnYaypQL5OFb2Z-wxxtzr60clhYUZURDLJqOqZ6FHoyT" - }, - "last_updated": "2017-01-16T11:38:04Z", - "start_time": "2017-01-16T11:38:02Z" - } - ]; - - it("should parse and join the docs correctly", () => { - const clonedDocs = docs.map(d => Object.assign({}, d)); - const [doc1, doc2] = combineDocsAndScheduler(clonedDocs, schedulerDocs); - - assert.deepEqual(doc2.status, "completed"); - - assert.deepEqual(doc1.status, "crashing"); - assert.deepEqual(doc1.startTime, new Date("2017-01-16T11:38:24Z")); - assert.deepEqual(doc1.stateTime, new Date("2017-01-23T06:17:06Z")); - }); + describe("encodeFullUrl", () => { + it("encodes db correctly", () => { + const url = "http://dev:5984/boom/aaaa"; + const encodedUrl = encodeFullUrl(url); - it("won't crash on no scheduler docs", () => { - const [doc1, doc2] = combineDocsAndScheduler(docs, []); + assert.deepEqual("http://dev:5984/boom%2Faaaa", encodedUrl); + }); + + }); + + describe("decodeFullUrl", () => { + + it("encodes db correctly", () => { + const url = "http://dev:5984/boom%2Faaaa"; + const encodedUrl = decodeFullUrl(url); + + assert.deepEqual("http://dev:5984/boom/aaaa", encodedUrl); + }); - assert.deepEqual(doc2.status, "completed"); - assert.deepEqual(doc1.status, "error"); - }); }); -}); + describe("getCredentialsFromUrl", () => { + + it("can get username and password", () => { + const {username, password } = getCredentialsFromUrl("https://bob:marley@my-couchdb.com/db"); + + assert.deepEqual(username, 'bob'); + assert.deepEqual(password, 'marley'); + }); + + it("can get username and password with special characters", () => { + const {username, password } = getCredentialsFromUrl("http://bob:m@:/rley@my-couchdb.com/db"); + + assert.deepEqual(username, 'bob'); + assert.deepEqual(password, 'm@:/rley'); + }); + + it("returns nothing for no username and password", () => { + const {username, password } = getCredentialsFromUrl("http://my-couchdb.com/db"); + + assert.deepEqual(username, ''); + assert.deepEqual(password, ''); + }); + }); + + describe("removeCredentialsFromUrl", () => { + + it("can remove username and password", () => { + const url = removeCredentialsFromUrl("https://bob:marley@my-couchdb.com/db"); + assert.deepEqual(url, 'https://my-couchdb.com/db'); + }); + + it("returns url if no password", () => { + const url = removeCredentialsFromUrl("https://my-couchdb.com/db"); + assert.deepEqual(url, 'https://my-couchdb.com/db'); + }); + + it("can remove username and password with special characters", () => { + const url = removeCredentialsFromUrl("https://bob:m@:/rley@my-couchdb.com/db"); + assert.deepEqual(url, 'https://my-couchdb.com/db'); + }); + + }); +}); \ No newline at end of file From 0bd010b3f11e9e1f1a03d584f2718fb7b9323dc1 Mon Sep 17 00:00:00 2001 From: Garren Smith Date: Thu, 2 Mar 2017 14:34:18 +0200 Subject: [PATCH 05/21] lint issue fix --- app/addons/replication/__tests__/api.tests.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/addons/replication/__tests__/api.tests.js b/app/addons/replication/__tests__/api.tests.js index 3125619b3..3ebb79273 100644 --- a/app/addons/replication/__tests__/api.tests.js +++ b/app/addons/replication/__tests__/api.tests.js @@ -301,4 +301,4 @@ describe('Replication API', () => { }); }); -}); \ No newline at end of file +}); From fd3472c171ffbaffdc830f3a552c8d63a29c69d9 Mon Sep 17 00:00:00 2001 From: Garren Smith Date: Mon, 6 Mar 2017 16:30:27 +0200 Subject: [PATCH 06/21] improvements from feedback --- app/addons/replication/api.js | 2 +- .../replication/assets/less/replication.less | 26 ++++++++---- .../replication/components/common-activity.js | 5 +++ app/addons/replication/components/common-table.js | 46 ++++++++++++++++------ app/addons/replication/components/remoteexample.js | 4 +- .../replication/components/replicateActivity.js | 4 +- 6 files changed, 61 insertions(+), 26 deletions(-) diff --git a/app/addons/replication/api.js b/app/addons/replication/api.js index 07446a5b9..0913c8978 100644 --- a/app/addons/replication/api.js +++ b/app/addons/replication/api.js @@ -353,6 +353,7 @@ export const parseReplicateInfo = (resp) => { status: convertState(job.history[0].type), errorMsg: '', selected: false, + continuous: /continuous/.test(job.id), raw: job }; }); @@ -376,7 +377,6 @@ export const deleteReplicatesApi = (replicates) => { cancel: true }; - console.log(data); return $.ajax({ type: 'POST', url: '/_replicate', diff --git a/app/addons/replication/assets/less/replication.less b/app/addons/replication/assets/less/replication.less index 0a8c3fbad..1611fbb58 100644 --- a/app/addons/replication/assets/less/replication.less +++ b/app/addons/replication/assets/less/replication.less @@ -44,13 +44,13 @@ 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; } } @@ -60,7 +60,7 @@ div.replication__page { .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 +80,7 @@ div.replication__page { .replication__remote-connection-url[type="text"] { font-size: 14px; - width: 100%; + width: 400px; color: #333; } @@ -91,14 +91,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 +121,7 @@ div.replication__page { .replication__doc-name-input[type="text"] { padding-right: 32px; font-size: 14px; - width: 248px; + width: 400px; color: #333; } @@ -215,7 +215,7 @@ td.replication__row-status { .replication__activity_header { display: flex; - justify-content: center; + justify-content: space-between; align-items: center; } @@ -290,6 +290,10 @@ input.replication__filter-input[type="text"] { margin-bottom: 0; } +.replication__error-continue { + margin-left: 20px; +} + .replication__error-cancel, .replication__error-continue { background-color: #0082BF; @@ -311,3 +315,9 @@ td.replication__empty-row { .replication__remote_icon_help:hover { color: #af2d24; } + +.replication__tooltip { + .tooltip-inner { + text-align: left + } +} \ No newline at end of file diff --git a/app/addons/replication/components/common-activity.js b/app/addons/replication/components/common-activity.js index 4e2245bc1..7c6f3e180 100644 --- a/app/addons/replication/components/common-activity.js +++ b/app/addons/replication/components/common-activity.js @@ -91,7 +91,12 @@ ReplicationFilter.propTypes = { export const ReplicationHeader = ({filter, onFilterChange}) => { return ( ); }; diff --git a/app/addons/replication/components/common-table.js b/app/addons/replication/components/common-table.js index b1554ca03..cfd7b372c 100644 --- a/app/addons/replication/components/common-table.js +++ b/app/addons/replication/components/common-table.js @@ -135,7 +135,7 @@ const RowActions = ({onlyDeleteAction, _id, url, deleteDocs}) => {
  • deleteDocs(_id)}>
  • @@ -169,10 +169,20 @@ const Row = ({ selectDoc, errorMsg, deleteDocs, - onlyDeleteAction + onlyDeleteAction, + showStateRow }) => { let momentTime = moment(startTime); const formattedStartTime = momentTime.isValid() ? momentTime.format("MMM Do, h:mm a") : ''; + let stateRow = null; + + if (showStateRow) { + stateRow = ; + } return ( @@ -181,11 +191,7 @@ const Row = ({ {formatUrl(target)} {formattedStartTime} {type} - + {stateRow} { @@ -305,6 +312,7 @@ export class ReplicationTable extends React.Component { errorMsg={doc.errorMsg} doc={doc} onlyDeleteAction={this.props.onlyDeleteAction} + showStateRow={this.props.showStateRow} />; }); } @@ -334,7 +342,21 @@ export class ReplicationTable extends React.Component { return ''; } + stateRow () { + if (this.props.showStateRow) { + return ( + + State + + + ); + } + + return null; + } + render () { + return ( @@ -363,10 +385,7 @@ export class ReplicationTable extends React.Component { Type - + {this.stateRow()} @@ -381,5 +400,6 @@ export class ReplicationTable extends React.Component { } ReplicationTable.defaultProps = { - onlyDeleteAction: false + onlyDeleteAction: false, + showStateRow: true }; diff --git a/app/addons/replication/components/remoteexample.js b/app/addons/replication/components/remoteexample.js index 1992b325a..63c09b194 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.

    @@ -27,7 +27,7 @@ const tooltipExisting = ( ); const tooltipNew = ( - + Enter the username and password of the remote account. ); diff --git a/app/addons/replication/components/replicateActivity.js b/app/addons/replication/components/replicateActivity.js index 4eb69c344..580f3f265 100644 --- a/app/addons/replication/components/replicateActivity.js +++ b/app/addons/replication/components/replicateActivity.js @@ -71,8 +71,7 @@ export default class Activity extends React.Component { return (

    - Only active jobs triggered through the _replicate endpoint are displayed below. -  Jobs that have completed or failed are not displayed + Active _replicate jobs are displayed. Completed and failed jobs are not.

    Date: Tue, 7 Mar 2017 14:36:25 +0200 Subject: [PATCH 07/21] review fixes --- app/addons/replication/components/activity.js | 4 +++ app/addons/replication/components/modals.js | 28 +++++++++-------- .../replication/components/replicateActivity.js | 1 + app/addons/replication/controller.js | 35 ++++++++++++++-------- 4 files changed, 42 insertions(+), 26 deletions(-) diff --git a/app/addons/replication/components/activity.js b/app/addons/replication/components/activity.js index a6ea0307b..af4634bd1 100644 --- a/app/addons/replication/components/activity.js +++ b/app/addons/replication/components/activity.js @@ -70,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. +

    { if (!visible) { return null; } - let header = "You are deleting a replication document."; - let btnText = "Delete 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.`; - btnText = "Delete Documents"; + header = `You are deleting ${multipleDocs} replication ${isReplicationDB ? 'documents' : 'jobs'}.`; + btnText = `Delete ${isReplicationDB ? 'Documents' : 'Replication Jobs'}`; } return ( @@ -41,14 +44,8 @@ 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 @@ -63,11 +60,16 @@ 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 }; +DeleteModal.defaultProps = { + isReplicationDB: true +}; + export const ErrorModal = ({visible, onClose, errorMsg, status}) => { if (!visible) { diff --git a/app/addons/replication/components/replicateActivity.js b/app/addons/replication/components/replicateActivity.js index 580f3f265..32f04947d 100644 --- a/app/addons/replication/components/replicateActivity.js +++ b/app/addons/replication/components/replicateActivity.js @@ -91,6 +91,7 @@ export default class Activity extends React.Component { changeSort={changeActivitySort} /> - ); - return elements; } @@ -255,6 +246,26 @@ export default class ReplicationController extends React.Component { 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; @@ -264,14 +275,12 @@ export default class ReplicationController extends React.Component { return ( - + {this.getHeaderComponents()}
    - - {this.getTabElements()} - + {this.getTabs()}
    {this.showSection()}
    From 44590e02f35fc11e2857e4557e0292feb3ee2c01 Mon Sep 17 00:00:00 2001 From: Garren Smith Date: Tue, 7 Mar 2017 17:14:59 +0200 Subject: [PATCH 08/21] create replicator db if it doesn't exist --- app/addons/replication/actions.js | 17 ++++++++++++++++- app/addons/replication/api.js | 38 ++++++++++++++++++++++++++++++-------- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/app/addons/replication/actions.js b/app/addons/replication/actions.js index 0d7703de5..ac451ffba 100644 --- a/app/addons/replication/actions.js +++ b/app/addons/replication/actions.js @@ -14,7 +14,15 @@ import FauxtonAPI from '../../core/api'; import ActionTypes from './actiontypes'; import Helpers from './helpers'; import Constants from './constants'; -import {supportNewApi, createReplicationDoc, fetchReplicateInfo, fetchReplicationDocs, decodeFullUrl, deleteReplicatesApi} from './api'; +import { + supportNewApi, + createReplicationDoc, + fetchReplicateInfo, + fetchReplicationDocs, + decodeFullUrl, + deleteReplicatesApi, + createReplicatorDB +} from './api'; import $ from 'jquery'; @@ -68,6 +76,13 @@ function replicate (params) { }); }, (xhr) => { const errorMessage = JSON.parse(xhr.responseText); + + if (errorMessage.error && errorMessage.error === "not_found") { + return createReplicatorDB().then(() => { + return replicate(params); + }); + } + FauxtonAPI.addNotification({ msg: errorMessage.reason, type: 'error', diff --git a/app/addons/replication/api.js b/app/addons/replication/api.js index 0913c8978..0f881ad38 100644 --- a/app/addons/replication/api.js +++ b/app/addons/replication/api.js @@ -16,6 +16,7 @@ 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 = () => { @@ -289,12 +290,18 @@ export const combineDocsAndScheduler = (docs, schedulerDocs) => { export const fetchReplicationDocs = () => { return supportNewApi() .then(newApi => { - const docsPromise = $.ajax({ - type: 'GET', - url: '/_replicator/_all_docs?include_docs=true&limit=100', - contentType: 'application/json; charset=utf-8', - dataType: 'json', - }).then((res) => { + 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)); }); @@ -305,8 +312,8 @@ export const fetchReplicationDocs = () => { return FauxtonAPI.Promise.join(docsPromise, schedulerPromise, (docs, schedulerDocs) => { return combineDocsAndScheduler(docs, schedulerDocs); }) - .catch(err => { - console.log('err', err); + .catch(() => { + return []; }); }); }; @@ -388,3 +395,18 @@ export const deleteReplicatesApi = (replicates) => { return FauxtonAPI.Promise.all(promises); }; + +export const createReplicatorDB = () => { + return fetch('/_replicator', { + method: 'PUT', + credentials: 'include', + headers: { + 'Accept': 'application/json; charset=utf-8', + } + }) + .then(res => res.json()) + .then(res => { + console.log('created', res); + return true; + }); +}; From 6c605652d699c9522e4ff27770237b5bd550d7bf Mon Sep 17 00:00:00 2001 From: Garren Smith Date: Wed, 8 Mar 2017 17:06:14 +0200 Subject: [PATCH 09/21] more fixes --- app/addons/components/assets/less/styled-select.less | 7 +++++++ app/addons/databases/components.react.jsx | 2 +- app/addons/replication/assets/less/replication.less | 18 +++++++++++++++--- app/addons/replication/components/activity.js | 2 +- app/addons/replication/components/common-table.js | 4 ++-- app/addons/replication/components/modals.js | 1 + app/addons/replication/components/remoteexample.js | 2 +- app/addons/replication/components/replicateActivity.js | 2 +- app/addons/replication/components/submit.js | 1 + app/addons/replication/route.js | 4 ++-- assets/less/notification-center.less | 1 + 11 files changed, 33 insertions(+), 11 deletions(-) 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({
    - {this.stateRow()} + {this.stateCol()} diff --git a/app/addons/replication/components/replicate-activity.js b/app/addons/replication/components/replicate-activity.js index 401603e26..11967933c 100644 --- a/app/addons/replication/components/replicate-activity.js +++ b/app/addons/replication/components/replicate-activity.js @@ -30,10 +30,10 @@ export default class Activity extends React.Component { }); } - showModal (doc) { + showModal (docId) { this.setState({ modalVisible: true, - unconfirmedDeleteDocId: doc + unconfirmedDeleteDocId: docId }); } diff --git a/app/addons/replication/route.js b/app/addons/replication/route.js index 750507ddb..95719b482 100644 --- a/app/addons/replication/route.js +++ b/app/addons/replication/route.js @@ -41,7 +41,7 @@ const ReplicationRouteObject = FauxtonAPI.RouteObject.extend({ }, defaultView: function (databaseName) { - let localSource = databaseName || ''; + const localSource = databaseName || ''; Actions.changeTabSection('new replication'); Actions.clearReplicationForm(); diff --git a/app/addons/replication/stores.js b/app/addons/replication/stores.js index 56d9d754c..b9ee66158 100644 --- a/app/addons/replication/stores.js +++ b/app/addons/replication/stores.js @@ -117,7 +117,7 @@ const ReplicationStore = FauxtonAPI.Store.extend({ getReplicateInfo () { return this._replicateInfo.filter(doc => { return _.values(doc).filter(item => { - if (!item) {return null;} + if (!item) {return false;} return item.toString().toLowerCase().match(this._replicateFilter); }).length > 0; }); From d7acddd54b11aefea042e27867defb72be5cd694 Mon Sep 17 00:00:00 2001 From: Garren Smith Date: Tue, 14 Mar 2017 12:06:43 +0200 Subject: [PATCH 20/21] fix replication NW tests --- app/addons/replication/stores.js | 2 +- app/addons/replication/tests/nightwatch/replication.js | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/addons/replication/stores.js b/app/addons/replication/stores.js index b9ee66158..19badc7d4 100644 --- a/app/addons/replication/stores.js +++ b/app/addons/replication/stores.js @@ -284,7 +284,7 @@ const ReplicationStore = FauxtonAPI.Store.extend({ }, clearReplicationForm () { - Object.values(validFieldMap).forEach(fieldName => this[fieldName] = ''); + _.values(validFieldMap).forEach(fieldName => this[fieldName] = ''); }, getRemoteSource () { 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]) From deb9dab9c88cc928c1a604c785ffea14980c9e47 Mon Sep 17 00:00:00 2001 From: Garren Smith Date: Tue, 14 Mar 2017 16:02:57 +0200 Subject: [PATCH 21/21] throw error on failed _replicator creation --- app/addons/replication/api.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/addons/replication/api.js b/app/addons/replication/api.js index 9611639d1..6f2942323 100644 --- a/app/addons/replication/api.js +++ b/app/addons/replication/api.js @@ -423,7 +423,13 @@ export const createReplicatorDB = () => { 'Accept': 'application/json; charset=utf-8', } }) - .then(res => res.json()) + .then(res => { + if (!res.ok) { + throw {reason: 'Failed to create the _replicator database.'}; + } + + return res.json(); + }) .then(() => { return true; });
    - State - - Actions + href={"#/replication/_create/" + encodedId} /> diff --git a/app/addons/replication/assets/less/replication.less b/app/addons/replication/assets/less/replication.less index 1611fbb58..828749e7c 100644 --- a/app/addons/replication/assets/less/replication.less +++ b/app/addons/replication/assets/less/replication.less @@ -57,6 +57,10 @@ div.replication__page { .replication__input-react-select { font-size: 14px; + .Select .Select-menu-outer { + width: 400px; + } + .Select div.Select-control { padding: 6px; border: 1px solid #cccccc; @@ -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,8 +298,8 @@ input.replication__filter-input[type="text"] { margin-bottom: 0; } -.replication__error-continue { - margin-left: 20px; +button.replication__error-continue { + margin-left: 20px !important; //needed to override bootstrap } .replication__error-cancel, @@ -320,4 +328,8 @@ td.replication__empty-row { .tooltip-inner { text-align: left } -} \ No newline at end of file +} + +.replication__activity-caveat { + padding-left: 80px; +} diff --git a/app/addons/replication/components/activity.js b/app/addons/replication/components/activity.js index af4634bd1..fb9c75a11 100644 --- a/app/addons/replication/components/activity.js +++ b/app/addons/replication/components/activity.js @@ -70,7 +70,7 @@ export default class Activity extends React.Component { const {modalVisible} = this.state; return (
    -

    +

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

    {
  • @@ -134,7 +134,7 @@ const RowActions = ({onlyDeleteAction, _id, url, deleteDocs}) => { actions.push(
  • deleteDocs(_id)}> diff --git a/app/addons/replication/components/modals.js b/app/addons/replication/components/modals.js index df909e000..9ef7c96a9 100644 --- a/app/addons/replication/components/modals.js +++ b/app/addons/replication/components/modals.js @@ -50,6 +50,7 @@ export const DeleteModal = ({ Cancel diff --git a/app/addons/replication/components/remoteexample.js b/app/addons/replication/components/remoteexample.js index 63c09b194..ddde70a70 100644 --- a/app/addons/replication/components/remoteexample.js +++ b/app/addons/replication/components/remoteexample.js @@ -21,7 +21,7 @@ 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.

    ); diff --git a/app/addons/replication/components/replicateActivity.js b/app/addons/replication/components/replicateActivity.js index 32f04947d..401603e26 100644 --- a/app/addons/replication/components/replicateActivity.js +++ b/app/addons/replication/components/replicateActivity.js @@ -70,7 +70,7 @@ export default class Activity extends React.Component { const {modalVisible} = this.state; return (
    -

    +

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

    Date: Thu, 9 Mar 2017 15:56:25 +0200 Subject: [PATCH 10/21] add more tests --- app/addons/replication/__tests__/actions.test.js | 132 +++++++++++++++++++++ app/addons/replication/__tests__/api.tests.js | 145 ++++++++++++++++++++++- app/addons/replication/actions.js | 74 +++++++----- app/addons/replication/api.js | 44 ++++--- app/addons/replication/base.js | 5 + jest-setup.js | 5 + 6 files changed, 356 insertions(+), 49 deletions(-) create mode 100644 app/addons/replication/__tests__/actions.test.js diff --git a/app/addons/replication/__tests__/actions.test.js b/app/addons/replication/__tests__/actions.test.js new file mode 100644 index 000000000..71432a303 --- /dev/null +++ b/app/addons/replication/__tests__/actions.test.js @@ -0,0 +1,132 @@ +// 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} 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); + done(); + } + }); + + fetchMock.getOnce('/_replicator/7dcea9874a8fcb13c6630a1547001559', doc); + getReplicationStateFrom(doc._id); + }); + }); +}); diff --git a/app/addons/replication/__tests__/api.tests.js b/app/addons/replication/__tests__/api.tests.js index 3ebb79273..d12b6f47e 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/job', 200); + return supportNewApi(true) + .then(resp => { + assert.ok(resp); + }); + }); + + it('returns false for no support', () => { + fetchMock.getOnce('/_scheduler/job', 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/job', 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/job', 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/actions.js b/app/addons/replication/actions.js index ac451ffba..3a29925fe 100644 --- a/app/addons/replication/actions.js +++ b/app/addons/replication/actions.js @@ -24,6 +24,7 @@ import { createReplicatorDB } from './api'; import $ from 'jquery'; +import 'whatwg-fetch'; function initReplicator (localSource) { @@ -49,16 +50,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); @@ -67,29 +71,37 @@ function replicate (params) { type: ActionTypes.REPLICATION_STARTING, }); - promise.then(() => { + const handleError = (json) => { + FauxtonAPI.addNotification({ + msg: json.reason, + type: 'error', + clear: true + }); + }; + + promise.then(json => { + if (!json.ok) { + throw json; + } + FauxtonAPI.addNotification({ msg: `Replication from ${decodeURIComponent(source)} to ${decodeURIComponent(target)} has been scheduled.`, type: 'success', escape: false, clear: true }); - }, (xhr) => { - const errorMessage = JSON.parse(xhr.responseText); - - if (errorMessage.error && errorMessage.error === "not_found") { + }) + .catch(json => { + if (json.error && json.error === "not_found") { return createReplicatorDB().then(() => { return replicate(params); - }); + }) + .catch(handleError); } - FauxtonAPI.addNotification({ - msg: errorMessage.reason, - type: 'error', - clear: true - }); + handleError(json); }); -} +}; function updateFormField (fieldName, value) { FauxtonAPI.dispatch({ @@ -227,7 +239,6 @@ const deleteDocs = (docs) => { }; const deleteReplicates = (replicates) => { - console.log('ss', replicates); FauxtonAPI.addNotification({ msg: `Deleting _replicate${replicates.length > 1 ? 's' : ''}.`, type: 'success', @@ -261,17 +272,20 @@ const deleteReplicates = (replicates) => { }); }; -const getReplicationStateFrom = (id) => { +export const getReplicationStateFrom = (id) => { FauxtonAPI.dispatch({ type: ActionTypes.REPLICATION_FETCHING_FORM_STATE }); - $.ajax({ - url: `${app.host}/_replicator/${encodeURIComponent(id)}`, - contentType: 'application/json', - dataType: 'json', + 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, @@ -303,10 +317,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 }); diff --git a/app/addons/replication/api.js b/app/addons/replication/api.js index 0f881ad38..648ea6a02 100644 --- a/app/addons/replication/api.js +++ b/app/addons/replication/api.js @@ -19,18 +19,21 @@ import _ from 'lodash'; import 'whatwg-fetch'; let newApiPromise = null; -export const supportNewApi = () => { - if (!newApiPromise) { +export const supportNewApi = (forceCheck) => { + if (!newApiPromise || forceCheck) { newApiPromise = new FauxtonAPI.Promise((resolve) => { - $.ajax({ - url: '/_scheduler/jobs', - contentType: 'application/json; charset=utf-8', - dataType: 'json', - method: 'GET' - }).then(() => { + fetch('/_scheduler/job', { + credentials: 'include', + headers: { + 'Accept': 'application/json; charset=utf-8', + } + }) + .then(resp => { + if (resp.status === 404) { + return resolve(false); + } + resolve(true); - }, () => { - resolve(false); }); }); } @@ -319,12 +322,18 @@ export const fetchReplicationDocs = () => { }; export const fetchSchedulerDocs = () => { - return $.ajax({ - type: 'GET', - url: '/_scheduler/docs?include_docs=true', - contentType: 'application/json; charset=utf-8', - dataType: 'json', - }).then((res) => { + 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; }); }; @@ -405,8 +414,7 @@ export const createReplicatorDB = () => { } }) .then(res => res.json()) - .then(res => { - console.log('created', res); + .then(() => { return true; }); }; diff --git a/app/addons/replication/base.js b/app/addons/replication/base.js index 6143126eb..02efe80f5 100644 --- a/app/addons/replication/base.js +++ b/app/addons/replication/base.js @@ -18,6 +18,11 @@ 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(); }); }; 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' +}); + From 66894cee1cac36f701e307aebfed04d266a5a387 Mon Sep 17 00:00:00 2001 From: Garren Smith Date: Thu, 9 Mar 2017 16:09:48 +0200 Subject: [PATCH 11/21] update travis --- .travis.yml | 3 +-- docker/dc.selenium.yml | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 48d54d34e..bf859e9cc 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 diff --git a/docker/dc.selenium.yml b/docker/dc.selenium.yml index 41ddd2bef..6fe625c2c 100644 --- a/docker/dc.selenium.yml +++ b/docker/dc.selenium.yml @@ -8,7 +8,7 @@ services: couchdb: container_name: couchdb - image: klaemo/couchdb:2.0-dev@sha256:e9b71abaff6aeaa34ee28604c3aeb78f3a7c789ad74a7b88148e2ef78f1e3b21 + image: klaemo/couchdb:2.0-dev command: '--with-haproxy -a tester:testerpass' ports: - "5984:5984" From 4a8f59840b7fd7b2e2d1b52018cb16687699a2a5 Mon Sep 17 00:00:00 2001 From: Garren Smith Date: Thu, 9 Mar 2017 16:18:48 +0200 Subject: [PATCH 12/21] travis debugging --- .travis.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.travis.yml b/.travis.yml index bf859e9cc..7eccf52bd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,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: From b7328bef2009ee04a660ad172aea95e891559f37 Mon Sep 17 00:00:00 2001 From: Garren Smith Date: Thu, 9 Mar 2017 17:56:00 +0200 Subject: [PATCH 13/21] another curl for travis --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 7eccf52bd..0f10c7662 100644 --- a/.travis.yml +++ b/.travis.yml @@ -20,6 +20,7 @@ before_script: - sleep 30 - docker logs couchdb - curl http://127.0.0.1:5984 + - curl http://127.0.0.1:8000 script: - travis_retry ./node_modules/.bin/grunt nightwatch after_script: From 2fc0294a69d22a66d0fb2b59747161d6c81d31b6 Mon Sep 17 00:00:00 2001 From: Garren Smith Date: Thu, 9 Mar 2017 20:05:03 +0200 Subject: [PATCH 14/21] update selenium --- docker/dc.selenium.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/dc.selenium.yml b/docker/dc.selenium.yml index 6fe625c2c..2f8d2dd9c 100644 --- a/docker/dc.selenium.yml +++ b/docker/dc.selenium.yml @@ -2,7 +2,7 @@ version: '2' services: selenium: container_name: selenium - image: selenium/standalone-firefox:2.48.2 + image: selenium/standalone-firefox:3.1.0 ports: - "4444:4444" From e84c18f8b4497d9c6aabfa7d70b38c5ab55300fd Mon Sep 17 00:00:00 2001 From: Garren Smith Date: Fri, 10 Mar 2017 07:26:59 +0200 Subject: [PATCH 15/21] rename file --- .travis.yml | 1 - .../components/{replicateActivity.js => replicate-activity.js} | 0 app/addons/replication/controller.js | 2 +- docker/dc.selenium.yml | 6 +++--- 4 files changed, 4 insertions(+), 5 deletions(-) rename app/addons/replication/components/{replicateActivity.js => replicate-activity.js} (100%) diff --git a/.travis.yml b/.travis.yml index 0f10c7662..7eccf52bd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -20,7 +20,6 @@ before_script: - sleep 30 - docker logs couchdb - curl http://127.0.0.1:5984 - - curl http://127.0.0.1:8000 script: - travis_retry ./node_modules/.bin/grunt nightwatch after_script: diff --git a/app/addons/replication/components/replicateActivity.js b/app/addons/replication/components/replicate-activity.js similarity index 100% rename from app/addons/replication/components/replicateActivity.js rename to app/addons/replication/components/replicate-activity.js diff --git a/app/addons/replication/controller.js b/app/addons/replication/controller.js index 65f91ddef..22c0b35b2 100644 --- a/app/addons/replication/controller.js +++ b/app/addons/replication/controller.js @@ -19,7 +19,7 @@ import Activity from './components/activity'; import {checkReplicationDocID} from './api'; import {OnePane, OnePaneHeader, OnePaneContent} from '../components/layouts'; import {TabElementWrapper, TabElement} from '../components/components/tabelement'; -import ReplicateActivity from './components/replicateactivity'; +import ReplicateActivity from './components/replicate-activity'; const {LoadLines, Polling, RefreshBtn} = Components; diff --git a/docker/dc.selenium.yml b/docker/dc.selenium.yml index 2f8d2dd9c..5d1c3855b 100644 --- a/docker/dc.selenium.yml +++ b/docker/dc.selenium.yml @@ -2,13 +2,13 @@ version: '2' services: selenium: container_name: selenium - image: selenium/standalone-firefox:3.1.0 + image: selenium/standalone-firefox:2.48.2 ports: - "4444:4444" couchdb: container_name: couchdb - image: klaemo/couchdb:2.0-dev + 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 From 2398e0ac27fb0dbf9666fb4865409de117530d6b Mon Sep 17 00:00:00 2001 From: Garren Smith Date: Mon, 13 Mar 2017 15:16:09 +0200 Subject: [PATCH 16/21] api and action tests --- app/addons/replication/__tests__/actions.test.js | 54 ++++++++++++++++++- app/addons/replication/actions.js | 67 ++++++++++++++++-------- app/addons/replication/api.js | 58 +++++++++++--------- 3 files changed, 130 insertions(+), 49 deletions(-) diff --git a/app/addons/replication/__tests__/actions.test.js b/app/addons/replication/__tests__/actions.test.js index 71432a303..804aa6bab 100644 --- a/app/addons/replication/__tests__/actions.test.js +++ b/app/addons/replication/__tests__/actions.test.js @@ -10,7 +10,7 @@ // License for the specific language governing permissions and limitations under // the License. import utils from '../../../../test/mocha/testUtils'; -import {replicate, getReplicationStateFrom} from '../actions'; +import {replicate, getReplicationStateFrom, deleteDocs} from '../actions'; import ActionTypes from '../actiontypes'; import fetchMock from 'fetch-mock'; import app from '../../../app'; @@ -121,7 +121,7 @@ describe("Replication Actions", () => { FauxtonAPI.dispatcher.register(({type, options}) => { if (ActionTypes.REPLICATION_SET_STATE_FROM_DOC === type) { assert.deepEqual(docState, options); - done(); + setTimeout(done); } }); @@ -129,4 +129,54 @@ describe("Replication Actions", () => { 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/actions.js b/app/addons/replication/actions.js index 3a29925fe..efda7273a 100644 --- a/app/addons/replication/actions.js +++ b/app/addons/replication/actions.js @@ -9,7 +9,6 @@ // 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'; @@ -23,7 +22,6 @@ import { deleteReplicatesApi, createReplicatorDB } from './api'; -import $ from 'jquery'; import 'whatwg-fetch'; @@ -36,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: { @@ -131,14 +134,21 @@ const getReplicationActivity = (supportNewApi) => { }; const getReplicateActivity = () => { - FauxtonAPI.dispatch({ - type: ActionTypes.REPLICATION_FETCHING_REPLICATE_STATUS, - }); + supportNewApi() + .then(newApi => { + if (!newApi) { + return; + } - fetchReplicateInfo().then(replicateInfo => { FauxtonAPI.dispatch({ - type: ActionTypes.REPLICATION_REPLICATE_STATUS, - options: replicateInfo + type: ActionTypes.REPLICATION_FETCHING_REPLICATE_STATUS, + }); + + fetchReplicateInfo().then(replicateInfo => { + FauxtonAPI.dispatch({ + type: ActionTypes.REPLICATION_REPLICATE_STATUS, + options: replicateInfo + }); }); }); }; @@ -195,7 +205,7 @@ const clearSelectedReplicates = () => { }); }; -const deleteDocs = (docs) => { +export const deleteDocs = (docs) => { const bulkDocs = docs.map(({raw: doc}) => { doc._deleted = true; return doc; @@ -208,13 +218,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.json(); + } + return resp.json(); + }) + .then(() => { + let msg = 'The selected documents have been deleted.'; if (docs.length === 1) { msg = `Document ${docs[0]._id} has been deleted`; @@ -226,12 +246,13 @@ const deleteDocs = (docs) => { escape: false, clear: true }); + clearSelectedDocs(); getReplicationActivity(); - }, (xhr) => { - const errorMessage = JSON.parse(xhr.responseText); + }) + .catch(error => { FauxtonAPI.addNotification({ - msg: errorMessage.reason, + msg: error.reason, type: 'error', clear: true }); diff --git a/app/addons/replication/api.js b/app/addons/replication/api.js index 648ea6a02..471ce04f1 100644 --- a/app/addons/replication/api.js +++ b/app/addons/replication/api.js @@ -29,7 +29,7 @@ export const supportNewApi = (forceCheck) => { } }) .then(resp => { - if (resp.status === 404) { + if (resp.status > 202) { return resolve(false); } @@ -340,15 +340,13 @@ export const fetchSchedulerDocs = () => { 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; } @@ -376,13 +374,22 @@ export const parseReplicateInfo = (resp) => { }; export const fetchReplicateInfo = () => { - return $.ajax({ - type: 'GET', - url: `/_scheduler/jobs`, - contentType: 'application/json; charset=utf-8', - dataType: 'json', - }).then(resp => { - return parseReplicateInfo(resp); + return supportNewApi() + .then(newApi => { + if (!newApi) { + return []; + } + + fetch('/_scheduler/jobs', { + credentials: 'include', + headers: { + 'Accept': 'application/json; charset=utf-8' + }, + }) + .then(resp => { + console.log('fetch replicate', resp); + return parseReplicateInfo(resp.json()); + }); }); }; @@ -393,13 +400,16 @@ export const deleteReplicatesApi = (replicates) => { cancel: true }; - return $.ajax({ - type: 'POST', - url: '/_replicate', - contentType: 'application/json; charset=utf-8', - dataType: 'json', - data: JSON.stringify(data) - }); + 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); From 9ca187d82a0e5e80ebfd66cfff2021a49048fc46 Mon Sep 17 00:00:00 2001 From: Garren Smith Date: Mon, 13 Mar 2017 15:56:11 +0200 Subject: [PATCH 17/21] fixes --- app/addons/replication/actions.js | 19 ++++++++++++------- app/addons/replication/api.js | 8 ++++---- app/addons/replication/components/common-table.js | 1 + 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/app/addons/replication/actions.js b/app/addons/replication/actions.js index efda7273a..83abb240e 100644 --- a/app/addons/replication/actions.js +++ b/app/addons/replication/actions.js @@ -144,7 +144,8 @@ const getReplicateActivity = () => { type: ActionTypes.REPLICATION_FETCHING_REPLICATE_STATUS, }); - fetchReplicateInfo().then(replicateInfo => { + fetchReplicateInfo() + .then(replicateInfo => { FauxtonAPI.dispatch({ type: ActionTypes.REPLICATION_REPLICATE_STATUS, options: replicateInfo @@ -229,7 +230,7 @@ export const deleteDocs = (docs) => { }) .then(resp => { if (!resp.ok) { - throw resp.json(); + throw resp; } return resp.json(); }) @@ -250,12 +251,16 @@ export const deleteDocs = (docs) => { clearSelectedDocs(); getReplicationActivity(); }) - .catch(error => { - FauxtonAPI.addNotification({ - msg: error.reason, - type: 'error', - clear: true + .catch(resp => { + resp.json() + .then(error => { + FauxtonAPI.addNotification({ + msg: error.reason, + type: 'error', + clear: true + }); }); + }); }; diff --git a/app/addons/replication/api.js b/app/addons/replication/api.js index 471ce04f1..9611639d1 100644 --- a/app/addons/replication/api.js +++ b/app/addons/replication/api.js @@ -22,7 +22,7 @@ let newApiPromise = null; export const supportNewApi = (forceCheck) => { if (!newApiPromise || forceCheck) { newApiPromise = new FauxtonAPI.Promise((resolve) => { - fetch('/_scheduler/job', { + fetch('/_scheduler/jobs', { credentials: 'include', headers: { 'Accept': 'application/json; charset=utf-8', @@ -380,15 +380,15 @@ export const fetchReplicateInfo = () => { return []; } - fetch('/_scheduler/jobs', { + return fetch('/_scheduler/jobs', { credentials: 'include', headers: { 'Accept': 'application/json; charset=utf-8' }, }) + .then(resp => resp.json()) .then(resp => { - console.log('fetch replicate', resp); - return parseReplicateInfo(resp.json()); + return parseReplicateInfo(resp); }); }); }; diff --git a/app/addons/replication/components/common-table.js b/app/addons/replication/components/common-table.js index 84b81f22b..7bfb4df97 100644 --- a/app/addons/replication/components/common-table.js +++ b/app/addons/replication/components/common-table.js @@ -291,6 +291,7 @@ export class ReplicationTable extends React.Component { } renderRows () { + console.log(this.props.docs); if (this.props.docs.length === 0) { return ; } From fd1f90bff861cd190a4d54e2147f6e4772e3ca0d Mon Sep 17 00:00:00 2001 From: Garren Smith Date: Mon, 13 Mar 2017 16:03:33 +0200 Subject: [PATCH 18/21] fix tests --- app/addons/replication/__tests__/api.tests.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/addons/replication/__tests__/api.tests.js b/app/addons/replication/__tests__/api.tests.js index d12b6f47e..54721c2f4 100644 --- a/app/addons/replication/__tests__/api.tests.js +++ b/app/addons/replication/__tests__/api.tests.js @@ -310,7 +310,7 @@ describe('Replication API', () => { }); it('returns true for support', () => { - fetchMock.getOnce('/_scheduler/job', 200); + fetchMock.getOnce('/_scheduler/jobs', 200); return supportNewApi(true) .then(resp => { assert.ok(resp); @@ -318,7 +318,7 @@ describe('Replication API', () => { }); it('returns false for no support', () => { - fetchMock.getOnce('/_scheduler/job', 404); + fetchMock.getOnce('/_scheduler/jobs', 404); return supportNewApi(true) .then(resp => { assert.notOk(resp); @@ -414,7 +414,7 @@ describe('Replication API', () => { }); it("returns parsedReplicationDocs", () => { - fetchMock.getOnce('/_scheduler/job', 404); + fetchMock.getOnce('/_scheduler/jobs', 404); fetchMock.get('/_replicator/_all_docs?include_docs=true&limit=100', _repDocs); return supportNewApi(true) .then(fetchReplicationDocs) @@ -431,7 +431,7 @@ describe('Replication API', () => { }); it("returns parsedReplicationDocs", () => { - fetchMock.getOnce('/_scheduler/job', 200); + 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) From eeaf8c81c627a711f58814bffc24f4ee9d7f67d0 Mon Sep 17 00:00:00 2001 From: Garren Smith Date: Tue, 14 Mar 2017 11:36:07 +0200 Subject: [PATCH 19/21] review fixes --- .../replication/components/common-activity.js | 57 ---------------------- app/addons/replication/components/common-table.js | 9 ++-- .../replication/components/replicate-activity.js | 4 +- app/addons/replication/route.js | 2 +- app/addons/replication/stores.js | 2 +- 5 files changed, 8 insertions(+), 66 deletions(-) diff --git a/app/addons/replication/components/common-activity.js b/app/addons/replication/components/common-activity.js index 7c6f3e180..5a75766b2 100644 --- a/app/addons/replication/components/common-activity.js +++ b/app/addons/replication/components/common-activity.js @@ -10,63 +10,6 @@ // License for the specific language governing permissions and limitations under // the License. import React from 'react'; -import {DeleteModal} from './modals'; - -export class BulkDeleteController extends React.Component { - constructor (props) { - super(props); - this.state = { - modalVisible: false, - unconfirmedDeleteDocId: null - }; - } - - closeModal () { - this.setState({ - modalVisible: false, - unconfirmedDeleteDocId: null - }); - } - - showModal (doc) { - this.setState({ - modalVisible: true, - unconfirmedDeleteDocId: doc - }); - } - - 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 {modalVisible} = this.state; - return ; - } -} - -BulkDeleteController.propTypes = { - docs: React.PropTypes.array.isRequired, - deleteDocs: React.PropTypes.func.isRequired -}; export const ReplicationFilter = ({value, onChange}) => { return ( diff --git a/app/addons/replication/components/common-table.js b/app/addons/replication/components/common-table.js index 7bfb4df97..4f287c545 100644 --- a/app/addons/replication/components/common-table.js +++ b/app/addons/replication/components/common-table.js @@ -112,7 +112,7 @@ const RowActions = ({onlyDeleteAction, _id, url, deleteDocs}) => { @@ -122,7 +122,7 @@ const RowActions = ({onlyDeleteAction, _id, url, deleteDocs}) => {
  • @@ -291,7 +291,6 @@ export class ReplicationTable extends React.Component { } renderRows () { - console.log(this.props.docs); if (this.props.docs.length === 0) { return ; } @@ -343,7 +342,7 @@ export class ReplicationTable extends React.Component { return ''; } - stateRow () { + stateCol () { if (this.props.showStateRow) { return (
  • @@ -386,7 +385,7 @@ export class ReplicationTable extends React.Component { Type Actions