diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e52599f78..8342edd599 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,3 +57,5 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [0.4.3] - 2018-02-05 ### Changed * Renamed Cosmos UI to Cosmos Voyager. @nylira +* Added Google Analytics for testnet versions +* Added Sentry error reporting for testnet versions \ No newline at end of file diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md index a5c5776b89..73a70e213f 100644 --- a/PULL_REQUEST_TEMPLATE.md +++ b/PULL_REQUEST_TEMPLATE.md @@ -3,8 +3,8 @@ ### Issue - - + +closes: ISSUE ### Screenshots diff --git a/app/index.ejs b/app/index.ejs index 9f518f6bae..995a4d7b2f 100644 --- a/app/index.ejs +++ b/app/index.ejs @@ -23,3 +23,16 @@ + + +<% if (htmlWebpackPlugin.options.enableAnalytics) { %> + + + +<% } %> \ No newline at end of file diff --git a/app/src/main/index.js b/app/src/main/index.js index bfc8e41764..b60f3d24cb 100644 --- a/app/src/main/index.js +++ b/app/src/main/index.js @@ -10,6 +10,7 @@ let semver = require('semver') let event = require('event-to-promise') let toml = require('toml') let axios = require('axios') +let Raven = require('raven') let pkg = require('../../../package.json') let relayServer = require('./relayServer.js') @@ -177,6 +178,8 @@ function startProcess (name, args, env) { child.on('exit', (code) => !shuttingDown && log(`${name} exited with code ${code}`)) child.on('error', function (err) { if (!(shuttingDown && err.code === 'ECONNRESET')) { + // TODO test + Raven.captureException(err) // if we throw errors here, they are not handled by the main process console.error('[Uncaught Exception] Child', name, 'produced an unhandled exception:', err) console.log('Shutting down UI') @@ -303,15 +306,16 @@ function setupLogging (root) { if (!TEST) { process.on('exit', shutdown) + // on uncaught exceptions we wait so the sentry event can be sent process.on('uncaughtException', async function (err) { + await sleep(1000) logError('[Uncaught Exception]', err) - console.error('[Uncaught Exception]', err) await shutdown() process.exit(1) }) process.on('unhandledRejection', async function (err) { + await sleep(1000) logError('[Unhandled Promise Rejection]', err) - console.error('[Unhandled Promise Rejection]', err) await shutdown() process.exit(1) }) @@ -388,7 +392,21 @@ async function reconnect (seeds) { return nodeIP } +function setupAnalytics () { + let networkIsWhitelisted = config.analytics_networks.indexOf(config.default_network) !== -1 + if (networkIsWhitelisted) { + log('Adding analytics') + } + + // only enable sending of error events in production setups and if the network is a testnet + Raven.config(networkIsWhitelisted && process.env.NODE_ENV === 'production' ? config.sentry_dsn : '', { + captureUnhandledRejections: true + }).install() +} + async function main () { + setupAnalytics() + let appVersionPath = join(root, 'app_version') let genesisPath = join(root, 'genesis.json') let configPath = join(root, 'config.toml') @@ -421,7 +439,7 @@ async function main () { // TODO: versions of the app with different data formats will need to learn how to // migrate old data throw Error(`Data was created with an incompatible app version - data=${existingVersion} app=${pkg.version}`) + data=${existingVersion} app=${pkg.version}`) } } else { throw Error(`The data directory (${root}) has missing files`) @@ -463,7 +481,7 @@ async function main () { // TODO: semver check, or exact match? if (gaiaVersion !== expectedGaiaVersion) { throw Error(`Requires gaia ${expectedGaiaVersion}, but got ${gaiaVersion}. - Please update your gaia installation or build with a newer binary.`) + Please update your gaia installation or build with a newer binary.`) } // read chainId from genesis.json @@ -479,8 +497,8 @@ async function main () { } catch (e) { throw new Error(`Can't open config.toml: ${e.message}`) } - let config = toml.parse(configText) - let seeds = config.p2p.seeds.split(',').filter(x => x !== '') + let configTOML = toml.parse(configText) + let seeds = configTOML.p2p.seeds.split(',').filter(x => x !== '') if (seeds.length === 0) { throw new Error('No seeds specified in config.toml') } diff --git a/app/src/network.js b/app/src/network.js index 0a743d5752..f7c417ca7f 100644 --- a/app/src/network.js +++ b/app/src/network.js @@ -1,9 +1,10 @@ let { join } = require('path') let { readFileSync } = require('fs') +let config = require('../../config.js') // this network gets used if none is specified via the // COSMOS_NETWORK env var -let DEFAULT_NETWORK = join(__dirname, '../networks/gaia-2') +let DEFAULT_NETWORK = join(__dirname, '../networks/' + config.default_network) let networkPath = process.env.COSMOS_NETWORK || DEFAULT_NETWORK let genesisText = readFileSync(join(networkPath, 'genesis.json'), 'utf8') diff --git a/app/src/renderer/components/staking/LiDelegate.vue b/app/src/renderer/components/staking/LiDelegate.vue index 355fb587d1..063d845571 100644 --- a/app/src/renderer/components/staking/LiDelegate.vue +++ b/app/src/renderer/components/staking/LiDelegate.vue @@ -8,11 +8,13 @@ span {{ num.prettyInt(delegate.voting_power) }} .bar(:style='vpStyles') .li-delegate__value.bonded_by_you - span {{ amountBonded }} + span {{ bondedByYou }} .li-delegate__value.status span {{ delegateType }} template(v-if="userCanDelegate") - .li-delegate__value.checkbox#remove-from-cart(v-if="inCart" @click='rm(delegate)') + .li-delegate__value.checkbox(v-if="bondedByYou > 0") + i.material-icons lock + .li-delegate__value.checkbox#remove-from-cart(v-else-if="inCart" @click='rm(delegate)') i.material-icons check_box .li-delegate__value.checkbox#add-to-cart(v-else @click='add(delegate)') i.material-icons check_box_outline_blank @@ -33,12 +35,12 @@ export default { }, computed: { ...mapGetters(['shoppingCart', 'delegates', 'config', 'committedDelegations', 'user']), - amountBonded () { + bondedByYou () { return this.num.prettyInt(this.committedDelegations[this.delegate.id]) }, styles () { let value = '' - if (this.inCart) value += 'li-delegate-active ' + if (this.inCart || this.bondedByYou > 0) value += 'li-delegate-active ' if (this.delegate.isValidator) value += 'li-delegate-validator ' return value }, @@ -78,6 +80,13 @@ export default { methods: { add (delegate) { this.$store.commit('addToCart', delegate) }, rm (delegate) { this.$store.commit('removeFromCart', delegate.id) } + }, + watch: { + bondedByYou (newVal, oldVal) { + if (newVal > 0) { + this.$store.commit('addToCart', this.delegate) + } + } } } @@ -141,6 +150,7 @@ export default { &.checkbox justify-content center + cursor pointer span white-space nowrap diff --git a/app/src/renderer/components/staking/PageBond.vue b/app/src/renderer/components/staking/PageBond.vue index 8ff42fc5c9..dbe920a44c 100644 --- a/app/src/renderer/components/staking/PageBond.vue +++ b/app/src/renderer/components/staking/PageBond.vue @@ -57,7 +57,10 @@ page.page-bond(title="Bond Atoms") type="number" placeholder="Atoms" step="1" - v-model.number="d.atoms") + min="0" + :max="$v.fields.delegates.$each[index].atoms.$params.between.max" + v-model.number="d.atoms" + @change.native="limitMax(d, $event)") form-msg(name="Atoms" type="required" v-if="!$v.fields.delegates.$each[index].atoms.required") @@ -149,10 +152,6 @@ export default { oldUnbondedAtoms () { return this.totalAtoms - this.oldBondedAtoms }, - // not used - // newBondedAtoms () { - // return this.fields.delegates.reduce((sum, d) => sum + (d.atoms || 0), 0) - // }, newUnbondedAtoms () { return this.fields.delegates.reduce((atoms, d) => { let delta = d.oldAtoms - d.atoms @@ -206,8 +205,8 @@ export default { this.$store.commit('activateDelegation') try { await this.$store.dispatch('submitDelegation', this.fields) - this.$store.commit('notify', { title: 'Atoms Bonded', - body: 'You have successfully updated your delegations.' }) + this.$store.commit('notify', { title: 'Successful Delegation', + body: 'You have successfully bonded / unbonded.' }) this.$router.push('/staking') } catch (err) { this.$store.commit('notifyError', { title: 'Error While Bonding Atoms', @@ -284,14 +283,17 @@ export default { restrictSize: { min: { width: offset } } }) .on('resizemove', (event) => { - let target = event.target - let ratio = (event.rect.width - offset) / (this.bondBarOuterWidth - offset) - let rawAtoms = ratio * this.totalAtoms + this.handleResize(event.target, event.rect.width) + }) + }, + handleResize (element, width) { + let offset = this.bondBarScrubWidth + let ratio = Math.round((width - offset) / (this.bondBarOuterWidth - offset) * 100) / 100 + let rawAtoms = ratio * this.totalAtoms - target.style.width = event.rect.width + 'px' + element.style.width = width + 'px' - this.updateDelegateAtoms(target.id.split('-')[1], rawAtoms) - }) + return this.updateDelegateAtoms(element.id.split('-')[1], rawAtoms) }, updateDelegateAtoms (delegateId, rawAtoms) { let d = this.fields.delegates.find(d => d.id === delegateId) @@ -301,8 +303,8 @@ export default { d.deltaAtoms = this.delta(rawAtoms, d.oldAtoms, 'int') d.deltaAtomsPercent = this.percent(this.delta(rawAtoms, d.oldAtoms), this.totalAtoms) + return d } - return d.atoms }, setBondBarOuterWidth () { let outerBar = this.$el.querySelector('.bond-bar__outer') @@ -325,6 +327,14 @@ export default { value = Math.round(ratio * 100) } return value + '%' + }, + limitMax (delegate, event) { + let max = parseInt(event.target.max) + if (delegate.atoms >= max) { + console.log(`${delegate.atoms} <= ${max}`) + delegate.atoms = max + return + } } }, async mounted () { diff --git a/app/src/renderer/components/staking/PageDelegates.vue b/app/src/renderer/components/staking/PageDelegates.vue index 8ab7d5ee48..0cba7c9a13 100644 --- a/app/src/renderer/components/staking/PageDelegates.vue +++ b/app/src/renderer/components/staking/PageDelegates.vue @@ -30,7 +30,6 @@ page(title='Validators and Candidates') @@ -123,6 +121,7 @@ export default { left 0 right 0 z-index z(toolBar) + .label color bright line-height 2rem diff --git a/app/src/renderer/lcdClient.js b/app/src/renderer/lcdClient.js new file mode 100644 index 0000000000..ae4b76654c --- /dev/null +++ b/app/src/renderer/lcdClient.js @@ -0,0 +1,105 @@ +'use strict' + +const axios = require('axios') + +// returns an async function which makes a request for the given +// HTTP method (GET/POST/DELETE/etc) and path (/foo/bar) +function req (method, path) { + return async function (data) { + return await this.request(method, path, data) + } +} + +// returns an async function which makes a request for the given +// HTTP method and path, which accepts arguments to be appended +// to the path (/foo/{arg}/...) +function argReq (method, path) { + return async function (args, data) { + // `args` can either be a single value or an array + if (Array.isArray(args)) { + args = args.join('/') + } + return await this.request(method, `${path}/${args}`, data) + } +} + +class Client { + constructor (server = 'http://localhost:8998') { + this.server = server + } + + async request (method, path, data) { + try { + let res = await axios[method.toLowerCase()](this.server + path, data) + return res.data + } catch (resError) { + if (!resError.response || !resError.response.data) { + throw resError + } + let data = resError.response.data + // server responded with error message, create an Error from that + let error = Error(data.error) + error.code = data.code + throw error + } + } +} + +let fetchAccount = argReq('GET', '/query/account') +let fetchNonce = argReq('GET', '/query/nonce') + +Object.assign(Client.prototype, { + sign: req('POST', '/sign'), + postTx: req('POST', '/tx'), + + // keys + generateKey: req('POST', '/keys'), + listKeys: req('GET', '/keys'), + getKey: argReq('GET', '/keys'), + updateKey: argReq('PUT', '/keys'), + deleteKey: argReq('DELETE', '/keys'), + recoverKey: req('POST', '/keys/recover'), + + // coins + buildSend: req('POST', '/build/send'), + async queryAccount (address) { + try { + return await fetchAccount.call(this, address) + } catch (err) { + // if account not found, return null instead of throwing + if (err.message.includes('account bytes are empty')) { + return null + } + throw err + } + }, + coinTxs: argReq('GET', '/tx/coin'), + + // nonce + async queryNonce (address) { + try { + return await fetchNonce.call(this, address) + } catch (err) { + // if nonce not found, return 0 instead of throwing + if (err.message.includes('nonce empty')) { + return 0 + } + throw err + } + }, + + // Tendermint RPC + status: req('GET', '/tendermint/status'), + + // staking + candidate: argReq('GET', '/query/stake/candidates'), + candidates: req('GET', '/query/stake/candidates'), + buildDelegate: req('POST', '/build/stake/delegate'), + buildUnbond: req('POST', '/build/stake/unbond'), + bondingsByDelegator: argReq('GET', '/tx/bondings/delegator'), + bondingsByValidator: argReq('GET', '/tx/bondings/validator') + + // TODO: separate API registration for different modules +}) + +module.exports = Client diff --git a/app/src/renderer/main.js b/app/src/renderer/main.js index 5042de94d4..9454bec942 100644 --- a/app/src/renderer/main.js +++ b/app/src/renderer/main.js @@ -5,16 +5,33 @@ import Router from 'vue-router' import Vuelidate from 'vuelidate' import shrinkStacktrace from '../helpers/shrink-stacktrace.js' import axios from 'axios' +import Raven from 'raven-js' +import {remote} from 'electron' + +const config = require('../../../config') import App from './App' import routes from './routes' import Node from './node' import Store from './vuex/store' +// setup sentry remote error reporting on testnets +const networkIsWhitelisted = config.analytics_networks.indexOf(config.default_network) !== -1 +Raven.config(networkIsWhitelisted && remote.getGlobal('process').env.NODE_ENV === 'production' ? config.sentry_dsn : '').install() + +// handle uncaught errors +window.addEventListener('unhandledrejection', function (event) { + Raven.captureException(event.reason) +}) +window.addEventListener('error', function (event) { + Raven.captureException(event.reason) +}) Vue.config.errorHandler = (error, vm, info) => { + Raven.captureException(error) shrinkStacktrace(error) return true } + Vue.use(Electron) Vue.use(Resource) Vue.use(Router) diff --git a/app/src/renderer/node.js b/app/src/renderer/node.js index dc29d7d7a0..4699891385 100644 --- a/app/src/renderer/node.js +++ b/app/src/renderer/node.js @@ -1,6 +1,6 @@ 'use strict' const RpcClient = require('tendermint') -const RestClient = require('cosmos-sdk') +const RestClient = require('./lcdClient.js') module.exports = function (nodeIP, relayPort, lcdPort) { const RELAY_SERVER = 'http://localhost:' + relayPort diff --git a/app/src/renderer/vuex/modules/delegates.js b/app/src/renderer/vuex/modules/delegates.js index 5ec7ba6cd6..2a5698faef 100644 --- a/app/src/renderer/vuex/modules/delegates.js +++ b/app/src/renderer/vuex/modules/delegates.js @@ -1,4 +1,5 @@ import axios from 'axios' +import indicateValidators from 'scripts/indicateValidators' export default ({ dispatch, node }) => { const state = { @@ -24,14 +25,15 @@ export default ({ dispatch, node }) => { } const actions = { - async getDelegates ({ state, dispatch }) { + async getDelegates ({ state, dispatch, rootState }) { state.loading = true let delegatePubkeys = (await node.candidates()).data - let delegates = await Promise.all(delegatePubkeys.map(pubkey => { + await Promise.all(delegatePubkeys.map(pubkey => { return dispatch('getDelegate', pubkey) })) + state.delegates = indicateValidators(state.delegates, rootState.config.maxValidators) state.loading = false - return delegates + return state.delegates }, async getDelegate ({ commit }, pubkey) { let delegate = (await axios.get(`http://localhost:${node.relayPort}/query/stake/candidate/${pubkey.data}`)).data.data diff --git a/config.js b/config.js index 04855b61b0..eb2dffe301 100644 --- a/config.js +++ b/config.js @@ -30,7 +30,12 @@ let config = { overwrite: true, platform: process.env.PLATFORM_TARGET || 'darwin,linux,win32', packageManager: 'yarn' - } + }, + + default_network: 'gaia-2', + analytics_networks: ['gaia-2', 'gaia-3-dev', 'gaia-3'], + google_analytics: 'UA-51029217-3', + sentry_dsn: 'https://4dee9f70a7d94cc0959a265c45902d84:cbf160384aab4cdeafbe9a08dee3b961@sentry.io/288169' } config.building.name = config.name diff --git a/package.json b/package.json index e8a93a5372..cb5c234869 100644 --- a/package.json +++ b/package.json @@ -96,13 +96,11 @@ "@nylira/vue-button": "^4.3.2", "@nylira/vue-field": "^1.1.17", "@nylira/vue-form-msg": "^1.0.3", - "@nylira/vue-input": "^3.2.0", "@nylira/vue-notifications": "^1.4.4", "@vue/test-utils": "^1.0.0-beta.11", "axios": "^0.17.0", "casual": "^1.5.19", "chart.js": "^2.6.0", - "cosmos-sdk": "^1.5.1", "deterministic-tar": "^0.1.2", "deterministic-zip": "^1.0.5", "electron": "^1.7.5", @@ -121,6 +119,8 @@ "no-scroll": "^2.1.0", "numeral": "^2.0.6", "perfect-scrollbar": "^1.3.0", + "raven": "^2.4.1", + "raven-js": "^3.22.3", "semver": "^5.4.1", "shortid": "^2.2.8", "stacktrace-js": "^2.0.0", diff --git a/tasks/release.js b/tasks/release.js index ddd61fd143..bb63e65632 100644 --- a/tasks/release.js +++ b/tasks/release.js @@ -124,9 +124,18 @@ function zipFolder (inDir, outDir, version) { return reject(err) } files - .filter(file => !fs.lstatSync(file).isDirectory()) .forEach(file => { - zip.file(path.relative(inDir, file), fs.readFileSync(file), {date: new Date('1987-08-16')}) // make the zip deterministic by changing all file times + // make the zip deterministic by changing all file times + if (fs.lstatSync(file).isDirectory()) { + zip.file(path.relative(inDir, file), null, { + dir: true, + date: new Date('1993-06-16') + }) + } else { + zip.file(path.relative(inDir, file), fs.readFileSync(file), { + date: new Date('1987-08-16') + }) + } }) resolve() }) @@ -142,34 +151,44 @@ function zipFolder (inDir, outDir, version) { }) } -function tarFolder (inDir, outDir, version) { - return new Promise(async (resolve, reject) => { - let name = path.parse(inDir).name - let outFile = path.join(outDir, `${name}_${version}.tar.gz`) - var pack = tar.pack() +async function tarFolder (inDir, outDir, version) { + let name = path.parse(inDir).name + let outFile = path.join(outDir, `${name}_${version}.tar.gz`) + var pack = tar.pack() - await new Promise((resolve) => { - glob(inDir + '/**/*', (err, files) => { - if (err) { - return reject(err) - } - // add files to tar - files - .filter(file => !fs.lstatSync(file).isDirectory()) - .forEach(file => { - try { - pack.entry({ name: path.relative(inDir, file) }, fs.readFileSync(file)) - } catch (err) { - console.error(`Couldn't pack file`, file, err) - // skip this file - } - }) - pack.finalize() - resolve() + let files = glob(inDir + '/**', { sync: true }) + + // add files to tar + for (let file of files) { + try { + let stats = fs.lstatSync(file) + + let contents, linkname, type + if (stats.isDirectory()) { + continue + } else if (stats.isSymbolicLink()) { + linkname = fs.readlinkSync(file) + type = 'symlink' + } else { + contents = fs.readFileSync(file) + type = 'file' + } + await new Promise((resolve) => { + pack.entry(Object.assign({}, stats, { + name: path.relative(inDir, file), + type, + linkname + }), contents, resolve) }) - }) + } catch (err) { + console.error(`Couldn't pack file`, file, err) + // skip this file + } + } + pack.finalize() - // make tar deterministic + // make tar deterministic + await new Promise((resolve) => { pack .pipe(deterministicTar()) // save tar to disc @@ -193,17 +212,17 @@ function deterministicTar () { var extract = tar.extract() .on('entry', function (header, stream, cb) { - if (header.type !== 'file') return cb() - header.mtime = header.atime = header.ctime = UNIXZERO header.uid = header.gid = 0 delete header.uname delete header.gname - header.mode = 0o777 - - stream.pipe(pack.entry(header, cb)) + if (header.type === 'file') { + stream.pipe(pack.entry(header, cb)) + } else { + pack.entry(header, cb) + } }) .on('finish', function () { pack.finalize() diff --git a/test/unit/specs/App.spec.js b/test/unit/specs/App.spec.js index 3f96ae0d62..838257c607 100644 --- a/test/unit/specs/App.spec.js +++ b/test/unit/specs/App.spec.js @@ -1,4 +1,17 @@ jest.mock('renderer/node.js', () => () => require('../helpers/node_mock')) +jest.mock('electron', () => ({ + require: jest.genMockFunction(), + match: jest.genMockFunction(), + app: jest.genMockFunction(), + remote: { + getGlobal: () => ({ + env: { + NODE_ENV: 'test' + } + }) + }, + dialog: jest.genMockFunction() +})) describe('App', () => { it('has all dependencies', async done => { diff --git a/test/unit/specs/components/staking/PageBond.spec.js b/test/unit/specs/components/staking/PageBond.spec.js index 0df32b42d4..ebd2f03b6c 100644 --- a/test/unit/specs/components/staking/PageBond.spec.js +++ b/test/unit/specs/components/staking/PageBond.spec.js @@ -94,6 +94,11 @@ describe('PageBond', () => { expect(delegate.atoms).toBe(88) }) + it('only shows percent based on showing atoms', () => { + let updatedDelegate = wrapper.vm.handleResize(wrapper.vm.$el.querySelector('#delegate-pubkeyX'), 0.12) + expect(updatedDelegate.deltaAtomsPercent).toBe('100%') + }) + it('calculates delta', () => { expect(wrapper.vm.delta(100.23293423, 90.5304934)).toBe(9.70244083) expect(wrapper.vm.delta(100, 90, 'int')).toBe(10) diff --git a/test/unit/specs/components/staking/__snapshots__/PageBond.spec.js.snap b/test/unit/specs/components/staking/__snapshots__/PageBond.spec.js.snap index e29f470808..eba8a4932d 100644 --- a/test/unit/specs/components/staking/__snapshots__/PageBond.spec.js.snap +++ b/test/unit/specs/components/staking/__snapshots__/PageBond.spec.js.snap @@ -242,6 +242,8 @@ exports[`PageBond has the expected html structure 1`] = ` -