Don't pop-up notifications after network switch (#4076)

* Better notifications

* Don't pollute with notifs if switched networks

* Better connection close/open events / No more notifs on change network

* PR Grumbles

* Add close and open events to HTTP // Add tests

* Fix tests

* WIP Signer Fix

* Fix Signer // Better reconnection handling

* PR Grumbles

* PR Grumbles

* Fixes wrong fetching of balances + Notifications

* Secure API WIP

* Updated Secure API Connection + Status

* Linting

* Linting

* Updated Secure API Logic

* Proper handling of token updates // Fixing poping notifications

* PR Grumbles

* PR Grumbles

* Fixing tests
This commit is contained in:
Nicolas Gotchac 2017-01-12 14:25:32 +01:00 committed by Jaco Greeff
parent bc2ebdc564
commit 81beec1352
22 changed files with 904 additions and 287 deletions

View File

@ -14,6 +14,8 @@
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
import EventEmitter from 'eventemitter3';
import { Http, Ws } from './transport';
import Contract from './contract';
@ -22,8 +24,10 @@ import Subscriptions from './subscriptions';
import util from './util';
import { isFunction } from './util/types';
export default class Api {
export default class Api extends EventEmitter {
constructor (transport) {
super();
if (!transport || !isFunction(transport.execute)) {
throw new Error('EthApi needs transport with execute() function defined');
}

View File

@ -51,14 +51,14 @@ export default class Http extends JsonRpcBase {
return fetch(this._url, request)
.catch((error) => {
this._connected = false;
this._setDisconnected();
throw error;
})
.then((response) => {
this._connected = true;
this._setConnected();
if (response.status !== 200) {
this._connected = false;
this._setDisconnected();
this.error(JSON.stringify({ status: response.status, statusText: response.statusText }));
console.error(`${method}(${JSON.stringify(params)}): ${response.status}: ${response.statusText}`);

View File

@ -37,6 +37,26 @@ describe('api/transport/Http', () => {
});
});
describe('transport emitter', () => {
it('emits close event', (done) => {
transport.once('close', () => {
done();
});
transport.execute('eth_call');
});
it('emits open event', (done) => {
mockHttp([{ method: 'eth_call', reply: { result: '' } }]);
transport.once('open', () => {
done();
});
transport.execute('eth_call');
});
});
describe('transport', () => {
const RESULT = ['this is some result'];

View File

@ -14,8 +14,12 @@
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
export default class JsonRpcBase {
import EventEmitter from 'eventemitter3';
export default class JsonRpcBase extends EventEmitter {
constructor () {
super();
this._id = 1;
this._debug = false;
this._connected = false;
@ -32,6 +36,20 @@ export default class JsonRpcBase {
return json;
}
_setConnected () {
if (!this._connected) {
this._connected = true;
this.emit('open');
}
}
_setDisconnected () {
if (this._connected) {
this._connected = false;
this.emit('close');
}
}
get id () {
return this._id;
}

View File

@ -22,7 +22,7 @@ import TransportError from '../error';
/* global WebSocket */
export default class Ws extends JsonRpcBase {
constructor (url, token, connect = true) {
constructor (url, token, autoconnect = true) {
super();
this._url = url;
@ -32,14 +32,14 @@ export default class Ws extends JsonRpcBase {
this._connecting = false;
this._connected = false;
this._lastError = null;
this._autoConnect = false;
this._autoConnect = autoconnect;
this._retries = 0;
this._reconnectTimeoutId = null;
this._connectPromise = null;
this._connectPromiseFunctions = {};
if (connect) {
if (autoconnect) {
this.connect();
}
}
@ -124,11 +124,8 @@ export default class Ws extends JsonRpcBase {
}
_onOpen = (event) => {
console.log('ws:onOpen');
this._connected = true;
this._setConnected();
this._connecting = false;
this._autoConnect = true;
this._retries = 0;
Object.keys(this._messages)
@ -142,7 +139,7 @@ export default class Ws extends JsonRpcBase {
}
_onClose = (event) => {
this._connected = false;
this._setDisconnected();
this._connecting = false;
event.timestamp = Date.now();
@ -209,8 +206,8 @@ export default class Ws extends JsonRpcBase {
if (result.error) {
this.error(event.data);
// Don't print error if request rejected...
if (!/rejected/.test(result.error.message)) {
// Don't print error if request rejected or not is not yet up...
if (!/(rejected|not yet up)/.test(result.error.message)) {
console.error(`${method}(${JSON.stringify(params)}): ${result.error.code}: ${result.error.message}`);
}

View File

@ -21,6 +21,37 @@ describe('api/transport/Ws', () => {
let transport;
let scope;
describe('transport emitter', () => {
const connect = () => {
const scope = mockWs();
const transport = new Ws(TEST_WS_URL);
return { transport, scope };
};
it('emits open event', (done) => {
const { transport, scope } = connect();
transport.once('open', () => {
done();
});
scope.stop();
});
it('emits close event', (done) => {
const { transport, scope } = connect();
transport.once('open', () => {
scope.server.close();
});
transport.once('close', () => {
done();
});
});
});
describe('transport', () => {
let result;

View File

@ -17,12 +17,20 @@
import LogLevel from 'loglevel';
export const LOG_KEYS = {
Balances: {
key: 'balances',
desc: 'Balances fetching'
},
TransferModalStore: {
path: 'modals/Transfer/store',
desc: 'Transfer Modal MobX Store'
key: 'modalsTransferStore',
desc: 'Transfer modal MobX store'
},
Signer: {
key: 'secureApi',
desc: 'The Signer and the Secure API'
}
};
export const getLogger = (LOG_KEY) => {
return LogLevel.getLogger(LOG_KEY.path);
return LogLevel.getLogger(LOG_KEY.key);
};

View File

@ -21,51 +21,164 @@ import { padRight } from '~/api/util/format';
import Contracts from '~/contracts';
let instance = null;
export default class Balances {
constructor (store, api) {
this._api = api;
this._store = store;
this._tokenregSubId = null;
this._tokenregMetaSubId = null;
this._tokenreg = null;
this._tokenregSID = null;
this._tokenMetaSID = null;
// Throttled `retrieveTokens` function
this._blockNumberSID = null;
this._accountsInfoSID = null;
// Throtthled load tokens (no more than once
// every minute)
this.loadTokens = throttle(
this._loadTokens,
60 * 1000,
{ leading: true, trailing: true }
);
// Throttled `_fetchBalances` function
// that gets called max once every 40s
this.longThrottledFetch = throttle(
this.fetchBalances,
this._fetchBalances,
40 * 1000,
{ trailing: true }
{ leading: false, trailing: true }
);
this.shortThrottledFetch = throttle(
this.fetchBalances,
this._fetchBalances,
2 * 1000,
{ trailing: true }
{ leading: false, trailing: true }
);
// Fetch all tokens every 2 minutes
this.throttledTokensFetch = throttle(
this.fetchTokens,
60 * 1000,
{ trailing: true }
this._fetchTokens,
2 * 60 * 1000,
{ leading: false, trailing: true }
);
// Unsubscribe previous instance if it exists
if (instance) {
Balances.stop();
}
}
start () {
this.subscribeBlockNumber();
this.subscribeAccountsInfo();
static get (store = {}) {
if (!instance && store) {
const { api } = store.getState();
return Balances.instantiate(store, api);
}
this.loadTokens();
return instance;
}
static instantiate (store, api) {
if (!instance) {
instance = new Balances(store, api);
}
return instance;
}
static start () {
if (!instance) {
return Promise.reject('BalancesProvider has not been intiated yet');
}
const self = instance;
// Unsubscribe from previous subscriptions
return Balances
.stop()
.then(() => self.loadTokens())
.then(() => {
const promises = [
self.subscribeBlockNumber(),
self.subscribeAccountsInfo()
];
return Promise.all(promises);
});
}
static stop () {
if (!instance) {
return Promise.resolve();
}
const self = instance;
const promises = [];
if (self._blockNumberSID) {
const p = self._api
.unsubscribe(self._blockNumberSID)
.then(() => {
self._blockNumberSID = null;
});
promises.push(p);
}
if (self._accountsInfoSID) {
const p = self._api
.unsubscribe(self._accountsInfoSID)
.then(() => {
self._accountsInfoSID = null;
});
promises.push(p);
}
// Unsubscribe without adding the promises
// to the result, since it would have to wait for a
// reconnection to resolve if the Node is disconnected
if (self._tokenreg) {
if (self._tokenregSID) {
const tokenregSID = self._tokenregSID;
self._tokenreg
.unsubscribe(tokenregSID)
.then(() => {
if (self._tokenregSID === tokenregSID) {
self._tokenregSID = null;
}
});
}
if (self._tokenMetaSID) {
const tokenMetaSID = self._tokenMetaSID;
self._tokenreg
.unsubscribe(tokenMetaSID)
.then(() => {
if (self._tokenMetaSID === tokenMetaSID) {
self._tokenMetaSID = null;
}
});
}
}
return Promise.all(promises);
}
subscribeAccountsInfo () {
this._api
return this._api
.subscribe('parity_allAccountsInfo', (error, accountsInfo) => {
if (error) {
return;
}
this.fetchBalances();
this.fetchAllBalances();
})
.then((accountsInfoSID) => {
this._accountsInfoSID = accountsInfoSID;
})
.catch((error) => {
console.warn('_subscribeAccountsInfo', error);
@ -73,49 +186,97 @@ export default class Balances {
}
subscribeBlockNumber () {
this._api
return this._api
.subscribe('eth_blockNumber', (error) => {
if (error) {
return console.warn('_subscribeBlockNumber', error);
}
const { syncing } = this._store.getState().nodeStatus;
this.throttledTokensFetch();
// If syncing, only retrieve balances once every
// few seconds
if (syncing) {
this.shortThrottledFetch.cancel();
return this.longThrottledFetch();
}
this.longThrottledFetch.cancel();
return this.shortThrottledFetch();
return this.fetchAllBalances();
})
.then((blockNumberSID) => {
this._blockNumberSID = blockNumberSID;
})
.catch((error) => {
console.warn('_subscribeBlockNumber', error);
});
}
fetchBalances () {
this._store.dispatch(fetchBalances());
fetchAllBalances (options = {}) {
// If it's a network change, reload the tokens
// ( and then fetch the tokens balances ) and fetch
// the accounts balances
if (options.changedNetwork) {
this.loadTokens({ skipNotifications: true });
this.loadTokens.flush();
this.fetchBalances({
force: true,
skipNotifications: true
});
return;
}
fetchTokens () {
this._store.dispatch(fetchTokensBalances());
this.fetchTokensBalances(options);
this.fetchBalances(options);
}
fetchTokensBalances (options) {
const { skipNotifications = false, force = false } = options;
this.throttledTokensFetch(skipNotifications);
if (force) {
this.throttledTokensFetch.flush();
}
}
fetchBalances (options) {
const { skipNotifications = false, force = false } = options;
const { syncing } = this._store.getState().nodeStatus;
// If syncing, only retrieve balances once every
// few seconds
if (syncing) {
this.shortThrottledFetch.cancel();
this.longThrottledFetch(skipNotifications);
if (force) {
this.longThrottledFetch.flush();
}
return;
}
this.longThrottledFetch.cancel();
this.shortThrottledFetch(skipNotifications);
if (force) {
this.shortThrottledFetch.flush();
}
}
_fetchBalances (skipNotifications = false) {
this._store.dispatch(fetchBalances(null, skipNotifications));
}
_fetchTokens (skipNotifications = false) {
this._store.dispatch(fetchTokensBalances(null, null, skipNotifications));
}
getTokenRegistry () {
return Contracts.get().tokenReg.getContract();
}
loadTokens () {
this
_loadTokens (options = {}) {
return this
.getTokenRegistry()
.then((tokenreg) => {
this._tokenreg = tokenreg;
this._store.dispatch(setTokenReg(tokenreg));
this._store.dispatch(loadTokens());
this._store.dispatch(loadTokens(options));
return this.attachToTokens(tokenreg);
})
@ -133,7 +294,7 @@ export default class Balances {
}
attachToNewToken (tokenreg) {
if (this._tokenregSubId) {
if (this._tokenregSID) {
return Promise.resolve();
}
@ -149,13 +310,13 @@ export default class Balances {
this.handleTokensLogs(logs);
})
.then((tokenregSubId) => {
this._tokenregSubId = tokenregSubId;
.then((tokenregSID) => {
this._tokenregSID = tokenregSID;
});
}
attachToTokenMetaChange (tokenreg) {
if (this._tokenregMetaSubId) {
if (this._tokenMetaSID) {
return Promise.resolve();
}
@ -172,8 +333,8 @@ export default class Balances {
this.handleTokensLogs(logs);
})
.then((tokenregMetaSubId) => {
this._tokenregMetaSubId = tokenregMetaSubId;
.then((tokenMetaSID) => {
this._tokenMetaSID = tokenMetaSID;
});
}

View File

@ -23,18 +23,27 @@ import { setAddressImage } from './imagesActions';
import * as ABIS from '~/contracts/abi';
import { notifyTransaction } from '~/util/notifications';
import { LOG_KEYS, getLogger } from '~/config';
import imagesEthereum from '../../../assets/images/contracts/ethereum-black-64x64.png';
const log = getLogger(LOG_KEYS.Balances);
const ETH = {
name: 'Ethereum',
tag: 'ETH',
image: imagesEthereum
};
function setBalances (_balances) {
function setBalances (_balances, skipNotifications = false) {
return (dispatch, getState) => {
const state = getState();
const currentTokens = Object.values(state.balances.tokens || {});
const currentTags = [ 'eth' ]
.concat(currentTokens.map((token) => token.tag))
.filter((tag) => tag)
.map((tag) => tag.toLowerCase());
const accounts = state.personal.accounts;
const nextBalances = _balances;
const prevBalances = state.balances.balances;
@ -48,37 +57,54 @@ function setBalances (_balances) {
const balance = Object.assign({}, balances[address]);
const { tokens, txCount = balance.txCount } = nextBalances[address];
const nextTokens = balance.tokens.slice();
tokens.forEach((t) => {
const { token, value } = t;
const { tag } = token;
const prevTokens = balance.tokens.slice();
const nextTokens = [];
const tokenIndex = nextTokens.findIndex((tok) => tok.token.tag === tag);
currentTags
.forEach((tag) => {
const prevToken = prevTokens.find((tok) => tok.token.tag.toLowerCase() === tag);
const nextToken = tokens.find((tok) => tok.token.tag.toLowerCase() === tag);
if (tokenIndex === -1) {
nextTokens.push({
token,
value
// If the given token is not in the current tokens, skip
if (!nextToken && !prevToken) {
return false;
}
// No updates
if (!nextToken) {
return nextTokens.push(prevToken);
}
const { token, value } = nextToken;
// If it's a new token, push it
if (!prevToken) {
return nextTokens.push({
token, value
});
} else {
const oldValue = nextTokens[tokenIndex].value;
}
// Otherwise, update the value
const prevValue = prevToken.value;
// If received a token/eth (old value < new value), notify
if (oldValue.lt(value) && accounts[address]) {
if (prevValue.lt(value) && accounts[address] && !skipNotifications) {
const account = accounts[address];
const txValue = value.minus(oldValue);
const txValue = value.minus(prevValue);
const redirectToAccount = () => {
const route = `/account/${account.address}`;
const route = `/accounts/${account.address}`;
dispatch(push(route));
};
notifyTransaction(account, token, txValue, redirectToAccount);
}
nextTokens[tokenIndex] = { token, value };
}
return nextTokens.push({
...prevToken,
value
});
});
balances[address] = { txCount: txCount || new BigNumber(0), tokens: nextTokens };
@ -123,7 +149,9 @@ export function setTokenImage (tokenAddress, image) {
};
}
export function loadTokens () {
export function loadTokens (options = {}) {
log.debug('loading tokens', Object.keys(options).length ? options : '');
return (dispatch, getState) => {
const { tokenreg } = getState().balances;
@ -131,7 +159,7 @@ export function loadTokens () {
.call()
.then((numTokens) => {
const tokenIds = range(numTokens.toNumber());
dispatch(fetchTokens(tokenIds));
dispatch(fetchTokens(tokenIds, options));
})
.catch((error) => {
console.warn('balances::loadTokens', error);
@ -139,8 +167,9 @@ export function loadTokens () {
};
}
export function fetchTokens (_tokenIds) {
export function fetchTokens (_tokenIds, options = {}) {
const tokenIds = uniq(_tokenIds || []);
return (dispatch, getState) => {
const { api, images, balances } = getState();
const { tokenreg } = balances;
@ -161,8 +190,9 @@ export function fetchTokens (_tokenIds) {
dispatch(setAddressImage(address, image, true));
});
log.debug('fetched token', tokens);
dispatch(setTokens(tokens));
dispatch(fetchBalances());
dispatch(updateTokensFilter(null, null, options));
})
.catch((error) => {
console.warn('balances::fetchTokens', error);
@ -170,7 +200,7 @@ export function fetchTokens (_tokenIds) {
};
}
export function fetchBalances (_addresses) {
export function fetchBalances (_addresses, skipNotifications = false) {
return (dispatch, getState) => {
const { api, personal } = getState();
const { visibleAccounts, accounts } = personal;
@ -192,8 +222,7 @@ export function fetchBalances (_addresses) {
balances[addr] = accountsBalances[idx];
});
dispatch(setBalances(balances));
updateTokensFilter(addresses)(dispatch, getState);
dispatch(setBalances(balances, skipNotifications));
})
.catch((error) => {
console.warn('balances::fetchBalances', error);
@ -201,7 +230,7 @@ export function fetchBalances (_addresses) {
};
}
export function updateTokensFilter (_addresses, _tokens) {
export function updateTokensFilter (_addresses, _tokens, options = {}) {
return (dispatch, getState) => {
const { api, balances, personal } = getState();
const { visibleAccounts, accounts } = personal;
@ -214,27 +243,32 @@ export function updateTokensFilter (_addresses, _tokens) {
const tokenAddresses = tokens.map((t) => t.address).sort();
if (tokensFilter.filterFromId || tokensFilter.filterToId) {
// Has the tokens addresses changed (eg. a network change)
const sameTokens = isEqual(tokenAddresses, tokensFilter.tokenAddresses);
const sameAddresses = isEqual(addresses, tokensFilter.addresses);
if (sameTokens && sameAddresses) {
// Addresses that are not in the current filter (omit those
// that the filter includes)
const newAddresses = addresses.filter((address) => !tokensFilter.addresses.includes(address));
// If no new addresses and the same tokens, don't change the filter
if (sameTokens && newAddresses.length === 0) {
log.debug('no need to update token filter', addresses, tokenAddresses, tokensFilter);
return queryTokensFilter(tokensFilter)(dispatch, getState);
}
}
let promise = Promise.resolve();
log.debug('updating the token filter', addresses, tokenAddresses);
const promises = [];
if (tokensFilter.filterFromId) {
promise = promise.then(() => api.eth.uninstallFilter(tokensFilter.filterFromId));
promises.push(api.eth.uninstallFilter(tokensFilter.filterFromId));
}
if (tokensFilter.filterToId) {
promise = promise.then(() => api.eth.uninstallFilter(tokensFilter.filterToId));
promises.push(api.eth.uninstallFilter(tokensFilter.filterToId));
}
if (tokenAddresses.length === 0 || addresses.length === 0) {
return promise;
}
const promise = Promise.all(promises);
const TRANSFER_SIGNATURE = api.util.sha3('Transfer(address,address,uint256)');
const topicsFrom = [ TRANSFER_SIGNATURE, addresses, null ];
@ -269,8 +303,10 @@ export function updateTokensFilter (_addresses, _tokens) {
addresses, tokenAddresses
};
const { skipNotifications } = options;
dispatch(setTokensFilter(nextTokensFilter));
fetchTokensBalances(addresses, tokens)(dispatch, getState);
fetchTokensBalances(addresses, tokens, skipNotifications)(dispatch, getState);
})
.catch((error) => {
console.warn('balances::updateTokensFilter', error);
@ -326,7 +362,7 @@ export function queryTokensFilter (tokensFilter) {
};
}
export function fetchTokensBalances (_addresses = null, _tokens = null) {
export function fetchTokensBalances (_addresses = null, _tokens = null, skipNotifications = false) {
return (dispatch, getState) => {
const { api, personal, balances } = getState();
const { visibleAccounts, accounts } = personal;
@ -348,7 +384,7 @@ export function fetchTokensBalances (_addresses = null, _tokens = null) {
balances[addr] = tokensBalances[idx];
});
dispatch(setBalances(balances));
dispatch(setBalances(balances, skipNotifications));
})
.catch((error) => {
console.warn('balances::fetchTokensBalances', error);

View File

@ -14,6 +14,7 @@
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
import BalancesProvider from './balances';
import { showSnackbar } from './snackbarActions';
import { DEFAULT_NETCHAIN } from './statusReducer';
@ -29,6 +30,11 @@ export default class ChainMiddleware {
if (newChain !== nodeStatus.netChain && nodeStatus.netChain !== DEFAULT_NETCHAIN) {
store.dispatch(showSnackbar(`Switched to ${newChain}. Please reload the page.`, 60000));
// Fetch the new balances without notifying the user of any change
BalancesProvider.get(store).fetchAllBalances({
changedNetwork: true
});
}
}
}

View File

@ -16,13 +16,18 @@
import sinon from 'sinon';
import Contracts from '~/contracts';
import { initialState as defaultNodeStatusState } from './statusReducer';
import ChainMiddleware from './chainMiddleware';
import { createWsApi } from '~/../test/e2e/ethapi';
let middleware;
let next;
let store;
const api = createWsApi();
Contracts.create(api);
function createMiddleware (collection = {}) {
middleware = new ChainMiddleware().toMiddleware();
next = sinon.stub();
@ -30,6 +35,7 @@ function createMiddleware (collection = {}) {
dispatch: sinon.stub(),
getState: () => {
return {
api: api,
nodeStatus: Object.assign({}, defaultNodeStatusState, collection)
};
}

View File

@ -16,7 +16,8 @@
import { isEqual, intersection } from 'lodash';
import { fetchBalances } from './balancesActions';
import BalancesProvider from './balances';
import { updateTokensFilter } from './balancesActions';
import { attachWallets } from './walletActions';
import Contract from '~/api/contract';
@ -98,7 +99,10 @@ export function personalAccountsInfo (accountsInfo) {
dispatch(_personalAccountsInfo(data));
dispatch(attachWallets(wallets));
dispatch(fetchBalances());
BalancesProvider.get().fetchAllBalances({
force: true
});
})
.catch((error) => {
console.warn('personalAccountsInfo', error);
@ -130,6 +134,18 @@ export function setVisibleAccounts (addresses) {
}
dispatch(_setVisibleAccounts(addresses));
dispatch(fetchBalances(addresses));
// Don't update the balances if no new addresses displayed
if (addresses.length === 0) {
return;
}
// Update the Tokens filter to take into account the new
// addresses
dispatch(updateTokensFilter());
BalancesProvider.get().fetchBalances({
force: true
});
};
}

View File

@ -14,9 +14,15 @@
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
import BalancesProvider from './balances';
import { statusBlockNumber, statusCollection, statusLogs } from './statusActions';
import { isEqual } from 'lodash';
import { LOG_KEYS, getLogger } from '~/config';
const log = getLogger(LOG_KEYS.Signer);
let instance = null;
export default class Status {
constructor (store, api) {
this._api = api;
@ -27,20 +33,90 @@ export default class Status {
this._longStatus = {};
this._minerSettings = {};
this._longStatusTimeoutId = null;
this._timeoutIds = {};
this._blockNumberSubscriptionId = null;
this._timestamp = Date.now();
// On connecting, stop all subscriptions
api.on('connecting', this.stop, this);
// On connected, start the subscriptions
api.on('connected', this.start, this);
// On disconnected, stop all subscriptions
api.on('disconnected', this.stop, this);
this.updateApiStatus();
}
static instantiate (store, api) {
if (!instance) {
instance = new Status(store, api);
}
return instance;
}
start () {
this._subscribeBlockNumber();
this._pollStatus();
this._pollLongStatus();
this._pollLogs();
log.debug('status::start');
Promise
.all([
this._subscribeBlockNumber(),
this._pollLogs(),
this._pollLongStatus(),
this._pollStatus()
])
.then(() => {
return BalancesProvider.start();
});
}
_subscribeBlockNumber () {
this._api
stop () {
log.debug('status::stop');
const promises = [];
if (this._blockNumberSubscriptionId) {
const promise = this._api
.unsubscribe(this._blockNumberSubscriptionId)
.then(() => {
this._blockNumberSubscriptionId = null;
});
promises.push(promise);
}
Object.values(this._timeoutIds).forEach((timeoutId) => {
clearTimeout(timeoutId);
});
const promise = BalancesProvider.stop();
promises.push(promise);
return Promise.all(promises)
.then(() => true)
.catch((error) => {
console.error('status::stop', error);
return true;
})
.then(() => this.updateApiStatus());
}
updateApiStatus () {
const apiStatus = this.getApiStatus();
log.debug('status::updateApiStatus', apiStatus);
if (!isEqual(apiStatus, this._apiStatus)) {
this._store.dispatch(statusCollection(apiStatus));
this._apiStatus = apiStatus;
}
}
_subscribeBlockNumber = () => {
return this._api
.subscribe('eth_blockNumber', (error, blockNumber) => {
if (error) {
return;
@ -51,6 +127,10 @@ export default class Status {
this._api.eth
.getBlockByNumber(blockNumber)
.then((block) => {
if (!block) {
return;
}
this._store.dispatch(statusCollection({
blockTimestamp: block.timestamp,
gasLimit: block.gasLimit
@ -59,6 +139,9 @@ export default class Status {
.catch((error) => {
console.warn('status._subscribeBlockNumber', 'getBlockByNumber', error);
});
})
.then((blockNumberSubscriptionId) => {
this._blockNumberSubscriptionId = blockNumberSubscriptionId;
});
}
@ -72,11 +155,7 @@ export default class Status {
.catch(() => false);
}
_pollStatus = () => {
const nextTimeout = (timeout = 1000) => {
setTimeout(() => this._pollStatus(), timeout);
};
getApiStatus = () => {
const { isConnected, isConnecting, needsToken, secureToken } = this._api;
const apiStatus = {
@ -86,19 +165,23 @@ export default class Status {
secureToken
};
const gotConnected = !this._apiStatus.isConnected && apiStatus.isConnected;
if (gotConnected) {
this._pollLongStatus();
return apiStatus;
}
if (!isEqual(apiStatus, this._apiStatus)) {
this._store.dispatch(statusCollection(apiStatus));
this._apiStatus = apiStatus;
_pollStatus = () => {
const nextTimeout = (timeout = 1000) => {
if (this._timeoutIds.status) {
clearTimeout(this._timeoutIds.status);
}
if (!isConnected) {
return nextTimeout(250);
this._timeoutIds.status = setTimeout(() => this._pollStatus(), timeout);
};
this.updateApiStatus();
if (!this._api.isConnected) {
nextTimeout(250);
return Promise.resolve();
}
const { refreshStatus } = this._store.getState().nodeStatus;
@ -110,7 +193,7 @@ export default class Status {
statusPromises.push(this._api.eth.hashrate());
}
Promise
return Promise
.all(statusPromises)
.then(([ syncing, ...statusResults ]) => {
const status = statusResults.length === 0
@ -125,11 +208,11 @@ export default class Status {
this._store.dispatch(statusCollection(status));
this._status = status;
}
nextTimeout();
})
.catch((error) => {
console.error('_pollStatus', error);
})
.then(() => {
nextTimeout();
});
}
@ -140,7 +223,7 @@ export default class Status {
* from the UI
*/
_pollMinerSettings = () => {
Promise
return Promise
.all([
this._api.eth.coinbase(),
this._api.parity.extraData(),
@ -175,21 +258,21 @@ export default class Status {
*/
_pollLongStatus = () => {
if (!this._api.isConnected) {
return;
return Promise.resolve();
}
const nextTimeout = (timeout = 30000) => {
if (this._longStatusTimeoutId) {
clearTimeout(this._longStatusTimeoutId);
if (this._timeoutIds.longStatus) {
clearTimeout(this._timeoutIds.longStatus);
}
this._longStatusTimeoutId = setTimeout(this._pollLongStatus, timeout);
this._timeoutIds.longStatus = setTimeout(() => this._pollLongStatus(), timeout);
};
// Poll Miner settings just in case
this._pollMinerSettings();
const minerPromise = this._pollMinerSettings();
Promise
const mainPromise = Promise
.all([
this._api.parity.netPeers(),
this._api.web3.clientVersion(),
@ -225,21 +308,31 @@ export default class Status {
})
.catch((error) => {
console.error('_pollLongStatus', error);
})
.then(() => {
nextTimeout(60000);
});
nextTimeout(60000);
return Promise.all([ minerPromise, mainPromise ]);
}
_pollLogs = () => {
const nextTimeout = (timeout = 1000) => setTimeout(this._pollLogs, timeout);
const nextTimeout = (timeout = 1000) => {
if (this._timeoutIds.logs) {
clearTimeout(this._timeoutIds.logs);
}
this._timeoutIds.logs = setTimeout(this._pollLogs, timeout);
};
const { devLogsEnabled } = this._store.getState().nodeStatus;
if (!devLogsEnabled) {
nextTimeout();
return;
return Promise.resolve();
}
Promise
return Promise
.all([
this._api.parity.devLogs(),
this._api.parity.devLogsLevels()
@ -249,11 +342,12 @@ export default class Status {
devLogs: devLogs.slice(-1024),
devLogsLevels
}));
nextTimeout();
})
.catch((error) => {
console.error('_pollLogs', error);
nextTimeout();
})
.then(() => {
return nextTimeout();
});
}
}

View File

@ -38,10 +38,10 @@ export default function (api, browserHistory) {
const middleware = initMiddleware(api, browserHistory);
const store = applyMiddleware(...middleware)(storeCreation)(reducers);
new BalancesProvider(store, api).start();
BalancesProvider.instantiate(store, api);
StatusProvider.instantiate(store, api);
new PersonalProvider(store, api).start();
new SignerProvider(store, api).start();
new StatusProvider(store, api).start();
store.dispatch(loadWallet(api));
setupWorker(store);

View File

@ -17,133 +17,34 @@
import { uniq } from 'lodash';
import Api from './api';
import { LOG_KEYS, getLogger } from '~/config';
const log = getLogger(LOG_KEYS.Signer);
const sysuiToken = window.localStorage.getItem('sysuiToken');
export default class SecureApi extends Api {
_isConnecting = false;
_needsToken = false;
_tokens = [];
_dappsInterface = null;
_dappsPort = 8080;
_signerPort = 8180;
constructor (url, nextToken) {
super(new Api.Transport.Ws(url, sysuiToken, false));
const transport = new Api.Transport.Ws(url, sysuiToken, false);
super(transport);
this._url = url;
this._isConnecting = true;
this._needsToken = false;
this._dappsPort = 8080;
this._dappsInterface = null;
this._signerPort = 8180;
// Try tokens from localstorage, then from hash
// Try tokens from localstorage, from hash and 'initial'
this._tokens = uniq([sysuiToken, nextToken, 'initial'])
.filter((token) => token)
.map((token) => ({ value: token, tried: false }));
this._tryNextToken();
}
saveToken = () => {
window.localStorage.setItem('sysuiToken', this._transport.token);
// DEBUG: console.log('SecureApi:saveToken', this._transport.token);
}
/**
* Returns a Promise that gets resolved with
* a boolean: `true` if the node is up, `false`
* otherwise
*/
_checkNodeUp () {
const url = this._url.replace(/wss?/, 'http');
return fetch(url, { method: 'HEAD' })
.then(
(r) => r.status === 200,
() => false
)
.catch(() => false);
}
_setManual () {
this._needsToken = true;
this._isConnecting = false;
}
_tryNextToken () {
const nextTokenIndex = this._tokens.findIndex((t) => !t.tried);
if (nextTokenIndex < 0) {
return this._setManual();
}
const nextToken = this._tokens[nextTokenIndex];
nextToken.tried = true;
this.updateToken(nextToken.value);
}
_followConnection = () => {
const token = this.transport.token;
return this
.transport
.connect()
.then(() => {
if (token === 'initial') {
return this.signer
.generateAuthorizationToken()
.then((token) => {
return this.updateToken(token);
})
.catch((e) => console.error(e));
}
this.connectSuccess();
return true;
})
.catch((e) => {
this
._checkNodeUp()
.then((isNodeUp) => {
// Try again in a few...
if (!isNodeUp) {
this._isConnecting = false;
const timeout = this.transport.retryTimeout;
window.setTimeout(() => {
this._followConnection();
}, timeout);
return;
}
this._tryNextToken();
return false;
});
});
}
connectSuccess () {
this._isConnecting = false;
this._needsToken = false;
this.saveToken();
Promise
.all([
this.parity.dappsPort(),
this.parity.dappsInterface(),
this.parity.signerPort()
])
.then(([dappsPort, dappsInterface, signerPort]) => {
this._dappsPort = dappsPort.toNumber();
this._dappsInterface = dappsInterface;
this._signerPort = signerPort.toNumber();
});
// DEBUG: console.log('SecureApi:connectSuccess', this._transport.token);
}
updateToken (token) {
this._transport.updateToken(token.replace(/[^a-zA-Z0-9]/g, ''), false);
return this._followConnection();
// DEBUG: console.log('SecureApi:updateToken', this._transport.token, connectState);
// When the transport is closed, try to reconnect
transport.on('close', this.connect, this);
this.connect();
}
get dappsPort () {
@ -151,17 +52,19 @@ export default class SecureApi extends Api {
}
get dappsUrl () {
let hostname;
if (window.location.hostname === 'home.parity') {
hostname = 'dapps.parity';
} else if (!this._dappsInterface || this._dappsInterface === '0.0.0.0') {
hostname = window.location.hostname;
} else {
hostname = this._dappsInterface;
return `http://${this.hostname}:${this.dappsPort}`;
}
return `http://${hostname}:${this._dappsPort}`;
get hostname () {
if (window.location.hostname === 'home.parity') {
return 'dapps.parity';
}
if (!this._dappsInterface || this._dappsInterface === '0.0.0.0') {
return window.location.hostname;
}
return this._dappsInterface;
}
get signerPort () {
@ -183,4 +86,279 @@ export default class SecureApi extends Api {
get secureToken () {
return this._transport.token;
}
connect () {
if (this._isConnecting) {
return;
}
log.debug('trying to connect...');
this._isConnecting = true;
this.emit('connecting');
// Reset the tested Tokens
this._resetTokens();
// Try to connect
return this._connect()
.then((connected) => {
this._isConnecting = false;
if (connected) {
const token = this.secureToken;
log.debug('got connected ; saving token', token);
// Save the sucessful token
this._saveToken(token);
this._needsToken = false;
// Emit the connected event
return this.emit('connected');
}
// If not connected, we need a new token
log.debug('needs a token');
this._needsToken = true;
return this.emit('disconnected');
})
.catch((error) => {
this._isConnecting = false;
log.debug('emitting "disconnected"');
this.emit('disconnected');
console.error('unhandled error in secureApi', error);
});
}
/**
* Returns a Promise that gets resolved with
* a boolean: `true` if the node is up, `false`
* otherwise (HEAD request to the Node)
*/
isNodeUp () {
const url = this._url.replace(/wss?/, 'http');
return fetch(url, { method: 'HEAD' })
.then(
(r) => r.status === 200,
() => false
)
.catch(() => false);
}
/**
* Update the given token, ie. add it to the token
* list, and then try to connect (if not already connecting)
*/
updateToken (_token) {
const token = this._sanitiseToken(_token);
log.debug('updating token', token);
// Update the tokens list: put the new one on first position
this._tokens = [ { value: token, tried: false } ].concat(this._tokens);
// Try to connect with the new token added
return this.connect();
}
/**
* Try to connect to the Node with the next Token in
* the list
*/
_connect () {
log.debug('trying next token');
// Get the first not-tried token
const nextToken = this._getNextToken();
// If no more tokens to try, user has to enter a new one
if (!nextToken) {
return Promise.resolve(false);
}
nextToken.tried = true;
return this._connectWithToken(nextToken.value)
.then((validToken) => {
// If not valid, try again with the next token in the list
if (!validToken) {
return this._connect();
}
// If correct and valid token, wait until the Node is ready
// and resolve as connected
return this._waitUntilNodeReady()
.then(() => this._fetchSettings())
.then(() => true);
})
.catch((error) => {
log.error('unkown error in _connect', error);
return false;
});
}
/**
* Connect with the given token.
* It returns a Promise that gets resolved
* with `validToken` as argument, whether the given token
* is valid or not
*/
_connectWithToken (_token) {
// Sanitize the token first
const token = this._sanitiseToken(_token);
// Update the token in the transport layer
this.transport.updateToken(token, false);
log.debug('connecting with token', token);
return this.transport.connect()
.then(() => {
log.debug('connected with', token);
if (token === 'initial') {
return this._generateAuthorizationToken();
}
// The token is valid !
return true;
})
.catch((error) => {
// Log if it's not a close error (ie. wrong token)
if (error && error.type !== 'close') {
log.debug('did not connect ; error', error);
}
// Check if the Node is up
return this.isNodeUp()
.then((isNodeUp) => {
// If it's not up, try again in a few...
if (!isNodeUp) {
const timeout = this.transport.retryTimeout;
log.debug('node is not up ; will try again in', timeout, 'ms');
return new Promise((resolve, reject) => {
window.setTimeout(() => {
this._connectWithToken(token).then(resolve).catch(reject);
}, timeout);
});
}
// The token is invalid
log.debug('tried with a wrong token', token);
return false;
});
});
}
/**
* Retrieve the correct ports from the Node
*/
_fetchSettings () {
return Promise
.all([
this.parity.dappsPort(),
this.parity.dappsInterface(),
this.parity.signerPort()
])
.then(([dappsPort, dappsInterface, signerPort]) => {
this._dappsPort = dappsPort.toNumber();
this._dappsInterface = dappsInterface;
this._signerPort = signerPort.toNumber();
});
}
/**
* Try to generate an Authorization Token.
* Then try to connect with the new token.
*/
_generateAuthorizationToken () {
return this.signer
.generateAuthorizationToken()
.then((token) => this._connectWithToken(token));
}
/**
* Get the next token to try, if any left
*/
_getNextToken () {
// Get the first not-tried token
const nextTokenIndex = this._tokens.findIndex((t) => !t.tried);
// If no more tokens to try, user has to enter a new one
if (nextTokenIndex < 0) {
return null;
}
const nextToken = this._tokens[nextTokenIndex];
return nextToken;
}
_resetTokens () {
this._tokens = this._tokens.map((token) => ({
...token,
tried: false
}));
}
_sanitiseToken (token) {
return token.replace(/[^a-zA-Z0-9]/g, '');
}
_saveToken (token) {
window.localStorage.setItem('sysuiToken', token);
}
/**
* Promise gets resolved when the node is up
* and running (it might take some time before
* the node is actually ready even when the client
* is connected).
*
* We check that the `parity_enode` RPC calls
* returns successfully
*/
_waitUntilNodeReady (_timeleft) {
// Default timeout to 30 seconds
const timeleft = Number.isFinite(_timeleft)
? _timeleft
: 30 * 1000;
// After timeout, just resolve the promise...
if (timeleft <= 0) {
console.warn('node is still not ready after 30 seconds...');
return Promise.resolve(true);
}
const start = Date.now();
return this
.parity.enode()
.then(() => true)
.catch((error) => {
if (!error) {
return true;
}
if (error.type !== 'NETWORK_DISABLED') {
throw error;
}
// Timeout between 250ms and 750ms
const timeout = Math.floor(250 + (500 * Math.random()));
log.debug('waiting until node is ready', 'retry in', timeout, 'ms');
// Retry in a few...
return new Promise((resolve, reject) => {
window.setTimeout(() => {
const duration = Date.now() - start;
this._waitUntilNodeReady(timeleft - duration).then(resolve).catch(reject);
}, timeout);
});
});
}
}

View File

@ -16,7 +16,6 @@
import Push from 'push.js';
import BigNumber from 'bignumber.js';
import { noop } from 'lodash';
import { fromWei } from '~/api/util/wei';
@ -33,13 +32,34 @@ export function notifyTransaction (account, token, _value, onClick) {
? ethereumIcon
: (token.image || unkownIcon);
Push.create(`${name}`, {
body: `You just received ${value.toFormat()} ${token.tag.toUpperCase()}`,
let _notification = null;
Push
.create(`${name}`, {
body: `You just received ${value.toFormat(3)} ${token.tag.toUpperCase()}`,
icon: {
x16: icon,
x32: icon
},
timeout: 20000,
onClick: onClick || noop
onClick: () => {
// Focus on the UI
try {
window.focus();
} catch (e) {}
if (onClick && typeof onClick === 'function') {
onClick();
}
// Close the notification
if (_notification) {
_notification.close();
_notification = null;
}
}
})
.then((notification) => {
_notification = notification;
});
}

View File

@ -59,6 +59,10 @@ class Status extends Component {
renderConsensus () {
const { upgradeStore } = this.props;
if (!upgradeStore || !upgradeStore.consensusCapability) {
return null;
}
if (upgradeStore.consensusCapability === 'capable') {
return (
<div>
@ -67,7 +71,9 @@ class Status extends Component {
defaultMessage='Capable' />
</div>
);
} else if (upgradeStore.consensusCapability.capableUntil) {
}
if (upgradeStore.consensusCapability.capableUntil) {
return (
<div>
<FormattedMessage
@ -78,7 +84,9 @@ class Status extends Component {
} } />
</div>
);
} else if (upgradeStore.consensusCapability.incapableSince) {
}
if (upgradeStore.consensusCapability.incapableSince) {
return (
<div>
<FormattedMessage

View File

@ -41,9 +41,9 @@ class Connection extends Component {
}
render () {
const { isConnected, needsToken } = this.props;
const { isConnecting, isConnected, needsToken } = this.props;
if (isConnected) {
if (!isConnecting && isConnected) {
return null;
}

View File

@ -51,7 +51,7 @@ export default class Parity extends Component {
Object.keys(LOG_KEYS).map((logKey) => {
const log = LOG_KEYS[logKey];
const logger = LogLevel.getLogger(log.path);
const logger = LogLevel.getLogger(log.key);
const level = logger.getLevel();
nextState[logKey] = { level, log };
@ -133,11 +133,11 @@ export default class Parity extends Component {
return Object.keys(logLevels).map((logKey) => {
const { level, log } = logLevels[logKey];
const { path, desc } = log;
const { key, desc } = log;
const onChange = (_, index) => {
const nextLevel = Object.values(selectValues)[index].value;
LogLevel.getLogger(path).setLevel(nextLevel);
LogLevel.getLogger(key).setLevel(nextLevel);
this.loadLogLevels();
};

View File

@ -37,6 +37,14 @@ export default class MiningSettings extends Component {
const { nodeStatus } = this.props;
const { coinbase, defaultExtraData, extraData, gasFloorTarget, minGasPrice } = nodeStatus;
const extradata = extraData
? decodeExtraData(extraData)
: '';
const defaultExtradata = defaultExtraData
? decodeExtraData(defaultExtraData)
: '';
return (
<div { ...this._testInherit() }>
<ContainerTitle title='mining settings' />
@ -53,9 +61,9 @@ export default class MiningSettings extends Component {
<Input
label='extradata'
hint='extra data for mined blocks'
value={ decodeExtraData(extraData) }
value={ extradata }
onSubmit={ this.onExtraDataChange }
defaultValue={ decodeExtraData(defaultExtraData) }
defaultValue={ defaultExtradata }
allowCopy
floatCopy
{ ...this._test('extra-data') }

View File

@ -95,9 +95,15 @@ export default class Status extends Component {
renderSettings () {
const { nodeStatus } = this.props;
const { rpcSettings, netPeers } = nodeStatus;
const { rpcSettings, netPeers, netPort = '' } = nodeStatus;
const peers = `${netPeers.active}/${netPeers.connected}/${netPeers.max}`;
if (!rpcSettings) {
return null;
}
const rpcPort = rpcSettings.port || '';
return (
<div { ...this._test('settings') }>
<ContainerTitle title='network settings' />
@ -121,7 +127,7 @@ export default class Status extends Component {
allowCopy
readOnly
label='network port'
value={ nodeStatus.netPort.toString() }
value={ netPort.toString() }
{ ...this._test('network-port') } />
</div>
</div>
@ -146,7 +152,7 @@ export default class Status extends Component {
allowCopy
readOnly
label='rpc port'
value={ rpcSettings.port.toString() }
value={ rpcPort.toString() }
{ ...this._test('rpc-port') } />
</div>
</div>

View File

@ -47,8 +47,8 @@ export function mockHttp (requests) {
}
export function mockWs (requests) {
const scope = { requests: 0, body: {} };
let mockServer = new MockWsServer(TEST_WS_URL);
const scope = { requests: 0, body: {}, server: mockServer };
scope.isDone = () => scope.requests === requests.length;
scope.stop = () => {