diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 000000000..381e034e0 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,42 @@ +# See http://help.github.com/ignore-files/ for more about ignoring files. + +# compiled output +/dist +/tmp + +# dependencies +/node_modules + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# misc +/.sass-cache +/connect.lock +/coverage/* +/libpeerconnection.log +npm-debug.log +testem.log +/typings + +# e2e +/e2e/*.js +/e2e/*.map + +#System Files +.DS_Store +Thumbs.db + +cache.json diff --git a/backend/mempool-config.json b/backend/mempool-config.json new file mode 100644 index 000000000..44f926bdf --- /dev/null +++ b/backend/mempool-config.json @@ -0,0 +1,22 @@ +{ + "ENV": "dev", + "DB_HOST": "localhost", + "DB_PORT": 8889, + "DB_USER": "", + "DB_PASSWORD": "", + "DB_DATABASE": "", + "HTTP_PORT": 3000, + "API_ENDPOINT": "/api/v1/", + "CHAT_SSL_ENABLED": false, + "CHAT_SSL_PRIVKEY": "", + "CHAT_SSL_CERT": "", + "CHAT_SSL_CHAIN": "", + "MEMPOOL_REFRESH_RATE_MS": 500, + "INITIAL_BLOCK_AMOUNT": 8, + "KEEP_BLOCK_AMOUNT": 24, + "BITCOIN_NODE_HOST": "localhost", + "BITCOIN_NODE_PORT": 18332, + "BITCOIN_NODE_USER": "", + "BITCOIN_NODE_PASS": "", + "TX_PER_SECOND_SPAN_SECONDS": 150 +} \ No newline at end of file diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 000000000..2a3e39e6a --- /dev/null +++ b/backend/package.json @@ -0,0 +1,27 @@ +{ + "name": "mempool-backend", + "version": "1.0.0", + "description": "Bitcoin mempool visualizer", + "main": "index.ts", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "dependencies": { + "bitcoin": "^3.0.1", + "compression": "^1.7.3", + "express": "^4.16.3", + "mysql2": "^1.6.1", + "request": "^2.88.0", + "ws": "^6.0.0" + }, + "devDependencies": { + "@types/express": "^4.16.0", + "@types/mysql2": "github:types/mysql2", + "@types/request": "^2.48.2", + "@types/ws": "^6.0.1", + "tslint": "^5.11.0", + "typescript": "^3.1.1" + } +} diff --git a/backend/src/api/bitcoin-api-wrapper.ts b/backend/src/api/bitcoin-api-wrapper.ts new file mode 100644 index 000000000..970dd20c9 --- /dev/null +++ b/backend/src/api/bitcoin-api-wrapper.ts @@ -0,0 +1,84 @@ +const config = require('../../mempool-config.json'); +import * as bitcoin from 'bitcoin'; +import { ITransaction, IMempoolInfo, IBlock } from '../interfaces'; + +class BitcoinApi { + client: any; + + constructor() { + this.client = new bitcoin.Client({ + host: config.BITCOIN_NODE_HOST, + port: config.BITCOIN_NODE_PORT, + user: config.BITCOIN_NODE_USER, + pass: config.BITCOIN_NODE_PASS, + }); + } + + getMempoolInfo(): Promise { + return new Promise((resolve, reject) => { + this.client.getMempoolInfo((err: Error, mempoolInfo: any) => { + if (err) { + return reject(err); + } + resolve(mempoolInfo); + }); + }); + } + + getRawMempool(): Promise { + return new Promise((resolve, reject) => { + this.client.getRawMemPool((err: Error, transactions: ITransaction['txid'][]) => { + if (err) { + return reject(err); + } + resolve(transactions); + }); + }); + } + + getRawTransaction(txId: string): Promise { + return new Promise((resolve, reject) => { + this.client.getRawTransaction(txId, true, (err: Error, txData: ITransaction) => { + if (err) { + return reject(err); + } + resolve(txData); + }); + }); + } + + getBlockCount(): Promise { + return new Promise((resolve, reject) => { + this.client.getBlockCount((err: Error, response: number) => { + if (err) { + return reject(err); + } + resolve(response); + }); + }); + } + + getBlock(hash: string, verbosity: 1 | 2 = 1): Promise { + return new Promise((resolve, reject) => { + this.client.getBlock(hash, verbosity, (err: Error, block: IBlock) => { + if (err) { + return reject(err); + } + resolve(block); + }); + }); + } + + getBlockHash(height: number): Promise { + return new Promise((resolve, reject) => { + this.client.getBlockHash(height, (err: Error, response: string) => { + if (err) { + return reject(err); + } + resolve(response); + }); + }); + } +} + +export default new BitcoinApi(); diff --git a/backend/src/api/blocks.ts b/backend/src/api/blocks.ts new file mode 100644 index 000000000..633e37b95 --- /dev/null +++ b/backend/src/api/blocks.ts @@ -0,0 +1,197 @@ +const config = require('../../mempool-config.json'); +import bitcoinApi from './bitcoin-api-wrapper'; +import { DB } from '../database'; +import { IBlock, ITransaction } from '../interfaces'; +import memPool from './mempool'; + +class Blocks { + private blocks: IBlock[] = []; + private newBlockCallback: Function | undefined; + + public setNewBlockCallback(fn: Function) { + this.newBlockCallback = fn; + } + + public getBlocks(): IBlock[] { + return this.blocks; + } + + public formatBlock(block: IBlock) { + return { + hash: block.hash, + height: block.height, + nTx: block.nTx - 1, + size: block.size, + time: block.time, + weight: block.weight, + fees: block.fees, + minFee: block.minFee, + maxFee: block.maxFee, + medianFee: block.medianFee, + }; + } + + public async updateBlocks() { + try { + const blockCount = await bitcoinApi.getBlockCount(); + + let currentBlockHeight = 0; + if (this.blocks.length === 0) { + currentBlockHeight = blockCount - config.INITIAL_BLOCK_AMOUNT; + } else { + currentBlockHeight = this.blocks[this.blocks.length - 1].height; + } + + while (currentBlockHeight < blockCount) { + currentBlockHeight++; + + let block: IBlock | undefined; + + const storedBlock = await this.$getBlockFromDatabase(currentBlockHeight); + if (storedBlock) { + block = storedBlock; + } else { + const blockHash = await bitcoinApi.getBlockHash(currentBlockHeight); + block = await bitcoinApi.getBlock(blockHash, 1); + + const coinbase = await memPool.getRawTransaction(block.tx[0], true); + if (coinbase && coinbase.totalOut) { + block.fees = coinbase.totalOut; + } + + const mempool = memPool.getMempool(); + let found = 0; + let notFound = 0; + + let transactions: ITransaction[] = []; + + for (let i = 1; i < block.tx.length; i++) { + if (mempool[block.tx[i]]) { + transactions.push(mempool[block.tx[i]]); + found++; + } else { + const tx = await memPool.getRawTransaction(block.tx[i]); + if (tx) { + transactions.push(tx); + } + notFound++; + } + } + + transactions.sort((a, b) => b.feePerVsize - a.feePerVsize); + transactions = transactions.filter((tx: ITransaction) => tx.feePerVsize); + + block.minFee = transactions[transactions.length - 1] ? transactions[transactions.length - 1].feePerVsize : 0; + block.maxFee = transactions[0] ? transactions[0].feePerVsize : 0; + block.medianFee = this.median(transactions.map((tx) => tx.feePerVsize)); + + if (this.newBlockCallback) { + this.newBlockCallback(block); + } + + await this.$saveBlockToDatabase(block); + await this.$saveTransactionsToDatabase(block.height, transactions); + console.log(`New block found (#${currentBlockHeight})! ${found} of ${block.tx.length} found in mempool. ${notFound} not found.`); + } + + this.blocks.push(block); + if (this.blocks.length > config.KEEP_BLOCK_AMOUNT) { + this.blocks.shift(); + } + + } + } catch (err) { + console.log('Error getBlockCount', err); + } + } + + private async $getBlockFromDatabase(height: number): Promise { + try { + const connection = await DB.pool.getConnection(); + const query = ` + SELECT * FROM blocks WHERE height = ? + `; + + const [rows] = await connection.query(query, [height]); + connection.release(); + + if (rows[0]) { + return rows[0]; + } + } catch (e) { + console.log('$get() block error', e); + } + } + + private async $saveBlockToDatabase(block: IBlock) { + try { + const connection = await DB.pool.getConnection(); + const query = ` + INSERT IGNORE INTO blocks + (height, hash, size, weight, minFee, maxFee, time, fees, nTx, medianFee) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `; + + const params: (any)[] = [ + block.height, + block.hash, + block.size, + block.weight, + block.minFee, + block.maxFee, + block.time, + block.fees, + block.nTx - 1, + block.medianFee, + ]; + + await connection.query(query, params); + connection.release(); + } catch (e) { + console.log('$create() block error', e); + } + } + + private async $saveTransactionsToDatabase(blockheight: number, transactions: ITransaction[]) { + try { + const connection = await DB.pool.getConnection(); + + for (let i = 0; i < transactions.length; i++) { + const query = ` + INSERT IGNORE INTO transactions + (blockheight, txid, fee, feePerVsize) + VALUES(?, ?, ?, ?) + `; + + const params: (any)[] = [ + blockheight, + transactions[i].txid, + transactions[i].fee, + transactions[i].feePerVsize, + ]; + + await connection.query(query, params); + } + + + connection.release(); + } catch (e) { + console.log('$create() transaction error', e); + } + } + + private median(numbers: number[]) { + if (!numbers.length) { return 0; } + let medianNr = 0; + const numsLen = numbers.length; + numbers.sort(); + if (numsLen % 2 === 0) { + medianNr = (numbers[numsLen / 2 - 1] + numbers[numsLen / 2]) / 2; + } else { + medianNr = numbers[(numsLen - 1) / 2]; + } + return medianNr; + } +} + +export default new Blocks(); diff --git a/backend/src/api/disk-cache.ts b/backend/src/api/disk-cache.ts new file mode 100644 index 000000000..f35b9dd6d --- /dev/null +++ b/backend/src/api/disk-cache.ts @@ -0,0 +1,16 @@ +import * as fs from 'fs'; + +class DiskCache { + static FILE_NAME = './cache.json'; + constructor() { } + + saveData(dataBlob: string) { + fs.writeFileSync(DiskCache.FILE_NAME, dataBlob, 'utf8'); + } + + loadData(): string { + return fs.readFileSync(DiskCache.FILE_NAME, 'utf8'); + } +} + +export default new DiskCache(); diff --git a/backend/src/api/fee-api.ts b/backend/src/api/fee-api.ts new file mode 100644 index 000000000..4226b9089 --- /dev/null +++ b/backend/src/api/fee-api.ts @@ -0,0 +1,47 @@ +import projectedBlocks from './projected-blocks'; +import { DB } from '../database'; + +class FeeApi { + constructor() { } + + public getRecommendedFee() { + const pBlocks = projectedBlocks.getProjectedBlocks(); + if (!pBlocks.length) { + return { + 'fastestFee': 0, + 'halfHourFee': 0, + 'hourFee': 0, + }; + } + let firstMedianFee = Math.ceil(pBlocks[0].medianFee); + + if (pBlocks.length === 1 && pBlocks[0].blockWeight <= 2000000) { + firstMedianFee = 1; + } + + const secondMedianFee = pBlocks[1] ? Math.ceil(pBlocks[1].medianFee) : firstMedianFee; + const thirdMedianFee = pBlocks[2] ? Math.ceil(pBlocks[2].medianFee) : secondMedianFee; + + return { + 'fastestFee': firstMedianFee, + 'halfHourFee': secondMedianFee, + 'hourFee': thirdMedianFee, + }; + } + + public async $getTransactionsForBlock(blockHeight: number): Promise { + try { + const connection = await DB.pool.getConnection(); + const query = `SELECT feePerVsize AS fpv FROM transactions WHERE blockheight = ? ORDER BY feePerVsize ASC`; + const [rows] = await connection.query(query, [blockHeight]); + connection.release(); + return rows; + } catch (e) { + console.log('$getTransactionsForBlock() error', e); + return []; + } + } + +} + +export default new FeeApi(); diff --git a/backend/src/api/fiat-conversion.ts b/backend/src/api/fiat-conversion.ts new file mode 100644 index 000000000..279246a9a --- /dev/null +++ b/backend/src/api/fiat-conversion.ts @@ -0,0 +1,31 @@ +import * as request from 'request'; + +class FiatConversion { + private tickers = { + 'BTCUSD': { + 'USD': 4110.78 + }, + }; + + constructor() { } + + public startService() { + setInterval(this.updateCurrency.bind(this), 1000 * 60 * 60); + this.updateCurrency(); + } + + public getTickers() { + return this.tickers; + } + + private updateCurrency() { + request('https://api.opennode.co/v1/rates', { json: true }, (err, res, body) => { + if (err) { return console.log(err); } + if (body && body.data) { + this.tickers = body.data; + } + }); + } +} + +export default new FiatConversion(); diff --git a/backend/src/api/mempool.ts b/backend/src/api/mempool.ts new file mode 100644 index 000000000..7c8dd0d9f --- /dev/null +++ b/backend/src/api/mempool.ts @@ -0,0 +1,156 @@ +const config = require('../../mempool-config.json'); +import bitcoinApi from './bitcoin-api-wrapper'; +import { ITransaction, IMempoolInfo, IMempool } from '../interfaces'; + +class Mempool { + private mempool: IMempool = {}; + private mempoolInfo: IMempoolInfo | undefined; + private mempoolChangedCallback: Function | undefined; + + private txPerSecondArray: number[] = []; + private txPerSecond: number = 0; + + private vBytesPerSecondArray: any[] = []; + private vBytesPerSecond: number = 0; + + constructor() { + setInterval(this.updateTxPerSecond.bind(this), 1000); + } + + public setMempoolChangedCallback(fn: Function) { + this.mempoolChangedCallback = fn; + } + + public getMempool(): { [txid: string]: ITransaction } { + return this.mempool; + } + + public setMempool(mempoolData: any) { + this.mempool = mempoolData; + } + + public getMempoolInfo(): IMempoolInfo | undefined { + return this.mempoolInfo; + } + + public getTxPerSecond(): number { + return this.txPerSecond; + } + + public getVBytesPerSecond(): number { + return this.vBytesPerSecond; + } + + public async getMemPoolInfo() { + try { + this.mempoolInfo = await bitcoinApi.getMempoolInfo(); + } catch (err) { + console.log('Error getMempoolInfo', err); + } + } + + public async getRawTransaction(txId: string, isCoinbase = false): Promise { + try { + const transaction = await bitcoinApi.getRawTransaction(txId); + let totalIn = 0; + if (!isCoinbase) { + for (let i = 0; i < transaction.vin.length; i++) { + try { + const result = await bitcoinApi.getRawTransaction(transaction.vin[i].txid); + transaction.vin[i]['value'] = result.vout[transaction.vin[i].vout].value; + totalIn += result.vout[transaction.vin[i].vout].value; + } catch (err) { + console.log('Locating historical tx error'); + } + } + } + let totalOut = 0; + transaction.vout.forEach((output) => totalOut += output.value); + + if (totalIn > totalOut) { + transaction.fee = parseFloat((totalIn - totalOut).toFixed(8)); + transaction.feePerWeightUnit = (transaction.fee * 100000000) / (transaction.vsize * 4) || 0; + transaction.feePerVsize = (transaction.fee * 100000000) / (transaction.vsize) || 0; + } else if (!isCoinbase) { + transaction.fee = 0; + transaction.feePerVsize = 0; + transaction.feePerWeightUnit = 0; + console.log('Minus fee error!'); + } + transaction.totalOut = totalOut; + return transaction; + } catch (e) { + console.log(txId + ' not found'); + return false; + } + } + + public async updateMempool() { + console.log('Updating mempool'); + const start = new Date().getTime(); + let hasChange: boolean = false; + let txCount = 0; + try { + const transactions = await bitcoinApi.getRawMempool(); + const diff = transactions.length - Object.keys(this.mempool).length; + for (const tx of transactions) { + if (!this.mempool[tx]) { + const transaction = await this.getRawTransaction(tx); + if (transaction) { + this.mempool[tx] = transaction; + txCount++; + this.txPerSecondArray.push(new Date().getTime()); + this.vBytesPerSecondArray.push({ + unixTime: new Date().getTime(), + vSize: transaction.vsize, + }); + hasChange = true; + if (diff > 0) { + console.log('Calculated fee for transaction ' + txCount + ' / ' + diff); + } else { + console.log('Calculated fee for transaction ' + txCount); + } + } else { + console.log('Error finding transaction in mempool.'); + } + } + } + + const newMempool: IMempool = {}; + transactions.forEach((tx) => { + if (this.mempool[tx]) { + newMempool[tx] = this.mempool[tx]; + } else { + hasChange = true; + } + }); + + this.mempool = newMempool; + + if (hasChange && this.mempoolChangedCallback) { + this.mempoolChangedCallback(this.mempool); + } + + const end = new Date().getTime(); + const time = end - start; + console.log('Mempool updated in ' + time / 1000 + ' seconds'); + } catch (err) { + console.log('getRawMempool error.', err); + } + } + + private updateTxPerSecond() { + const nowMinusTimeSpan = new Date().getTime() - (1000 * config.TX_PER_SECOND_SPAN_SECONDS); + this.txPerSecondArray = this.txPerSecondArray.filter((unixTime) => unixTime > nowMinusTimeSpan); + this.txPerSecond = this.txPerSecondArray.length / config.TX_PER_SECOND_SPAN_SECONDS || 0; + + this.vBytesPerSecondArray = this.vBytesPerSecondArray.filter((data) => data.unixTime > nowMinusTimeSpan); + if (this.vBytesPerSecondArray.length) { + this.vBytesPerSecond = Math.round( + this.vBytesPerSecondArray.map((data) => data.vSize).reduce((a, b) => a + b) / config.TX_PER_SECOND_SPAN_SECONDS + ); + } + } +} + +export default new Mempool(); diff --git a/backend/src/api/projected-blocks.ts b/backend/src/api/projected-blocks.ts new file mode 100644 index 000000000..54b2d894b --- /dev/null +++ b/backend/src/api/projected-blocks.ts @@ -0,0 +1,104 @@ +import { ITransaction, IProjectedBlock, IMempool, IProjectedBlockInternal } from '../interfaces'; + +class ProjectedBlocks { + private projectedBlocks: IProjectedBlockInternal[] = []; + + constructor() {} + + public getProjectedBlocks(txId?: string): IProjectedBlock[] { + return this.projectedBlocks.map((projectedBlock) => { + return { + blockSize: projectedBlock.blockSize, + blockWeight: projectedBlock.blockWeight, + nTx: projectedBlock.nTx, + minFee: projectedBlock.minFee, + maxFee: projectedBlock.maxFee, + minWeightFee: projectedBlock.minWeightFee, + maxWeightFee: projectedBlock.maxWeightFee, + medianFee: projectedBlock.medianFee, + fees: projectedBlock.fees, + hasMytx: txId ? projectedBlock.txIds.some((tx) => tx === txId) : false + }; + }); + } + + public getProjectedBlockFeesForBlock(index: number) { + const projectedBlock = this.projectedBlocks[index]; + + if (!projectedBlock) { + throw new Error('No projected block for that index'); + } + + return projectedBlock.txFeePerVsizes.map((fpv) => { + return {'fpv': fpv}; + }); + } + + public updateProjectedBlocks(memPool: IMempool): void { + const latestMempool = memPool; + const memPoolArray: ITransaction[] = []; + for (const i in latestMempool) { + if (latestMempool.hasOwnProperty(i)) { + memPoolArray.push(latestMempool[i]); + } + } + + if (!memPoolArray.length) { + this.projectedBlocks = []; + } + + memPoolArray.sort((a, b) => b.feePerWeightUnit - a.feePerWeightUnit); + const memPoolArrayFiltered = memPoolArray.filter((tx) => tx.feePerWeightUnit); + const projectedBlocks: any = []; + + let blockWeight = 0; + let blockSize = 0; + let transactions: ITransaction[] = []; + memPoolArrayFiltered.forEach((tx) => { + if (blockWeight + tx.vsize * 4 < 4000000 || projectedBlocks.length === 3) { + blockWeight += tx.vsize * 4; + blockSize += tx.size; + transactions.push(tx); + } else { + projectedBlocks.push(this.dataToProjectedBlock(transactions, blockSize, blockWeight)); + blockWeight = 0; + blockSize = 0; + transactions = []; + } + }); + if (transactions.length) { + projectedBlocks.push(this.dataToProjectedBlock(transactions, blockSize, blockWeight)); + } + this.projectedBlocks = projectedBlocks; + } + + private dataToProjectedBlock(transactions: ITransaction[], blockSize: number, blockWeight: number): IProjectedBlockInternal { + return { + blockSize: blockSize, + blockWeight: blockWeight, + nTx: transactions.length - 1, + minFee: transactions[transactions.length - 1].feePerVsize, + maxFee: transactions[0].feePerVsize, + minWeightFee: transactions[transactions.length - 1].feePerWeightUnit, + maxWeightFee: transactions[0].feePerWeightUnit, + medianFee: this.median(transactions.map((tx) => tx.feePerVsize)), + txIds: transactions.map((tx) => tx.txid), + txFeePerVsizes: transactions.map((tx) => tx.feePerVsize).reverse(), + fees: transactions.map((tx) => tx.fee).reduce((acc, currValue) => acc + currValue), + }; + } + + private median(numbers: number[]) { + let medianNr = 0; + const numsLen = numbers.length; + numbers.sort(); + if (numsLen % 2 === 0) { + medianNr = (numbers[numsLen / 2 - 1] + numbers[numsLen / 2]) / 2; + } else { + medianNr = numbers[(numsLen - 1) / 2]; + } + return medianNr; + } +} + +export default new ProjectedBlocks(); diff --git a/backend/src/api/statistics.ts b/backend/src/api/statistics.ts new file mode 100644 index 000000000..19ab2ac19 --- /dev/null +++ b/backend/src/api/statistics.ts @@ -0,0 +1,379 @@ +import memPool from './mempool'; +import { DB } from '../database'; + +import { ITransaction, IMempoolStats } from '../interfaces'; + +class Statistics { + protected intervalTimer: NodeJS.Timer | undefined; + + constructor() { + } + + public startStatistics(): void { + const now = new Date(); + const nextInterval = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), + Math.floor(now.getMinutes() / 1) * 1 + 1, 0, 0); + const difference = nextInterval.getTime() - now.getTime(); + + setTimeout(() => { + this.runStatistics(); + this.intervalTimer = setInterval(() => { this.runStatistics(); }, 1 * 60 * 1000); + }, difference); + } + + private runStatistics(): void { + const currentMempool = memPool.getMempool(); + const txPerSecond = memPool.getTxPerSecond(); + const vBytesPerSecond = memPool.getVBytesPerSecond(); + + if (txPerSecond === 0) { + return; + } + + console.log('Running statistics'); + + let memPoolArray: ITransaction[] = []; + for (const i in currentMempool) { + if (currentMempool.hasOwnProperty(i)) { + memPoolArray.push(currentMempool[i]); + } + } + // Remove 0 and undefined + memPoolArray = memPoolArray.filter((tx) => tx.feePerWeightUnit); + + if (!memPoolArray.length) { + return; + } + + memPoolArray.sort((a, b) => a.feePerWeightUnit - b.feePerWeightUnit); + const totalWeight = memPoolArray.map((tx) => tx.vsize).reduce((acc, curr) => acc + curr) * 4; + const totalFee = memPoolArray.map((tx) => tx.fee).reduce((acc, curr) => acc + curr); + + const logFees = [1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 30, 40, 50, 60, 70, 80, 90, 100, 125, 150, 175, 200, + 250, 300, 350, 400, 500, 600, 700, 800, 900, 1000, 1200, 1400, 1600, 1800, 2000]; + + const weightUnitFees: { [feePerWU: number]: number } = {}; + const weightVsizeFees: { [feePerWU: number]: number } = {}; + + memPoolArray.forEach((transaction) => { + for (let i = 0; i < logFees.length; i++) { + if ((logFees[i] === 2000 && transaction.feePerWeightUnit >= 2000) || transaction.feePerWeightUnit <= logFees[i]) { + if (weightUnitFees[logFees[i]]) { + weightUnitFees[logFees[i]] += transaction.vsize * 4; + } else { + weightUnitFees[logFees[i]] = transaction.vsize * 4; + } + break; + } + } + }); + + memPoolArray.forEach((transaction) => { + for (let i = 0; i < logFees.length; i++) { + if ((logFees[i] === 2000 && transaction.feePerVsize >= 2000) || transaction.feePerVsize <= logFees[i]) { + if (weightVsizeFees[logFees[i]]) { + weightVsizeFees[logFees[i]] += transaction.vsize; + } else { + weightVsizeFees[logFees[i]] = transaction.vsize; + } + break; + } + } + }); + + this.$create({ + added: 'NOW()', + unconfirmed_transactions: memPoolArray.length, + tx_per_second: txPerSecond, + vbytes_per_second: Math.round(vBytesPerSecond), + mempool_byte_weight: totalWeight, + total_fee: totalFee, + fee_data: JSON.stringify({ + 'wu': weightUnitFees, + 'vsize': weightVsizeFees + }), + vsize_1: weightVsizeFees['1'] || 0, + vsize_2: weightVsizeFees['2'] || 0, + vsize_3: weightVsizeFees['3'] || 0, + vsize_4: weightVsizeFees['4'] || 0, + vsize_5: weightVsizeFees['5'] || 0, + vsize_6: weightVsizeFees['6'] || 0, + vsize_8: weightVsizeFees['8'] || 0, + vsize_10: weightVsizeFees['10'] || 0, + vsize_12: weightVsizeFees['12'] || 0, + vsize_15: weightVsizeFees['15'] || 0, + vsize_20: weightVsizeFees['20'] || 0, + vsize_30: weightVsizeFees['30'] || 0, + vsize_40: weightVsizeFees['40'] || 0, + vsize_50: weightVsizeFees['50'] || 0, + vsize_60: weightVsizeFees['60'] || 0, + vsize_70: weightVsizeFees['70'] || 0, + vsize_80: weightVsizeFees['80'] || 0, + vsize_90: weightVsizeFees['90'] || 0, + vsize_100: weightVsizeFees['100'] || 0, + vsize_125: weightVsizeFees['125'] || 0, + vsize_150: weightVsizeFees['150'] || 0, + vsize_175: weightVsizeFees['175'] || 0, + vsize_200: weightVsizeFees['200'] || 0, + vsize_250: weightVsizeFees['250'] || 0, + vsize_300: weightVsizeFees['300'] || 0, + vsize_350: weightVsizeFees['350'] || 0, + vsize_400: weightVsizeFees['400'] || 0, + vsize_500: weightVsizeFees['500'] || 0, + vsize_600: weightVsizeFees['600'] || 0, + vsize_700: weightVsizeFees['700'] || 0, + vsize_800: weightVsizeFees['800'] || 0, + vsize_900: weightVsizeFees['900'] || 0, + vsize_1000: weightVsizeFees['1000'] || 0, + vsize_1200: weightVsizeFees['1200'] || 0, + vsize_1400: weightVsizeFees['1400'] || 0, + vsize_1600: weightVsizeFees['1600'] || 0, + vsize_1800: weightVsizeFees['1800'] || 0, + vsize_2000: weightVsizeFees['2000'] || 0, + }); + } + + private async $create(statistics: IMempoolStats): Promise { + try { + const connection = await DB.pool.getConnection(); + const query = `INSERT INTO statistics( + added, + unconfirmed_transactions, + tx_per_second, + vbytes_per_second, + mempool_byte_weight, + fee_data, + total_fee, + vsize_1, + vsize_2, + vsize_3, + vsize_4, + vsize_5, + vsize_6, + vsize_8, + vsize_10, + vsize_12, + vsize_15, + vsize_20, + vsize_30, + vsize_40, + vsize_50, + vsize_60, + vsize_70, + vsize_80, + vsize_90, + vsize_100, + vsize_125, + vsize_150, + vsize_175, + vsize_200, + vsize_250, + vsize_300, + vsize_350, + vsize_400, + vsize_500, + vsize_600, + vsize_700, + vsize_800, + vsize_900, + vsize_1000, + vsize_1200, + vsize_1400, + vsize_1600, + vsize_1800, + vsize_2000 + ) + VALUES (${statistics.added}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`; + + const params: (string | number)[] = [ + statistics.unconfirmed_transactions, + statistics.tx_per_second, + statistics.vbytes_per_second, + statistics.mempool_byte_weight, + statistics.fee_data, + statistics.total_fee, + statistics.vsize_1, + statistics.vsize_2, + statistics.vsize_3, + statistics.vsize_4, + statistics.vsize_5, + statistics.vsize_6, + statistics.vsize_8, + statistics.vsize_10, + statistics.vsize_12, + statistics.vsize_15, + statistics.vsize_20, + statistics.vsize_30, + statistics.vsize_40, + statistics.vsize_50, + statistics.vsize_60, + statistics.vsize_70, + statistics.vsize_80, + statistics.vsize_90, + statistics.vsize_100, + statistics.vsize_125, + statistics.vsize_150, + statistics.vsize_175, + statistics.vsize_200, + statistics.vsize_250, + statistics.vsize_300, + statistics.vsize_350, + statistics.vsize_400, + statistics.vsize_500, + statistics.vsize_600, + statistics.vsize_700, + statistics.vsize_800, + statistics.vsize_900, + statistics.vsize_1000, + statistics.vsize_1200, + statistics.vsize_1400, + statistics.vsize_1600, + statistics.vsize_1800, + statistics.vsize_2000, + ]; + await connection.query(query, params); + connection.release(); + } catch (e) { + console.log('$create() error', e); + } + } + + public async $listLatestFromId(fromId: number): Promise { + try { + const connection = await DB.pool.getConnection(); + const query = `SELECT * FROM statistics WHERE id > ? ORDER BY id DESC`; + const [rows] = await connection.query(query, [fromId]); + connection.release(); + return rows; + } catch (e) { + console.log('$listLatestFromId() error', e); + return []; + } + } + + private getQueryForDays(days: number, groupBy: number) { + + return `SELECT id, added, unconfirmed_transactions, + AVG(tx_per_second) AS tx_per_second, + AVG(vbytes_per_second) AS vbytes_per_second, + AVG(vsize_1) AS vsize_1, + AVG(vsize_2) AS vsize_2, + AVG(vsize_3) AS vsize_3, + AVG(vsize_4) AS vsize_4, + AVG(vsize_5) AS vsize_5, + AVG(vsize_6) AS vsize_6, + AVG(vsize_8) AS vsize_8, + AVG(vsize_10) AS vsize_10, + AVG(vsize_12) AS vsize_12, + AVG(vsize_15) AS vsize_15, + AVG(vsize_20) AS vsize_20, + AVG(vsize_30) AS vsize_30, + AVG(vsize_40) AS vsize_40, + AVG(vsize_50) AS vsize_50, + AVG(vsize_60) AS vsize_60, + AVG(vsize_70) AS vsize_70, + AVG(vsize_80) AS vsize_80, + AVG(vsize_90) AS vsize_90, + AVG(vsize_100) AS vsize_100, + AVG(vsize_125) AS vsize_125, + AVG(vsize_150) AS vsize_150, + AVG(vsize_175) AS vsize_175, + AVG(vsize_200) AS vsize_200, + AVG(vsize_250) AS vsize_250, + AVG(vsize_300) AS vsize_300, + AVG(vsize_350) AS vsize_350, + AVG(vsize_400) AS vsize_400, + AVG(vsize_500) AS vsize_500, + AVG(vsize_600) AS vsize_600, + AVG(vsize_700) AS vsize_700, + AVG(vsize_800) AS vsize_800, + AVG(vsize_900) AS vsize_900, + AVG(vsize_1000) AS vsize_1000, + AVG(vsize_1200) AS vsize_1200, + AVG(vsize_1400) AS vsize_1400, + AVG(vsize_1600) AS vsize_1600, + AVG(vsize_1800) AS vsize_1800, + AVG(vsize_2000) AS vsize_2000 FROM statistics GROUP BY UNIX_TIMESTAMP(added) DIV ${groupBy} ORDER BY id DESC LIMIT ${days}`; + } + + public async $list2H(): Promise { + try { + const connection = await DB.pool.getConnection(); + const query = `SELECT * FROM statistics ORDER BY id DESC LIMIT 120`; + const [rows] = await connection.query(query); + connection.release(); + return rows; + } catch (e) { + console.log('$list2H() error', e); + return []; + } + } + + public async $list24H(): Promise { + try { + const connection = await DB.pool.getConnection(); + const query = this.getQueryForDays(120, 720); + const [rows] = await connection.query(query); + connection.release(); + return rows; + } catch (e) { + return []; + } + } + + public async $list1W(): Promise { + try { + const connection = await DB.pool.getConnection(); + const query = this.getQueryForDays(120, 5040); + const [rows] = await connection.query(query); + connection.release(); + return rows; + } catch (e) { + console.log('$list1W() error', e); + return []; + } + } + + public async $list1M(): Promise { + try { + const connection = await DB.pool.getConnection(); + const query = this.getQueryForDays(120, 20160); + const [rows] = await connection.query(query); + connection.release(); + return rows; + } catch (e) { + console.log('$list1M() error', e); + return []; + } + } + + public async $list3M(): Promise { + try { + const connection = await DB.pool.getConnection(); + const query = this.getQueryForDays(120, 60480); + const [rows] = await connection.query(query); + connection.release(); + return rows; + } catch (e) { + console.log('$list3M() error', e); + return []; + } + } + + public async $list6M(): Promise { + try { + const connection = await DB.pool.getConnection(); + const query = this.getQueryForDays(120, 120960); + const [rows] = await connection.query(query); + connection.release(); + return rows; + } catch (e) { + console.log('$list6M() error', e); + return []; + } + } + +} + +export default new Statistics(); diff --git a/backend/src/database.ts b/backend/src/database.ts new file mode 100644 index 000000000..a151ced49 --- /dev/null +++ b/backend/src/database.ts @@ -0,0 +1,26 @@ +const config = require('../mempool-config.json'); +import { createPool } from 'mysql2/promise'; + +export class DB { + static pool = createPool({ + host: config.DB_HOST, + port: config.DB_PORT, + database: config.DB_DATABASE, + user: config.DB_USER, + password: config.DB_PASSWORD, + connectionLimit: 10, + supportBigNumbers: true, + }); +} + +export async function checkDbConnection() { + try { + const connection = await DB.pool.getConnection(); + console.log('MySQL connection established.'); + connection.release(); + } catch (e) { + console.log('Could not connect to MySQL.'); + console.log(e); + process.exit(1); + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 000000000..d92559762 --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,231 @@ +const config = require('../mempool-config.json'); +import * as fs from 'fs'; +import * as express from 'express'; +import * as compression from 'compression'; +import * as http from 'http'; +import * as https from 'https'; +import * as WebSocket from 'ws'; + +import bitcoinApi from './api/bitcoin-api-wrapper'; +import diskCache from './api/disk-cache'; +import memPool from './api/mempool'; +import blocks from './api/blocks'; +import projectedBlocks from './api/projected-blocks'; +import statistics from './api/statistics'; +import { IBlock, IMempool } from './interfaces'; + +import routes from './routes'; +import fiatConversion from './api/fiat-conversion'; + +class MempoolSpace { + private wss: WebSocket.Server; + private server: https.Server | http.Server; + private app: any; + + constructor() { + this.app = express(); + this.app + .use((req, res, next) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + next(); + }) + .use(compression()); + if (config.ENV === 'dev') { + this.server = http.createServer(this.app); + this.wss = new WebSocket.Server({ server: this.server }); + } else { + const credentials = { + cert: fs.readFileSync('/etc/letsencrypt/live/mempool.space/fullchain.pem'), + key: fs.readFileSync('/etc/letsencrypt/live/mempool.space/privkey.pem'), + }; + this.server = https.createServer(credentials, this.app); + this.wss = new WebSocket.Server({ server: this.server }); + } + + this.setUpRoutes(); + this.setUpWebsocketHandling(); + this.setUpMempoolCache(); + this.runMempoolIntervalFunctions(); + + statistics.startStatistics(); + fiatConversion.startService(); + + this.server.listen(8999, () => { + console.log(`Server started on port 8999 :)`); + }); + } + + private async runMempoolIntervalFunctions() { + await blocks.updateBlocks(); + await memPool.getMemPoolInfo(); + await memPool.updateMempool(); + setTimeout(this.runMempoolIntervalFunctions.bind(this), config.MEMPOOL_REFRESH_RATE_MS); + } + + private setUpMempoolCache() { + const cacheData = diskCache.loadData(); + if (cacheData) { + memPool.setMempool(JSON.parse(cacheData)); + } + + process.on('SIGINT', (options) => { + console.log('SIGINT'); + diskCache.saveData(JSON.stringify(memPool.getMempool())); + process.exit(2); + }); + } + + private setUpWebsocketHandling() { + this.wss.on('connection', (client: WebSocket) => { + let theBlocks = blocks.getBlocks(); + theBlocks = theBlocks.concat([]).splice(theBlocks.length - config.INITIAL_BLOCK_AMOUNT); + + const formatedBlocks = theBlocks.map((b) => blocks.formatBlock(b)); + + client.send(JSON.stringify({ + 'mempoolInfo': memPool.getMempoolInfo(), + 'blocks': formatedBlocks, + 'projectedBlocks': projectedBlocks.getProjectedBlocks(), + 'txPerSecond': memPool.getTxPerSecond(), + 'vBytesPerSecond': memPool.getVBytesPerSecond(), + 'conversions': fiatConversion.getTickers()['BTCUSD'], + })); + + client.on('message', async (message: any) => { + try { + const parsedMessage = JSON.parse(message); + if (parsedMessage.action === 'track-tx' && parsedMessage.txId && /^[a-fA-F0-9]{64}$/.test(parsedMessage.txId)) { + const tx = await memPool.getRawTransaction(parsedMessage.txId); + if (tx) { + console.log('Now tracking: ' + parsedMessage.txId); + client['trackingTx'] = true; + client['txId'] = parsedMessage.txId; + client['tx'] = tx; + + if (tx.blockhash) { + const currentBlocks = blocks.getBlocks(); + const foundBlock = currentBlocks.find((block) => block.tx && block.tx.some((i: string) => i === parsedMessage.txId)); + if (foundBlock) { + console.log('Found block by looking in local cache'); + client['blockHeight'] = foundBlock.height; + } else { + const theBlock = await bitcoinApi.getBlock(tx.blockhash); + if (theBlock) { + client['blockHeight'] = theBlock.height; + } + } + } else { + client['blockHeight'] = 0; + } + client.send(JSON.stringify({ + 'projectedBlocks': projectedBlocks.getProjectedBlocks(client['txId']), + 'track-tx': { + tracking: true, + blockHeight: client['blockHeight'], + tx: client['tx'], + } + })); + } else { + console.log('TX NOT FOUND, NOT TRACKING'); + client.send(JSON.stringify({ + 'track-tx': { + tracking: false, + blockHeight: 0, + message: 'not-found', + } + })); + } + } + if (parsedMessage.action === 'stop-tracking-tx') { + console.log('STOP TRACKING'); + client['trackingTx'] = false; + client.send(JSON.stringify({ + 'track-tx': { + tracking: false, + blockHeight: 0, + message: 'not-found', + } + })); + } + } catch (e) { + console.log(e); + } + }); + + client.on('close', () => { + client['trackingTx'] = false; + }); + }); + + blocks.setNewBlockCallback((block: IBlock) => { + const formattedBlocks = blocks.formatBlock(block); + + this.wss.clients.forEach((client) => { + if (client.readyState !== WebSocket.OPEN) { + return; + } + + if (client['trackingTx'] === true && client['blockHeight'] === 0) { + if (block.tx.some((tx) => tx === client['txId'])) { + client['blockHeight'] = block.height; + } + } + + client.send(JSON.stringify({ + 'block': formattedBlocks, + 'track-tx': { + tracking: client['trackingTx'] || false, + blockHeight: client['blockHeight'], + } + })); + }); + }); + + memPool.setMempoolChangedCallback((newMempool: IMempool) => { + projectedBlocks.updateProjectedBlocks(newMempool); + + let pBlocks = projectedBlocks.getProjectedBlocks(); + const mempoolInfo = memPool.getMempoolInfo(); + const txPerSecond = memPool.getTxPerSecond(); + const vBytesPerSecond = memPool.getVBytesPerSecond(); + + this.wss.clients.forEach((client: WebSocket) => { + if (client.readyState !== WebSocket.OPEN) { + return; + } + + if (client['trackingTx'] && client['blockHeight'] === 0) { + pBlocks = projectedBlocks.getProjectedBlocks(client['txId']); + } + + client.send(JSON.stringify({ + 'projectedBlocks': pBlocks, + 'mempoolInfo': mempoolInfo, + 'txPerSecond': txPerSecond, + 'vBytesPerSecond': vBytesPerSecond, + 'track-tx': { + tracking: client['trackingTx'] || false, + blockHeight: client['blockHeight'], + } + })); + }); + }); + } + + private setUpRoutes() { + this.app + .get(config.API_ENDPOINT + 'transactions/height/:id', routes.$getgetTransactionsForBlock) + .get(config.API_ENDPOINT + 'transactions/projected/:id', routes.getgetTransactionsForProjectedBlock) + .get(config.API_ENDPOINT + 'fees/recommended', routes.getRecommendedFees) + .get(config.API_ENDPOINT + 'statistics/live', routes.getLiveResult) + .get(config.API_ENDPOINT + 'statistics/2h', routes.get2HStatistics) + .get(config.API_ENDPOINT + 'statistics/24h', routes.get24HStatistics) + .get(config.API_ENDPOINT + 'statistics/1w', routes.get1WHStatistics) + .get(config.API_ENDPOINT + 'statistics/1m', routes.get1MStatistics) + .get(config.API_ENDPOINT + 'statistics/3m', routes.get3MStatistics) + .get(config.API_ENDPOINT + 'statistics/6m', routes.get6MStatistics) + ; + } +} + +const mempoolSpace = new MempoolSpace(); diff --git a/backend/src/interfaces.ts b/backend/src/interfaces.ts new file mode 100644 index 000000000..424f7dafa --- /dev/null +++ b/backend/src/interfaces.ts @@ -0,0 +1,151 @@ +export interface IMempoolInfo { + size: number; + bytes: number; + usage: number; + maxmempool: number; + mempoolminfee: number; + minrelaytxfee: number; +} + +export interface ITransaction { + txid: string; + hash: string; + version: number; + size: number; + vsize: number; + locktime: number; + vin: Vin[]; + vout: Vout[]; + hex: string; + fee: number; + feePerWeightUnit: number; + feePerVsize: number; + blockhash?: string; + confirmations?: number; + time?: number; + blocktime?: number; + totalOut?: number; +} + +export interface IBlock { + hash: string; + confirmations: number; + strippedsize: number; + size: number; + weight: number; + height: number; + version: number; + versionHex: string; + merkleroot: string; + tx: any; + time: number; + mediantime: number; + nonce: number; + bits: string; + difficulty: number; + chainwork: string; + nTx: number; + previousblockhash: string; + fees: number; + + minFee?: number; + maxFee?: number; + medianFee?: number; +} + +interface ScriptSig { + asm: string; + hex: string; +} + +interface Vin { + txid: string; + vout: number; + scriptSig: ScriptSig; + sequence: number; +} + +interface ScriptPubKey { + asm: string; + hex: string; + reqSigs: number; + type: string; + addresses: string[]; +} + +interface Vout { + value: number; + n: number; + scriptPubKey: ScriptPubKey; +} + +export interface IMempoolStats { + id?: number; + added: string; + unconfirmed_transactions: number; + tx_per_second: number; + vbytes_per_second: number; + total_fee: number; + mempool_byte_weight: number; + fee_data: string; + + vsize_1: number; + vsize_2: number; + vsize_3: number; + vsize_4: number; + vsize_5: number; + vsize_6: number; + vsize_8: number; + vsize_10: number; + vsize_12: number; + vsize_15: number; + vsize_20: number; + vsize_30: number; + vsize_40: number; + vsize_50: number; + vsize_60: number; + vsize_70: number; + vsize_80: number; + vsize_90: number; + vsize_100: number; + vsize_125: number; + vsize_150: number; + vsize_175: number; + vsize_200: number; + vsize_250: number; + vsize_300: number; + vsize_350: number; + vsize_400: number; + vsize_500: number; + vsize_600: number; + vsize_700: number; + vsize_800: number; + vsize_900: number; + vsize_1000: number; + vsize_1200: number; + vsize_1400: number; + vsize_1600: number; + vsize_1800: number; + vsize_2000: number; +} + +export interface IProjectedBlockInternal extends IProjectedBlock { + txIds: string[]; + txFeePerVsizes: number[]; +} + +export interface IProjectedBlock { + blockSize: number; + blockWeight: number; + maxFee: number; + maxWeightFee: number; + medianFee: number; + minFee: number; + minWeightFee: number; + nTx: number; + fees: number; + hasMyTxId?: boolean; +} + +export interface IMempool { [txid: string]: ITransaction; } + diff --git a/backend/src/routes.ts b/backend/src/routes.ts new file mode 100644 index 000000000..3c620c4a7 --- /dev/null +++ b/backend/src/routes.ts @@ -0,0 +1,63 @@ +import statistics from './api/statistics'; +import feeApi from './api/fee-api'; +import projectedBlocks from './api/projected-blocks'; + +class Routes { + constructor() {} + + public async getLiveResult(req, res) { + const result = await statistics.$listLatestFromId(req.query.lastId); + res.send(result); + } + + public async get2HStatistics(req, res) { + const result = await statistics.$list2H(); + res.send(result); + } + + public async get24HStatistics(req, res) { + const result = await statistics.$list24H(); + res.send(result); + } + + public async get1WHStatistics(req, res) { + const result = await statistics.$list1W(); + res.send(result); + } + + public async get1MStatistics(req, res) { + const result = await statistics.$list1M(); + res.send(result); + } + + public async get3MStatistics(req, res) { + const result = await statistics.$list3M(); + res.send(result); + } + + public async get6MStatistics(req, res) { + const result = await statistics.$list6M(); + res.send(result); + } + + public async getRecommendedFees(req, res) { + const result = feeApi.getRecommendedFee(); + res.send(result); + } + + public async $getgetTransactionsForBlock(req, res) { + const result = await feeApi.$getTransactionsForBlock(req.params.id); + res.send(result); + } + + public async getgetTransactionsForProjectedBlock(req, res) { + try { + const result = await projectedBlocks.getProjectedBlockFeesForBlock(req.params.id); + res.send(result); + } catch (e) { + res.status(500).send(e.message); + } + } +} + +export default new Routes(); diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 000000000..fb1b48c6e --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es2015", + "strict": true, + "noImplicitAny": false, + "sourceMap": false, + "outDir": "dist", + "moduleResolution": "node", + "typeRoots": [ + "node_modules/@types" + ] + }, + "include": [ + "src/**/*.ts", + ], + "exclude": [ + "dist/**" + ] +} \ No newline at end of file diff --git a/backend/tslint.json b/backend/tslint.json new file mode 100644 index 000000000..65ac58f4b --- /dev/null +++ b/backend/tslint.json @@ -0,0 +1,137 @@ +{ + "rules": { + "arrow-return-shorthand": true, + "callable-types": true, + "class-name": true, + "comment-format": [ + true, + "check-space" + ], + "curly": true, + "deprecation": { + "severity": "warn" + }, + "eofline": true, + "forin": true, + "import-blacklist": [ + true, + "rxjs", + "rxjs/Rx" + ], + "import-spacing": true, + "indent": [ + true, + "spaces" + ], + "interface-over-type-literal": true, + "label-position": true, + "max-line-length": [ + true, + 140 + ], + "member-access": false, + "member-ordering": [ + true, + { + "order": [ + "static-field", + "instance-field", + "static-method", + "instance-method" + ] + } + ], + "no-arg": true, + "no-bitwise": true, + "no-console": [ + true, + "debug", + "info", + "time", + "timeEnd", + "trace" + ], + "no-construct": true, + "no-debugger": true, + "no-duplicate-super": true, + "no-empty": false, + "no-empty-interface": true, + "no-eval": true, + "no-inferrable-types": false, + "no-misused-new": true, + "no-non-null-assertion": true, + "no-shadowed-variable": true, + "no-string-literal": false, + "no-string-throw": true, + "no-switch-case-fall-through": true, + "no-trailing-whitespace": true, + "no-unnecessary-initializer": true, + "no-unused-expression": true, + "no-use-before-declare": true, + "no-var-keyword": true, + "object-literal-sort-keys": false, + "one-line": [ + true, + "check-open-brace", + "check-catch", + "check-else", + "check-whitespace" + ], + "prefer-const": true, + "quotemark": [ + true, + "single" + ], + "radix": true, + "semicolon": [ + true, + "always" + ], + "triple-equals": [ + true, + "allow-null-check" + ], + "typedef-whitespace": [ + true, + { + "call-signature": "nospace", + "index-signature": "nospace", + "parameter": "nospace", + "property-declaration": "nospace", + "variable-declaration": "nospace" + } + ], + "unified-signatures": true, + "variable-name": false, + "whitespace": [ + true, + "check-branch", + "check-decl", + "check-operator", + "check-separator", + "check-type" + ], + "directive-selector": [ + true, + "attribute", + "app", + "camelCase" + ], + "component-selector": [ + true, + "element", + "app", + "kebab-case" + ], + "no-output-on-prefix": true, + "use-input-property-decorator": true, + "use-output-property-decorator": true, + "use-host-property-decorator": true, + "no-input-rename": true, + "no-output-rename": true, + "use-life-cycle-interface": true, + "use-pipe-transform-interface": true, + "component-class-suffix": true, + "directive-class-suffix": true + } +} diff --git a/frontend/.editorconfig b/frontend/.editorconfig new file mode 100644 index 000000000..6e87a003d --- /dev/null +++ b/frontend/.editorconfig @@ -0,0 +1,13 @@ +# Editor configuration, see http://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 000000000..ee5c9d833 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,39 @@ +# See http://help.github.com/ignore-files/ for more about ignoring files. + +# compiled output +/dist +/tmp +/out-tsc + +# dependencies +/node_modules + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# misc +/.sass-cache +/connect.lock +/coverage +/libpeerconnection.log +npm-debug.log +yarn-error.log +testem.log +/typings + +# System Files +.DS_Store +Thumbs.db diff --git a/frontend/angular.json b/frontend/angular.json new file mode 100644 index 000000000..fe9ec09b0 --- /dev/null +++ b/frontend/angular.json @@ -0,0 +1,127 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "mempool": { + "root": "", + "sourceRoot": "src", + "projectType": "application", + "prefix": "app", + "schematics": { + "@schematics/angular:component": { + "styleext": "scss" + } + }, + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:browser", + "options": { + "outputPath": "dist/mempool", + "index": "src/index.html", + "main": "src/main.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "src/tsconfig.app.json", + "assets": [ + "src/favicon.ico", + "src/assets", + "src/.htaccess" + ], + "styles": [ + "src/styles.scss" + ], + "scripts": [] + }, + "configurations": { + "production": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ], + "optimization": true, + "outputHashing": "all", + "sourceMap": false, + "extractCss": true, + "namedChunks": false, + "aot": true, + "extractLicenses": true, + "vendorChunk": false, + "buildOptimizer": true + } + } + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "options": { + "browserTarget": "mempool:build" + }, + "configurations": { + "production": { + "browserTarget": "mempool:build:production" + } + } + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n", + "options": { + "browserTarget": "mempool:build" + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "main": "src/test.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "src/tsconfig.spec.json", + "karmaConfig": "src/karma.conf.js", + "styles": [ + "src/styles.scss" + ], + "scripts": [], + "assets": [ + "src/favicon.ico", + "src/assets" + ] + } + }, + "lint": { + "builder": "@angular-devkit/build-angular:tslint", + "options": { + "tsConfig": [ + "src/tsconfig.app.json", + "src/tsconfig.spec.json" + ], + "exclude": [ + "**/node_modules/**" + ] + } + } + } + }, + "mempool-e2e": { + "root": "e2e/", + "projectType": "application", + "architect": { + "e2e": { + "builder": "@angular-devkit/build-angular:protractor", + "options": { + "protractorConfig": "e2e/protractor.conf.js", + "devServerTarget": "mempool:serve" + } + }, + "lint": { + "builder": "@angular-devkit/build-angular:tslint", + "options": { + "tsConfig": "e2e/tsconfig.e2e.json", + "exclude": [ + "**/node_modules/**" + ] + } + } + } + } + }, + "defaultProject": "mempool" +} \ No newline at end of file diff --git a/frontend/e2e/protractor.conf.js b/frontend/e2e/protractor.conf.js new file mode 100644 index 000000000..86776a391 --- /dev/null +++ b/frontend/e2e/protractor.conf.js @@ -0,0 +1,28 @@ +// Protractor configuration file, see link for more information +// https://github.com/angular/protractor/blob/master/lib/config.ts + +const { SpecReporter } = require('jasmine-spec-reporter'); + +exports.config = { + allScriptsTimeout: 11000, + specs: [ + './src/**/*.e2e-spec.ts' + ], + capabilities: { + 'browserName': 'chrome' + }, + directConnect: true, + baseUrl: 'http://localhost:4200/', + framework: 'jasmine', + jasmineNodeOpts: { + showColors: true, + defaultTimeoutInterval: 30000, + print: function() {} + }, + onPrepare() { + require('ts-node').register({ + project: require('path').join(__dirname, './tsconfig.e2e.json') + }); + jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); + } +}; \ No newline at end of file diff --git a/frontend/e2e/src/app.e2e-spec.ts b/frontend/e2e/src/app.e2e-spec.ts new file mode 100644 index 000000000..e42d1f965 --- /dev/null +++ b/frontend/e2e/src/app.e2e-spec.ts @@ -0,0 +1,14 @@ +import { AppPage } from './app.po'; + +describe('workspace-project App', () => { + let page: AppPage; + + beforeEach(() => { + page = new AppPage(); + }); + + it('should display welcome message', () => { + page.navigateTo(); + expect(page.getParagraphText()).toEqual('Welcome to app!'); + }); +}); diff --git a/frontend/e2e/src/app.po.ts b/frontend/e2e/src/app.po.ts new file mode 100644 index 000000000..82ea75ba5 --- /dev/null +++ b/frontend/e2e/src/app.po.ts @@ -0,0 +1,11 @@ +import { browser, by, element } from 'protractor'; + +export class AppPage { + navigateTo() { + return browser.get('/'); + } + + getParagraphText() { + return element(by.css('app-root h1')).getText(); + } +} diff --git a/frontend/e2e/tsconfig.e2e.json b/frontend/e2e/tsconfig.e2e.json new file mode 100644 index 000000000..a6dd62202 --- /dev/null +++ b/frontend/e2e/tsconfig.e2e.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "../out-tsc/app", + "module": "commonjs", + "target": "es5", + "types": [ + "jasmine", + "jasminewd2", + "node" + ] + } +} \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 000000000..9d8d6dfdc --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,53 @@ +{ + "name": "mempool", + "version": "0.0.0", + "scripts": { + "ng": "ng", + "start": "ng serve --aot --proxy-config proxy.conf.json", + "build": "ng build --prod --vendorChunk=false --build-optimizer=true", + "test": "ng test", + "lint": "ng lint", + "e2e": "ng e2e" + }, + "private": true, + "dependencies": { + "@angular/animations": "^8.0.0", + "@angular/common": "^8.0.0", + "@angular/compiler": "^8.0.0", + "@angular/core": "^8.0.0", + "@angular/forms": "^8.0.0", + "@angular/platform-browser": "^8.0.0", + "@angular/platform-browser-dynamic": "^8.0.0", + "@angular/router": "^8.0.0", + "@ng-bootstrap/ng-bootstrap": "^3.3.1", + "bootstrap": "^4.3.1", + "chartist": "^0.11.2", + "core-js": "^2.6.9", + "ng-chartist": "^2.0.0-beta.1", + "rxjs": "^6.5.2", + "tslib": "^1.9.0", + "zone.js": "~0.9.1" + }, + "devDependencies": { + "@angular-devkit/build-angular": "~0.800.0", + "@angular/cli": "~8.0.2", + "@angular/compiler-cli": "^8.0.0", + "@angular/language-service": "^8.0.0", + "@types/chartist": "^0.9.46", + "@types/jasmine": "^2.8.16", + "@types/jasminewd2": "^2.0.6", + "@types/node": "~8.9.4", + "codelyzer": "~5.1.0", + "jasmine-core": "~2.99.1", + "jasmine-spec-reporter": "~4.2.1", + "karma": "~1.7.1", + "karma-chrome-launcher": "~2.2.0", + "karma-coverage-istanbul-reporter": "~1.4.2", + "karma-jasmine": "~1.1.1", + "karma-jasmine-html-reporter": "^0.2.2", + "protractor": "~5.3.0", + "ts-node": "~7.0.0", + "tslint": "~5.15.0", + "typescript": "~3.4.3" + } +} diff --git a/frontend/proxy.conf.json b/frontend/proxy.conf.json new file mode 100644 index 000000000..8a5402c52 --- /dev/null +++ b/frontend/proxy.conf.json @@ -0,0 +1,6 @@ +{ + "/api": { + "target": "http://localhost:8999/", + "secure": false + } +} \ No newline at end of file diff --git a/frontend/src/.htaccess b/frontend/src/.htaccess new file mode 100644 index 000000000..2513ec66c --- /dev/null +++ b/frontend/src/.htaccess @@ -0,0 +1,7 @@ +RewriteEngine on +RewriteCond %{REQUEST_FILENAME} -s [OR] +RewriteCond %{REQUEST_FILENAME} -l [OR] +RewriteCond %{REQUEST_FILENAME} -d +RewriteRule ^.*$ - [NC,L] + +RewriteRule ^(.*) /index.html [NC,L] \ No newline at end of file diff --git a/frontend/src/app/about/about.component.html b/frontend/src/app/about/about.component.html new file mode 100644 index 000000000..99defc554 --- /dev/null +++ b/frontend/src/app/about/about.component.html @@ -0,0 +1,41 @@ +
+ +

+ +

About

+ +

Mempool.Space is a realtime Bitcoin blockchain visualizer and statistics website focused on SegWit.

+

Created by @softcrypto (Telegram). @softcrypt0 (Twitter). +
Designed by emeraldo.io.

+ + +

Fee API

+ +
+ +
+ +
+ +

Donate

+

Segwit native

+ +
+ bc1qqrmgr60uetlmrpylhtllawyha9z5gw6hwdmk2t + +

+

Segwit compatibility

+ +
+ 3Ccig4G4u8hbExnxBJHeE5ZmxxWxvEQ65f + + +

+ +

PayNym

+ +
+

+ PM8TJZWDn1XbYmVVMR3RP9Kt1BW69VCSLTC12UB8iWUiKcEBJsxB4UUKBMJxc3LVaxtU5d524sLFrTy9kFuyPQ73QkEagGcMfCE6M38E5C67EF8KAqvS +

+
diff --git a/frontend/src/app/about/about.component.scss b/frontend/src/app/about/about.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/app/about/about.component.ts b/frontend/src/app/about/about.component.ts new file mode 100644 index 000000000..9a3c3894e --- /dev/null +++ b/frontend/src/app/about/about.component.ts @@ -0,0 +1,15 @@ +import { Component, OnInit } from '@angular/core'; + +@Component({ + selector: 'app-about', + templateUrl: './about.component.html', + styleUrls: ['./about.component.scss'] +}) +export class AboutComponent implements OnInit { + + constructor() { } + + ngOnInit() { + } + +} diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts new file mode 100644 index 000000000..6f435c80a --- /dev/null +++ b/frontend/src/app/app-routing.module.ts @@ -0,0 +1,40 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; +import { BlockchainComponent } from './blockchain/blockchain.component'; +import { AboutComponent } from './about/about.component'; +import { StatisticsComponent } from './statistics/statistics.component'; + +const routes: Routes = [ + { + path: '', + children: [], + component: BlockchainComponent + }, + { + path: 'tx/:id', + children: [], + component: BlockchainComponent + }, + { + path: 'about', + children: [], + component: AboutComponent + }, + { + path: 'statistics', + component: StatisticsComponent, + }, + { + path: 'graphs', + component: StatisticsComponent, + }, + { + path: '**', + redirectTo: '' + } +]; +@NgModule({ + imports: [RouterModule.forRoot(routes)], + exports: [RouterModule] +}) +export class AppRoutingModule { } diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html new file mode 100644 index 000000000..aca28bbea --- /dev/null +++ b/frontend/src/app/app.component.html @@ -0,0 +1,32 @@ +
+ +
+ +
+ + \ No newline at end of file diff --git a/frontend/src/app/app.component.scss b/frontend/src/app/app.component.scss new file mode 100644 index 000000000..bdaf25149 --- /dev/null +++ b/frontend/src/app/app.component.scss @@ -0,0 +1,28 @@ +li.nav-item.active { + background-color: #653b9c; +} + +li.nav-item { + padding: 10px; +} + +.navbar { + z-index: 100; +} + +@media (min-width: 768px) { + .navbar { + padding: 0rem 1rem; + } + li.nav-item { + padding: 20px; + } +} + +.logo { + margin-left: 40px; +} + +li.nav-item a { + color: #ffffff; +} diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts new file mode 100644 index 000000000..6346c837c --- /dev/null +++ b/frontend/src/app/app.component.ts @@ -0,0 +1,52 @@ +import { Component, OnInit } from '@angular/core'; +import { MemPoolService } from './services/mem-pool.service'; +import { Router } from '@angular/router'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; + +@Component({ + selector: 'app-root', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'] +}) +export class AppComponent implements OnInit { + navCollapsed = false; + isOffline = false; + searchForm: FormGroup; + + constructor( + private memPoolService: MemPoolService, + private router: Router, + private formBuilder: FormBuilder, + ) { } + + ngOnInit() { + this.searchForm = this.formBuilder.group({ + txId: ['', Validators.pattern('^[a-fA-F0-9]{64}$')], + }); + + this.memPoolService.isOffline + .subscribe((state) => { + this.isOffline = state; + }); + } + + collapse(): void { + this.navCollapsed = !this.navCollapsed; + } + + search() { + const txId = this.searchForm.value.txId; + if (txId) { + if (window.location.pathname === '/' || window.location.pathname.substr(0, 4) === '/tx/') { + window.history.pushState({}, '', `/tx/${txId}`); + } else { + this.router.navigate(['/tx/', txId]); + } + this.memPoolService.txIdSearch.next(txId); + this.searchForm.setValue({ + txId: '', + }); + this.collapse(); + } + } +} diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts new file mode 100644 index 000000000..6330c103d --- /dev/null +++ b/frontend/src/app/app.module.ts @@ -0,0 +1,45 @@ +import { BrowserModule } from '@angular/platform-browser'; +import { NgModule } from '@angular/core'; + +import { AppComponent } from './app.component'; +import { BlockchainComponent } from './blockchain/blockchain.component'; +import { AppRoutingModule } from './app-routing.module'; +import { SharedModule } from './shared/shared.module'; +import { MemPoolService } from './services/mem-pool.service'; +import { HttpClientModule } from '@angular/common/http'; +import { FooterComponent } from './footer/footer.component'; +import { AboutComponent } from './about/about.component'; +import { TxBubbleComponent } from './tx-bubble/tx-bubble.component'; +import { ReactiveFormsModule } from '@angular/forms'; +import { BlockModalComponent } from './block-modal/block-modal.component'; +import { StatisticsComponent } from './statistics/statistics.component'; +import { ProjectedBlockModalComponent } from './projected-block-modal/projected-block-modal.component'; + +@NgModule({ + declarations: [ + AppComponent, + BlockchainComponent, + FooterComponent, + StatisticsComponent, + AboutComponent, + TxBubbleComponent, + BlockModalComponent, + ProjectedBlockModalComponent, + ], + imports: [ + ReactiveFormsModule, + BrowserModule, + HttpClientModule, + AppRoutingModule, + SharedModule, + ], + providers: [ + MemPoolService, + ], + entryComponents: [ + BlockModalComponent, + ProjectedBlockModalComponent, + ], + bootstrap: [AppComponent] +}) +export class AppModule { } diff --git a/frontend/src/app/block-modal/block-modal.component.html b/frontend/src/app/block-modal/block-modal.component.html new file mode 100644 index 000000000..f726dad7d --- /dev/null +++ b/frontend/src/app/block-modal/block-modal.component.html @@ -0,0 +1,45 @@ + + diff --git a/frontend/src/app/block-modal/block-modal.component.scss b/frontend/src/app/block-modal/block-modal.component.scss new file mode 100644 index 000000000..75d226682 --- /dev/null +++ b/frontend/src/app/block-modal/block-modal.component.scss @@ -0,0 +1,7 @@ +.yellow-color { + color: #ffd800; +} + +.green-color { + color: #3bcc49; +} diff --git a/frontend/src/app/block-modal/block-modal.component.ts b/frontend/src/app/block-modal/block-modal.component.ts new file mode 100644 index 000000000..b65c92cd9 --- /dev/null +++ b/frontend/src/app/block-modal/block-modal.component.ts @@ -0,0 +1,73 @@ +import { Component, OnInit, Input } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { ApiService } from '../services/api.service'; +import { IBlock } from '../blockchain/interfaces'; +import { MemPoolService } from '../services/mem-pool.service'; +import * as Chartist from 'chartist'; + +@Component({ + selector: 'app-block-modal', + templateUrl: './block-modal.component.html', + styleUrls: ['./block-modal.component.scss'] +}) +export class BlockModalComponent implements OnInit { + @Input() block: IBlock; + + mempoolVsizeFeesData: any; + mempoolVsizeFeesOptions: any; + conversions: any; + + constructor( + public activeModal: NgbActiveModal, + private apiService: ApiService, + private memPoolService: MemPoolService, + ) { } + + ngOnInit() { + + this.mempoolVsizeFeesOptions = { + showArea: false, + showLine: false, + fullWidth: false, + showPoint: false, + low: 0, + axisX: { + position: 'start', + showLabel: false, + offset: 0, + showGrid: false, + }, + axisY: { + position: 'end', + scaleMinSpace: 40, + showGrid: false, + }, + plugins: [ + Chartist.plugins.tooltip({ + tooltipOffset: { + x: 15, + y: 250 + }, + transformTooltipTextFnc: (value: number): any => { + return Math.ceil(value) + ' sat/vB'; + }, + anchorToPoint: false, + }) + ] + }; + + this.memPoolService.conversions + .subscribe((conversions) => { + this.conversions = conversions; + }); + + this.apiService.listTransactionsForBlock$(this.block.height) + .subscribe((data) => { + this.mempoolVsizeFeesData = { + labels: data.map((x, i) => i), + series: [data.map((tx) => tx.fpv)] + }; + }); + } + +} diff --git a/frontend/src/app/blockchain/blockchain.component.html b/frontend/src/app/blockchain/blockchain.component.html new file mode 100644 index 000000000..98075f441 --- /dev/null +++ b/frontend/src/app/blockchain/blockchain.component.html @@ -0,0 +1,69 @@ +
+

Loading blocks...

+
+
+
+
+

Locating transaction...

+
+
+

Transaction not found!

+
+
+
+ +
+
+
+
+
+ ~{{ projectedBlock.medianFee | ceil }} sat/vB +
+ {{ projectedBlock.minFee | ceil }} - {{ projectedBlock.maxFee | ceil }} sat/vB +
+
{{ projectedBlock.blockSize | bytes: 2 }}
+
{{ projectedBlock.nTx }} transactions
+
In ~{{ 10 * i + 10 }} minutes
+ +
+{{ projectedBlock.blockWeight / 4000000 | ceil }} blocks
+
+
+ +
+
+
+
+ +
+
+ + + +
+
+ ~{{ block.medianFee | ceil }} sat/vB +
+ {{ block.minFee | ceil }} - {{ block.maxFee | ceil }} sat/vB +
+ +
{{ block.size | bytes: 2 }}
+
{{ block.nTx }} transactions
+

+
{{ getTimeSinceMined(block) }} ago
+
+ +
+
+ +
+ +
+
+ +
+ + + + diff --git a/frontend/src/app/blockchain/blockchain.component.scss b/frontend/src/app/blockchain/blockchain.component.scss new file mode 100644 index 000000000..1898bcbbf --- /dev/null +++ b/frontend/src/app/blockchain/blockchain.component.scss @@ -0,0 +1,195 @@ +.block-filled { + width: 100%; + background-color: #aeffb0; + position: absolute; + bottom: 0; + left: 0; +} + +.block-filled .segwit { + background-color: #16ca1a; +} + +.bitcoin-block { + width: 125px; + height: 125px; + cursor: pointer; +} + +.mined-block { + position: absolute; + top: 0px; + transition: 1s; +} + +.block-size { + font-size: 18px; + font-weight: bold; +} + +.blocks-container { + position: absolute; + top: 0px; + left: 40px; +} + +.projected-blocks-container { + position: absolute; + top: 0px; + right: 0px; + left: 0px; + + animation: opacityPulse 2s ease-out; + animation-iteration-count: infinite; + opacity: 1; +} + +.projected-block { + position: absolute; + top: 0; +} + +.block-body { + text-align: center; +} + +@keyframes opacityPulse { + 0% {opacity: 0.7;} + 50% {opacity: 1.0;} + 100% {opacity: 0.7;} +} + +.time-difference { + position: absolute; + bottom: 10px; + text-align: center; + width: 100%; + font-size: 14px; +} + +#divider { + width: 3px; + height: 3000px; + left: 0; + top: -1000px; + background-image: url('/assets/divider-new.png'); + background-repeat: repeat-y; + position: absolute; + margin-bottom: 120px; +} + +#divider > img { + position: absolute; + left: -100px; + top: -28px; +} + +.fees { + font-size: 10px; + margin-top: 10px; + margin-bottom: 2px; +} + +.btcblockmiddle { + height: 18px; +} + +.breakRow { + height: 30px; + margin-top: 20px; +} + +.yellow-color { + color: #ffd800; +} + +.transaction-count { + font-size: 12px; +} + +.blockchain-wrapper { + overflow: hidden; +} + +.position-container { + position: absolute; + left: 50%; + top: calc(50% - 60px); +} + +.block-height { + position: absolute; + font-size: 12px; + bottom: 160px; + width: 100%; + left: -12px; + text-shadow: 0px 32px 3px #111; + z-index: 100; +} + +@media (max-width: 767.98px) { + #divider { + top: -50px; + } + .position-container { + top: 100px; + } + .projected-blocks-container { + position: absolute; + left: -165px; + top: -40px; + } + .block-height { + bottom: 125px; + left: inherit; + text-shadow: inherit; + z-index: inherit; + } +} + +@media (min-width: 1920px) { + .position-container { + transform: scale(1.3); + } +} + +@media (min-width: 768px) { + .bitcoin-block::after { + content: ''; + width: 125px; + height: 24px; + position:absolute; + top: -24px; + left: -20px; + background-color: #232838; + transform:skew(40deg); + transform-origin:top; + } + + .bitcoin-block::before { + content: ''; + width: 20px; + height: 125px; + position: absolute; + top: -12px; + left: -20px; + background-color: #191c27; + + transform: skewY(50deg); + transform-origin: top; + } + + .projected-block.bitcoin-block::after { + background-color: #403834; + } + + .projected-block.bitcoin-block::before { + background-color: #2d2825; + } +} + +.black-background { + background-color: #11131f; + z-index: 100; + position: relative; +} diff --git a/frontend/src/app/blockchain/blockchain.component.ts b/frontend/src/app/blockchain/blockchain.component.ts new file mode 100644 index 000000000..5b0600aae --- /dev/null +++ b/frontend/src/app/blockchain/blockchain.component.ts @@ -0,0 +1,272 @@ +import { Component, OnInit, OnDestroy, Renderer2, HostListener } from '@angular/core'; +import { IMempoolDefaultResponse, IBlock, IProjectedBlock, ITransaction } from './interfaces'; +import { retryWhen, tap } from 'rxjs/operators'; +import { MemPoolService } from '../services/mem-pool.service'; +import { ApiService } from '../services/api.service'; +import { ActivatedRoute, ParamMap } from '@angular/router'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { BlockModalComponent } from '../block-modal/block-modal.component'; +import { ProjectedBlockModalComponent } from '../projected-block-modal/projected-block-modal.component'; + +@Component({ + selector: 'app-blockchain', + templateUrl: './blockchain.component.html', + styleUrls: ['./blockchain.component.scss'] +}) +export class BlockchainComponent implements OnInit, OnDestroy { + blocks: IBlock[] = []; + projectedBlocks: IProjectedBlock[] = []; + subscription: any; + socket: any; + innerWidth: any; + txBubbleStyle: any = {}; + + txTrackingLoading = false; + txTrackingEnabled = false; + txTrackingTx: ITransaction | null = null; + txTrackingBlockHeight = 0; + txShowTxNotFound = false; + txBubbleArrowPosition = 'top'; + + @HostListener('window:resize', ['$event']) + onResize(event: Event) { + this.innerWidth = window.innerWidth; + this.moveTxBubbleToPosition(); + } + + constructor( + private memPoolService: MemPoolService, + private apiService: ApiService, + private renderer: Renderer2, + private route: ActivatedRoute, + private modalService: NgbModal, + ) {} + + ngOnInit() { + + this.txBubbleStyle = { + 'position': 'absolute', + 'top': '425px', + 'visibility': 'hidden', + }; + + this.innerWidth = window.innerWidth; + this.socket = this.apiService.websocketSubject; + this.subscription = this.socket + .pipe( + retryWhen((errors: any) => errors.pipe( + tap(() => this.memPoolService.isOffline.next(true)))) + ) + .subscribe((response: IMempoolDefaultResponse) => { + this.memPoolService.isOffline.next(false); + if (response.mempoolInfo && response.txPerSecond !== undefined) { + this.memPoolService.loaderSubject.next({ + memPoolInfo: response.mempoolInfo, + txPerSecond: response.txPerSecond, + vBytesPerSecond: response.vBytesPerSecond, + }); + } + if (response.blocks && response.blocks.length) { + this.blocks = response.blocks; + this.blocks.reverse(); + } + if (response.block) { + if (!this.blocks.some((block) => response.block !== undefined && response.block.height === block.height )) { + this.blocks.unshift(response.block); + if (this.blocks.length >= 8) { + this.blocks.pop(); + } + } + } + if (response.conversions) { + this.memPoolService.conversions.next(response.conversions); + } + if (response.projectedBlocks) { + this.projectedBlocks = response.projectedBlocks; + const mempoolWeight = this.projectedBlocks.map((block) => block.blockWeight).reduce((a, b) => a + b); + this.memPoolService.mempoolWeight.next(mempoolWeight); + } + if (response['track-tx']) { + if (response['track-tx'].tracking) { + this.txTrackingEnabled = true; + this.txTrackingBlockHeight = response['track-tx'].blockHeight; + if (response['track-tx'].tx) { + this.txTrackingTx = response['track-tx'].tx; + this.txTrackingLoading = false; + } + } else { + this.txTrackingEnabled = false; + this.txTrackingTx = null; + this.txTrackingBlockHeight = 0; + } + if (response['track-tx'].message && response['track-tx'].message === 'not-found') { + this.txTrackingLoading = false; + this.txShowTxNotFound = true; + setTimeout(() => { this.txShowTxNotFound = false; }, 2000); + } + setTimeout(() => { + this.moveTxBubbleToPosition(); + }); + } + }, + (err: Error) => console.log(err) + ); + this.renderer.addClass(document.body, 'disable-scroll'); + + this.route.paramMap + .subscribe((params: ParamMap) => { + const txId: string | null = params.get('id'); + if (!txId) { + return; + } + this.txTrackingLoading = true; + this.socket.next({'action': 'track-tx', 'txId': txId}); + }); + + this.memPoolService.txIdSearch + .subscribe((txId) => { + if (txId) { + this.txTrackingLoading = true; + this.socket.next({'action': 'track-tx', 'txId': txId}); + } + }); + } + + moveTxBubbleToPosition() { + let element: HTMLElement | null = null; + if (this.txTrackingBlockHeight === 0) { + const index = this.projectedBlocks.findIndex((pB) => pB.hasMytx); + if (index > -1) { + element = document.getElementById('projected-block-' + index); + } else { + return; + } + } else { + element = document.getElementById('bitcoin-block-' + this.txTrackingBlockHeight); + } + + this.txBubbleStyle['visibility'] = 'visible'; + this.txBubbleStyle['position'] = 'absolute'; + + if (!element) { + if (this.innerWidth <= 768) { + this.txBubbleArrowPosition = 'bottom'; + this.txBubbleStyle['left'] = window.innerWidth / 2 - 50 + 'px'; + this.txBubbleStyle['bottom'] = '270px'; + this.txBubbleStyle['top'] = 'inherit'; + this.txBubbleStyle['position'] = 'fixed'; + } else { + this.txBubbleStyle['left'] = window.innerWidth - 220 + 'px'; + this.txBubbleArrowPosition = 'right'; + this.txBubbleStyle['top'] = '425px'; + } + } else { + this.txBubbleArrowPosition = 'top'; + const domRect: DOMRect | ClientRect = element.getBoundingClientRect(); + this.txBubbleStyle['left'] = domRect.left - 50 + 'px'; + this.txBubbleStyle['top'] = domRect.top + 125 + window.scrollY + 'px'; + + if (domRect.left + 100 > window.innerWidth) { + this.txBubbleStyle['left'] = window.innerWidth - 220 + 'px'; + this.txBubbleArrowPosition = 'right'; + } else if (domRect.left + 220 > window.innerWidth) { + this.txBubbleStyle['left'] = window.innerWidth - 240 + 'px'; + this.txBubbleArrowPosition = 'top-right'; + } else { + this.txBubbleStyle['left'] = domRect.left + 15 + 'px'; + } + + if (domRect.left < 86) { + this.txBubbleArrowPosition = 'top-left'; + this.txBubbleStyle['left'] = 125 + 'px'; + } + } + } + + getTimeSinceMined(block: IBlock): string { + const minutes = ((new Date().getTime()) - (new Date(block.time * 1000).getTime())) / 1000 / 60; + if (minutes >= 120) { + return Math.floor(minutes / 60) + ' hours'; + } + if (minutes >= 60) { + return Math.floor(minutes / 60) + ' hour'; + } + if (minutes <= 1) { + return '< 1 minute'; + } + if (minutes === 1) { + return '1 minute'; + } + return Math.round(minutes) + ' minutes'; + } + + getStyleForBlock(block: IBlock) { + const greenBackgroundHeight = 100 - (block.weight / 4000000) * 100; + if (this.innerWidth <= 768) { + return { + 'top': 155 * this.blocks.indexOf(block) + 'px', + 'background': `repeating-linear-gradient(#2d3348, #2d3348 ${greenBackgroundHeight}%, + #9339f4 ${Math.max(greenBackgroundHeight, 0)}%, #105fb0 100%)`, + }; + } else { + return { + 'left': 155 * this.blocks.indexOf(block) + 'px', + 'background': `repeating-linear-gradient(#2d3348, #2d3348 ${greenBackgroundHeight}%, + #9339f4 ${Math.max(greenBackgroundHeight, 0)}%, #105fb0 100%)`, + }; + } + } + + getStyleForProjectedBlockAtIndex(index: number) { + const greenBackgroundHeight = 100 - (this.projectedBlocks[index].blockWeight / 4000000) * 100; + if (this.innerWidth <= 768) { + if (index === 3) { + return { + 'top': 40 + index * 155 + 'px' + }; + } + return { + 'top': 40 + index * 155 + 'px', + 'background': `repeating-linear-gradient(#554b45, #554b45 ${greenBackgroundHeight}%, + #bd7c13 ${Math.max(greenBackgroundHeight, 0)}%, #c5345a 100%)`, + }; + } else { + if (index === 3) { + return { + 'right': 40 + index * 155 + 'px' + }; + } + return { + 'right': 40 + index * 155 + 'px', + 'background': `repeating-linear-gradient(#554b45, #554b45 ${greenBackgroundHeight}%, + #bd7c13 ${Math.max(greenBackgroundHeight, 0)}%, #c5345a 100%)`, + }; + } + } + + trackByProjectedFn(index: number) { + return index; + } + + trackByBlocksFn(index: number, item: IBlock) { + return item.height; + } + + ngOnDestroy() { + if (this.subscription) { + this.subscription.unsubscribe(); + } + this.renderer.removeClass(document.body, 'disable-scroll'); + } + + openBlockModal(block: IBlock) { + const modalRef = this.modalService.open(BlockModalComponent, { size: 'lg' }); + modalRef.componentInstance.block = block; + } + + openProjectedBlockModal(block: IBlock, index: number) { + const modalRef = this.modalService.open(ProjectedBlockModalComponent, { size: 'lg' }); + modalRef.componentInstance.block = block; + modalRef.componentInstance.index = index; + } +} diff --git a/frontend/src/app/blockchain/interfaces.ts b/frontend/src/app/blockchain/interfaces.ts new file mode 100644 index 000000000..99971d7bf --- /dev/null +++ b/frontend/src/app/blockchain/interfaces.ts @@ -0,0 +1,176 @@ +export interface IMempoolInfo { + size: number; + bytes: number; + usage: number; + maxmempool: number; + mempoolminfee: number; + minrelaytxfee: number; +} + +export interface IMempoolDefaultResponse { + mempoolInfo?: IMempoolInfo; + blocks?: IBlock[]; + block?: IBlock; + projectedBlocks?: IProjectedBlock[]; + txPerSecond?: number; + vBytesPerSecond: number; + 'track-tx'?: ITrackTx; + conversions?: any; +} + +export interface ITrackTx { + tx?: ITransaction; + blockHeight: number; + tracking: boolean; + message?: string; +} + +export interface IProjectedBlock { + blockSize: number; + blockWeight: number; + maxFee: number; + maxWeightFee: number; + medianFee: number; + minFee: number; + minWeightFee: number; + nTx: number; + hasMytx: boolean; +} + +export interface IStrippedBlock { + bits: number; + difficulty: number; + hash: string; + height: number; + nTx: number; + size: number; + strippedsize: number; + time: number; + weight: number; +} + +export interface ITransaction { + txid: string; + hash: string; + version: number; + size: number; + vsize: number; + locktime: number; + vin: Vin[]; + vout: Vout[]; + hex: string; + + fee: number; + feePerVsize: number; + feePerWeightUnit: number; +} + +export interface IBlock { + hash: string; + confirmations: number; + strippedsize: number; + size: number; + weight: number; + height: number; + version: number; + versionHex: string; + merkleroot: string; + tx: ITransaction[]; + time: number; + mediantime: number; + nonce: number; + bits: string; + difficulty: number; + chainwork: string; + nTx: number; + previousblockhash: string; + + minFee: number; + maxFee: number; + medianFee: number; + fees: number; +} + +interface ScriptSig { + asm: string; + hex: string; +} + +interface Vin { + txid: string; + vout: number; + scriptSig: ScriptSig; + sequence: number; +} + +interface ScriptPubKey { + asm: string; + hex: string; + reqSigs: number; + type: string; + addresses: string[]; +} + +interface Vout { + value: number; + n: number; + scriptPubKey: ScriptPubKey; +} + +export interface IMempoolStats { + id: number; + added: string; + unconfirmed_transactions: number; + tx_per_second: number; + vbytes_per_second: number; + mempool_byte_weight: number; + fee_data: IFeeData; + vsize_1: number; + vsize_2: number; + vsize_3: number; + vsize_4: number; + vsize_5: number; + vsize_6: number; + vsize_8: number; + vsize_10: number; + vsize_12: number; + vsize_15: number; + vsize_20: number; + vsize_30: number; + vsize_40: number; + vsize_50: number; + vsize_60: number; + vsize_70: number; + vsize_80: number; + vsize_90: number; + vsize_100: number; + vsize_125: number; + vsize_150: number; + vsize_175: number; + vsize_200: number; + vsize_250: number; + vsize_300: number; + vsize_350: number; + vsize_400: number; + vsize_500: number; + vsize_600: number; + vsize_700: number; + vsize_800: number; + vsize_900: number; + vsize_1000: number; + vsize_1200: number; + vsize_1400: number; + vsize_1600: number; + vsize_1800: number; + vsize_2000: number; +} + +export interface IBlockTransaction { + f: number; + fpv: number; +} + +interface IFeeData { + wu: { [ fee: string ]: number }; + vsize: { [ fee: string ]: number }; +} diff --git a/frontend/src/app/footer/footer.component.html b/frontend/src/app/footer/footer.component.html new file mode 100644 index 000000000..8cc174093 --- /dev/null +++ b/frontend/src/app/footer/footer.component.html @@ -0,0 +1,18 @@ +
+
+
+
+ Unconfirmed transactions: {{ memPoolInfo?.memPoolInfo?.size | number }} ({{ mempoolBlocks }} blocks) +
+ Tx per second: {{ memPoolInfo?.txPerSecond | number : '1.2-2' }} tx/s +
+ Tx weight per second:  + +
+
{{ memPoolInfo?.vBytesPerSecond | ceil | number }} vBytes/s
+
+ +
+
+
+
diff --git a/frontend/src/app/footer/footer.component.scss b/frontend/src/app/footer/footer.component.scss new file mode 100644 index 000000000..cc37ebdbe --- /dev/null +++ b/frontend/src/app/footer/footer.component.scss @@ -0,0 +1,42 @@ +.footer { + position: fixed; + bottom: 0; + width: 100%; + height: 120px; + background-color: #1d1f31; +} + +.footer > .container { + margin-top: 25px; +} + +.txPerSecond { + color: #4a9ff4; +} + +.mempoolSize { + color: #4a68b9; +} + +.unconfirmedTx { + color: #f14d80; +} + +.info-block { + float:left; +} + +.progress { + display: inline-flex; + width: 150px; + background-color: #2d3348; + height: 1.1rem; +} + +.progress-bar { + padding: 4px; +} + +.bg-warning { + background-color: #b58800 !important; +} diff --git a/frontend/src/app/footer/footer.component.ts b/frontend/src/app/footer/footer.component.ts new file mode 100644 index 000000000..898995f30 --- /dev/null +++ b/frontend/src/app/footer/footer.component.ts @@ -0,0 +1,54 @@ +import { Component, OnInit } from '@angular/core'; +import { MemPoolService, MemPoolState } from '../services/mem-pool.service'; + +@Component({ + selector: 'app-footer', + templateUrl: './footer.component.html', + styleUrls: ['./footer.component.scss'] +}) +export class FooterComponent implements OnInit { + memPoolInfo: MemPoolState | undefined; + mempoolBlocks = 0; + progressWidth = ''; + progressClass: string; + + constructor( + private memPoolService: MemPoolService + ) { } + + ngOnInit() { + this.memPoolService.loaderSubject + .subscribe((mempoolState) => { + this.memPoolInfo = mempoolState; + this.updateProgress(); + }); + this.memPoolService.mempoolWeight + .subscribe((mempoolWeight) => { + this.mempoolBlocks = Math.ceil(mempoolWeight / 4000000); + }); + } + + updateProgress() { + if (!this.memPoolInfo) { + return; + } + + const vBytesPerSecondLimit = 1667; + + let vBytesPerSecond = this.memPoolInfo.vBytesPerSecond; + if (vBytesPerSecond > 1667) { + vBytesPerSecond = 1667; + } + + const percent = Math.round((vBytesPerSecond / vBytesPerSecondLimit) * 100); + this.progressWidth = percent + '%'; + + if (percent <= 75) { + this.progressClass = 'bg-success'; + } else if (percent <= 99) { + this.progressClass = 'bg-warning'; + } else { + this.progressClass = 'bg-danger'; + } + } +} diff --git a/frontend/src/app/projected-block-modal/projected-block-modal.component.html b/frontend/src/app/projected-block-modal/projected-block-modal.component.html new file mode 100644 index 000000000..add57bbe9 --- /dev/null +++ b/frontend/src/app/projected-block-modal/projected-block-modal.component.html @@ -0,0 +1,41 @@ + + diff --git a/frontend/src/app/projected-block-modal/projected-block-modal.component.scss b/frontend/src/app/projected-block-modal/projected-block-modal.component.scss new file mode 100644 index 000000000..75d226682 --- /dev/null +++ b/frontend/src/app/projected-block-modal/projected-block-modal.component.scss @@ -0,0 +1,7 @@ +.yellow-color { + color: #ffd800; +} + +.green-color { + color: #3bcc49; +} diff --git a/frontend/src/app/projected-block-modal/projected-block-modal.component.ts b/frontend/src/app/projected-block-modal/projected-block-modal.component.ts new file mode 100644 index 000000000..b0d280aa7 --- /dev/null +++ b/frontend/src/app/projected-block-modal/projected-block-modal.component.ts @@ -0,0 +1,74 @@ +import { Component, OnInit, Input } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { ApiService } from '../services/api.service'; +import { IBlock } from '../blockchain/interfaces'; +import { MemPoolService } from '../services/mem-pool.service'; +import * as Chartist from 'chartist'; + +@Component({ + selector: 'app-projected-block-modal', + templateUrl: './projected-block-modal.component.html', + styleUrls: ['./projected-block-modal.component.scss'] +}) +export class ProjectedBlockModalComponent implements OnInit { + @Input() block: IBlock; + @Input() index: number; + + mempoolVsizeFeesData: any; + mempoolVsizeFeesOptions: any; + conversions: any; + + constructor( + public activeModal: NgbActiveModal, + private apiService: ApiService, + private memPoolService: MemPoolService, + ) { } + + ngOnInit() { + + this.mempoolVsizeFeesOptions = { + showArea: false, + showLine: false, + fullWidth: false, + showPoint: false, + low: 0, + axisX: { + position: 'start', + showLabel: false, + offset: 0, + showGrid: false, + }, + axisY: { + position: 'end', + scaleMinSpace: 40, + showGrid: false, + }, + plugins: [ + Chartist.plugins.tooltip({ + tooltipOffset: { + x: 15, + y: 250 + }, + transformTooltipTextFnc: (value: number): any => { + return Math.ceil(value) + ' sat/vB'; + }, + anchorToPoint: false, + }) + ] + }; + + this.memPoolService.conversions + .subscribe((conversions) => { + this.conversions = conversions; + }); + + this.apiService.listTransactionsForProjectedBlock$(this.index) + .subscribe((data) => { + this.mempoolVsizeFeesData = { + labels: data.map((x, i) => i), + series: [data.map((tx) => tx.fpv)] + }; + }); + } + +} diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts new file mode 100644 index 000000000..44200a43d --- /dev/null +++ b/frontend/src/app/services/api.service.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@angular/core'; +import { environment } from '../../environments/environment'; +import { webSocket } from 'rxjs/webSocket'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { IMempoolDefaultResponse, IMempoolStats, IBlockTransaction } from '../blockchain/interfaces'; +import { Observable } from 'rxjs'; + + +let WEB_SOCKET_URL = 'wss://mempool.space:8999'; +let API_BASE_URL = 'https://mempool.space:8999/api/v1'; + +if (!environment.production) { + WEB_SOCKET_URL = 'ws://localhost:8999'; + API_BASE_URL = '/api/v1'; +} + +@Injectable({ + providedIn: 'root' +}) +export class ApiService { + constructor( + private httpClient: HttpClient, + ) { } + + websocketSubject = webSocket(WEB_SOCKET_URL); + + listTransactionsForBlock$(height: number): Observable { + return this.httpClient.get(API_BASE_URL + '/transactions/height/' + height); + } + + listTransactionsForProjectedBlock$(index: number): Observable { + return this.httpClient.get(API_BASE_URL + '/transactions/projected/' + index); + } + + listLiveStatistics$(lastId: number): Observable { + const params = new HttpParams() + .set('lastId', lastId.toString()); + + return this.httpClient.get(API_BASE_URL + '/statistics/live', { + params: params + }); + } + + list2HStatistics$(): Observable { + return this.httpClient.get(API_BASE_URL + '/statistics/2h'); + } + + list24HStatistics$(): Observable { + return this.httpClient.get(API_BASE_URL + '/statistics/24h'); + } + + list1WStatistics$(): Observable { + return this.httpClient.get(API_BASE_URL + '/statistics/1w'); + } + + list1MStatistics$(): Observable { + return this.httpClient.get(API_BASE_URL + '/statistics/1m'); + } + + list3MStatistics$(): Observable { + return this.httpClient.get(API_BASE_URL + '/statistics/3m'); + } + + list6MStatistics$(): Observable { + return this.httpClient.get(API_BASE_URL + '/statistics/6m'); + } + +} diff --git a/frontend/src/app/services/mem-pool.service.ts b/frontend/src/app/services/mem-pool.service.ts new file mode 100644 index 000000000..b792a6cda --- /dev/null +++ b/frontend/src/app/services/mem-pool.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@angular/core'; +import { Subject, ReplaySubject } from 'rxjs'; +import { IMempoolInfo } from '../blockchain/interfaces'; + +export interface MemPoolState { + memPoolInfo: IMempoolInfo; + txPerSecond: number; + vBytesPerSecond: number; +} + +@Injectable({ + providedIn: 'root' +}) +export class MemPoolService { + loaderSubject = new Subject(); + isOffline = new Subject(); + txIdSearch = new Subject(); + conversions = new ReplaySubject(); + mempoolWeight = new Subject(); +} diff --git a/frontend/src/app/shared/pipes/bytes-pipe/bytes.pipe.ts b/frontend/src/app/shared/pipes/bytes-pipe/bytes.pipe.ts new file mode 100644 index 000000000..b961669fc --- /dev/null +++ b/frontend/src/app/shared/pipes/bytes-pipe/bytes.pipe.ts @@ -0,0 +1,63 @@ +/* tslint:disable */ +import { Pipe, PipeTransform } from '@angular/core'; +import { isNumberFinite, isPositive, isInteger, toDecimal } from './utils'; + +export type ByteUnit = 'B' | 'kB' | 'MB' | 'GB' | 'TB'; + +@Pipe({ + name: 'bytes' +}) +export class BytesPipe implements PipeTransform { + + static formats: { [key: string]: { max: number, prev?: ByteUnit } } = { + 'B': {max: 1000}, + 'kB': {max: Math.pow(1000, 2), prev: 'B'}, + 'MB': {max: Math.pow(1000, 3), prev: 'kB'}, + 'GB': {max: Math.pow(1000, 4), prev: 'MB'}, + 'TB': {max: Number.MAX_SAFE_INTEGER, prev: 'GB'} + }; + + transform(input: any, decimal: number = 0, from: ByteUnit = 'B', to?: ByteUnit): any { + + if (!(isNumberFinite(input) && + isNumberFinite(decimal) && + isInteger(decimal) && + isPositive(decimal))) { + return input; + } + + let bytes = input; + let unit = from; + while (unit !== 'B') { + bytes *= 1024; + unit = BytesPipe.formats[unit].prev!; + } + + if (to) { + const format = BytesPipe.formats[to]; + + const result = toDecimal(BytesPipe.calculateResult(format, bytes), decimal); + + return BytesPipe.formatResult(result, to); + } + + for (const key in BytesPipe.formats) { + const format = BytesPipe.formats[key]; + if (bytes < format.max) { + + const result = toDecimal(BytesPipe.calculateResult(format, bytes), decimal); + + return BytesPipe.formatResult(result, key); + } + } + } + + static formatResult(result: number, unit: string): string { + return `${result} ${unit}`; + } + + static calculateResult(format: { max: number, prev?: ByteUnit }, bytes: number) { + const prev = format.prev ? BytesPipe.formats[format.prev] : undefined; + return prev ? bytes / prev.max : bytes; + } +} diff --git a/frontend/src/app/shared/pipes/bytes-pipe/utils.ts b/frontend/src/app/shared/pipes/bytes-pipe/utils.ts new file mode 100644 index 000000000..fc8c2b08f --- /dev/null +++ b/frontend/src/app/shared/pipes/bytes-pipe/utils.ts @@ -0,0 +1,311 @@ +/* tslint:disable */ + +export type CollectionPredicate = (item?: any, index?: number, collection?: any[]) => boolean; + +export function isUndefined(value: any): value is undefined { + + return typeof value === 'undefined'; +} + +export function isNull(value: any): value is null { + return value === null; +} + +export function isNumber(value: any): value is number { + return typeof value === 'number'; +} + +export function isNumberFinite(value: any): value is number { + return isNumber(value) && isFinite(value); +} + +// Not strict positive +export function isPositive(value: number): boolean { + return value >= 0; +} + + +export function isInteger(value: number): boolean { + // No rest, is an integer + return (value % 1) === 0; +} + +export function isNil(value: any): value is (null | undefined) { + return value === null || typeof (value) === 'undefined'; +} + +export function isString(value: any): value is string { + return typeof value === 'string'; +} + +export function isObject(value: any): boolean { + return value !== null && typeof value === 'object'; +} + +export function isArray(value: any): boolean { + return Array.isArray(value); +} + +export function isFunction(value: any): boolean { + return typeof value === 'function'; +} + +export function toDecimal(value: number, decimal: number): number { + return Math.round(value * Math.pow(10, decimal)) / Math.pow(10, decimal); +} + +export function upperFirst(value: string): string { + return value.slice(0, 1).toUpperCase() + value.slice(1); +} + +export function createRound(method: string): Function { + // Math to suppress error + const func: any = (Math)[method]; + return function (value: number, precision: number = 0) { + if (typeof value === 'string') { + throw new TypeError('Rounding method needs a number'); + } + if (typeof precision !== 'number' || isNaN(precision)) { + precision = 0; + } + if (precision) { + let pair = `${value}e`.split('e'); + const val = func(`${pair[0]}e` + (+pair[1] + precision)); + pair = `${val}e`.split('e'); + return +(pair[0] + 'e' + (+pair[1] - precision)); + } + return func(value); + }; +} + +export function leftPad(str: string, len: number = 0, ch: any = ' ') { + str = String(str); + ch = toString(ch); + let i = -1; + const length = len - str.length; + while (++i < length && (str.length + ch.length) <= len) { + str = ch + str; + } + return str; +} + +export function rightPad(str: string, len: number = 0, ch: any = ' ') { + str = String(str); + ch = toString(ch); + let i = -1; + const length = len - str.length; + while (++i < length && (str.length + ch.length) <= len) { + str += ch; + } + return str; +} + +export function toString(value: number | string) { + return `${value}`; +} + +export function pad(str: string, len: number = 0, ch: any = ' '): string { + str = String(str); + ch = toString(ch); + let i = -1; + const length = len - str.length; + + let left = true; + while (++i < length) { + const l = (str.length + ch.length <= len) ? (str.length + ch.length) : (str.length + 1); + if (left) { + str = leftPad(str, l, ch); + } else { + str = rightPad(str, l, ch); + } + left = !left; + } + return str; +} + +export function flatten(input: any[], index: number = 0): any[] { + + if (index >= input.length) { + return input; + } + + if (isArray(input[index])) { + return flatten( + input.slice(0, index).concat(input[index], input.slice(index + 1)), + index + ); + } + + return flatten(input, index + 1); + +} + + +export function getProperty(value: { [key: string]: any }, key: string): any { + + if (isNil(value) || !isObject(value)) { + return undefined; + } + + const keys: string[] = key.split('.'); + let result: any = value[keys.shift()!]; + + for (const kk of keys) { + if (isNil(result) || !isObject(result)) { + return undefined; + } + + result = result[kk]; + } + + return result; +} + +export function sum(input: Array, initial = 0): number { + + return input.reduce((previous: number, current: number) => previous + current, initial); +} + +// http://stackoverflow.com/questions/6274339/how-can-i-shuffle-an-array-in-javascript +export function shuffle(input: any): any { + + if (!isArray(input)) { + return input; + } + + const copy = [...input]; + + for (let i = copy.length; i; --i) { + const j = Math.floor(Math.random() * i); + const x = copy[i - 1]; + copy[i - 1] = copy[j]; + copy[j] = x; + } + + return copy; +} + +export function deepIndexOf(collection: any[], value: any) { + + let index = -1; + const length = collection.length; + + while (++index < length) { + if (deepEqual(value, collection[index])) { + return index; + } + } + + return -1; +} + + +export function deepEqual(a: any, b: any) { + + if (a === b) { + return true; + } + + if (!(typeof a === 'object' && typeof b === 'object')) { + return a === b; + } + + const keysA = Object.keys(a); + const keysB = Object.keys(b); + + if (keysA.length !== keysB.length) { + return false; + } + + // Test for A's keys different from B. + const hasOwn = Object.prototype.hasOwnProperty; + for (let i = 0; i < keysA.length; i++) { + const key = keysA[i]; + if (!hasOwn.call(b, keysA[i]) || !deepEqual(a[key], b[key])) { + return false; + } + } + + return true; +} + +export function isDeepObject(object: any) { + + return object.__isDeepObject__; +} + +export function wrapDeep(object: any) { + + return new DeepWrapper(object); +} + +export function unwrapDeep(object: any) { + + if (isDeepObject(object)) { + return object.data; + } + + return object; +} + +export class DeepWrapper { + + public __isDeepObject__ = true; + + constructor(public data: any) { } +} + +export function count(input: any): any { + + if (!isArray(input) && !isObject(input) && !isString(input)) { + return input; + } + + if (isObject(input)) { + return Object.keys(input).map((value) => input[value]).length; + } + + return input.length; +} + +export function empty(input: any): any { + + if (!isArray(input)) { + return input; + } + + return input.length === 0; +} + +export function every(input: any, predicate: CollectionPredicate) { + + if (!isArray(input) || !predicate) { + return input; + } + + let result = true; + let i = -1; + + while (++i < input.length && result) { + result = predicate(input[i], i, input); + } + + + return result; +} + +export function takeUntil(input: any[], predicate: CollectionPredicate) { + + let i = -1; + const result: any = []; + while (++i < input.length && !predicate(input[i], i, input)) { + result[i] = input[i]; + } + + return result; +} + +export function takeWhile(input: any[], predicate: CollectionPredicate) { + return takeUntil(input, (item: any, index: number | undefined, collection: any[] | undefined) => + !predicate(item, index, collection)); +} diff --git a/frontend/src/app/shared/pipes/math-ceil/math-ceil.pipe.ts b/frontend/src/app/shared/pipes/math-ceil/math-ceil.pipe.ts new file mode 100644 index 000000000..f4babc545 --- /dev/null +++ b/frontend/src/app/shared/pipes/math-ceil/math-ceil.pipe.ts @@ -0,0 +1,8 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ name: 'ceil' }) +export class CeilPipe implements PipeTransform { + transform(nr: number) { + return Math.ceil(nr); + } +} diff --git a/frontend/src/app/shared/pipes/math-round-pipe/math-round.pipe.ts b/frontend/src/app/shared/pipes/math-round-pipe/math-round.pipe.ts new file mode 100644 index 000000000..f8b402daa --- /dev/null +++ b/frontend/src/app/shared/pipes/math-round-pipe/math-round.pipe.ts @@ -0,0 +1,8 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ name: 'round' }) +export class RoundPipe implements PipeTransform { + transform(nr: number) { + return Math.round(nr); + } +} diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts new file mode 100644 index 000000000..938dcf9f0 --- /dev/null +++ b/frontend/src/app/shared/shared.module.ts @@ -0,0 +1,34 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { NgbButtonsModule, NgbModalModule } from '@ng-bootstrap/ng-bootstrap'; + +import { BytesPipe } from './pipes/bytes-pipe/bytes.pipe'; +import { RoundPipe } from './pipes/math-round-pipe/math-round.pipe'; +import { CeilPipe } from './pipes/math-ceil/math-ceil.pipe'; +import { ChartistComponent } from '../statistics/chartist.component'; + +@NgModule({ + imports: [ + CommonModule, + NgbButtonsModule.forRoot(), + NgbModalModule.forRoot(), + ], + declarations: [ + ChartistComponent, + RoundPipe, + CeilPipe, + BytesPipe, + ], + exports: [ + RoundPipe, + CeilPipe, + BytesPipe, + NgbButtonsModule, + NgbModalModule, + ChartistComponent, + ], + providers: [ + BytesPipe + ] +}) +export class SharedModule { } diff --git a/frontend/src/app/statistics/chartist.component.scss b/frontend/src/app/statistics/chartist.component.scss new file mode 100644 index 000000000..62885776b --- /dev/null +++ b/frontend/src/app/statistics/chartist.component.scss @@ -0,0 +1,72 @@ +@import "../../styles.scss"; + +.ct-bar-label { + font-size: 20px; + font-weight: bold; + fill: #fff; +} + +.ct-target-line { + stroke: #f5f5f5; + stroke-width: 3px; + stroke-dasharray: 7px; +} + +.ct-area { + stroke: none; + fill-opacity: 0.9; +} + +.ct-label { + fill: rgba(255, 255, 255, 0.4); + color: rgba(255, 255, 255, 0.4); +} + +.ct-grid { + stroke: rgba(255, 255, 255, 0.2); +} + +/* LEGEND */ + +.ct-legend { + position: absolute; + z-index: 10; + left: 0px; + list-style: none; + font-size: 13px; + padding: 0px 0px 0px 30px; + top: 90px; + + li { + position: relative; + padding-left: 23px; + margin-bottom: 0px; + } + + li:before { + width: 12px; + height: 12px; + position: absolute; + left: 0; + content: ''; + border: 3px solid transparent; + border-radius: 2px; + } + + li.inactive:before { + background: transparent; + } + + &.ct-legend-inside { + position: absolute; + top: 0; + right: 0; + } + + @for $i from 0 to length($ct-series-colors) { + .ct-series-#{$i}:before { + background-color: nth($ct-series-colors, $i + 1); + border-color: nth($ct-series-colors, $i + 1); + } + } +} \ No newline at end of file diff --git a/frontend/src/app/statistics/chartist.component.ts b/frontend/src/app/statistics/chartist.component.ts new file mode 100644 index 000000000..d483395e6 --- /dev/null +++ b/frontend/src/app/statistics/chartist.component.ts @@ -0,0 +1,657 @@ +import { + Component, + ElementRef, + Input, + OnChanges, + OnDestroy, + OnInit, + SimpleChanges, + ViewEncapsulation +} from '@angular/core'; + +import * as Chartist from 'chartist'; + +/** + * Possible chart types + * @type {String} + */ +export type ChartType = 'Pie' | 'Bar' | 'Line'; + +export type ChartInterfaces = + | Chartist.IChartistPieChart + | Chartist.IChartistBarChart + | Chartist.IChartistLineChart; +export type ChartOptions = + | Chartist.IBarChartOptions + | Chartist.ILineChartOptions + | Chartist.IPieChartOptions; +export type ResponsiveOptionTuple = Chartist.IResponsiveOptionTuple< + ChartOptions +>; +export type ResponsiveOptions = ResponsiveOptionTuple[]; + +/** + * Represent a chart event. + * For possible values, check the Chartist docs. + */ +export interface ChartEvent { + [eventName: string]: (data: any) => void; +} + +@Component({ + selector: 'app-chartist', + template: '', + styleUrls: ['./chartist.component.scss'], + encapsulation: ViewEncapsulation.None, +}) +export class ChartistComponent implements OnInit, OnChanges, OnDestroy { + @Input() + // @ts-ignore + public data: Promise | Chartist.IChartistData; + + // @ts-ignore + @Input() public type: Promise | ChartType; + + @Input() + // @ts-ignore + public options: Promise | Chartist.IChartOptions; + + @Input() + // @ts-ignore + public responsiveOptions: Promise | ResponsiveOptions; + + // @ts-ignore + @Input() public events: ChartEvent; + + // @ts-ignore + public chart: ChartInterfaces; + + private element: HTMLElement; + + constructor(element: ElementRef) { + this.element = element.nativeElement; + } + + public ngOnInit(): Promise { + if (!this.type || !this.data) { + Promise.reject('Expected at least type and data.'); + } + + return this.renderChart().then((chart) => { + if (this.events !== undefined) { + this.bindEvents(chart); + } + + return chart; + }); + } + + public ngOnChanges(changes: SimpleChanges): void { + this.update(changes); + } + + public ngOnDestroy(): void { + if (this.chart) { + this.chart.detach(); + } + } + + public renderChart(): Promise { + const promises: any[] = [ + this.type, + this.element, + this.data, + this.options, + this.responsiveOptions + ]; + + return Promise.all(promises).then((values) => { + const [type, ...args]: any = values; + + if (!(type in Chartist)) { + throw new Error(`${type} is not a valid chart type`); + } + + this.chart = (Chartist as any)[type](...args); + + return this.chart; + }); + } + + public update(changes: SimpleChanges): void { + if (!this.chart || 'type' in changes) { + this.renderChart(); + } else { + if (changes.data) { + this.data = changes.data.currentValue; + } + + if (changes.options) { + this.options = changes.options.currentValue; + } + + (this.chart as any).update(this.data, this.options); + } + } + + public bindEvents(chart: any): void { + for (const event of Object.keys(this.events)) { + chart.on(event, this.events[event]); + } + } +} + +/** + * Chartist.js plugin to display a "target" or "goal" line across the chart. + * Only tested with bar charts. Works for horizontal and vertical bars. + */ +(function(window, document, Chartist) { + 'use strict'; + + const defaultOptions = { + // The class name so you can style the text + className: 'ct-target-line', + // The axis to draw the line. y == vertical bars, x == horizontal + axis: 'y', + // What value the target line should be drawn at + value: null + }; + + Chartist.plugins = Chartist.plugins || {}; + + Chartist.plugins.ctTargetLine = function (options: any) { + options = Chartist.extend({}, defaultOptions, options); + return function ctTargetLine (chart: any) { + + chart.on('created', function(context: any) { + const projectTarget = { + y: function (chartRect: any, bounds: any, value: any) { + const targetLineY = chartRect.y1 - (chartRect.height() / bounds.max * value); + + return { + x1: chartRect.x1, + x2: chartRect.x2, + y1: targetLineY, + y2: targetLineY + }; + }, + x: function (chartRect: any, bounds: any, value: any) { + const targetLineX = chartRect.x1 + (chartRect.width() / bounds.max * value); + + return { + x1: targetLineX, + x2: targetLineX, + y1: chartRect.y1, + y2: chartRect.y2 + }; + } + }; + // @ts-ignore + const targetLine = projectTarget[options.axis](context.chartRect, context.bounds, options.value); + + context.svg.elem('line', targetLine, options.className); + }); + }; + }; + +}(window, document, Chartist)); + + +/** + * Chartist.js plugin to display a data label on top of the points in a line chart. + * + */ +/* global Chartist */ +(function(window, document, Chartist) { + 'use strict'; + + const defaultOptions = { + labelClass: 'ct-label', + labelOffset: { + x: 0, + y: -10 + }, + textAnchor: 'middle', + align: 'center', + labelInterpolationFnc: Chartist.noop + }; + + const labelPositionCalculation = { + point: function(data: any) { + return { + x: data.x, + y: data.y + }; + }, + bar: { + left: function(data: any) { + return { + x: data.x1, + y: data.y1 + }; + }, + center: function(data: any) { + return { + x: data.x1 + (data.x2 - data.x1) / 2, + y: data.y1 + }; + }, + right: function(data: any) { + return { + x: data.x2, + y: data.y1 + }; + } + } + }; + + Chartist.plugins = Chartist.plugins || {}; + Chartist.plugins.ctPointLabels = function(options: any) { + + options = Chartist.extend({}, defaultOptions, options); + + function addLabel(position: any, data: any) { + // if x and y exist concat them otherwise output only the existing value + const value = data.value.x !== undefined && data.value.y ? + (data.value.x + ', ' + data.value.y) : + data.value.y || data.value.x; + + data.group.elem('text', { + x: position.x + options.labelOffset.x, + y: position.y + options.labelOffset.y, + style: 'text-anchor: ' + options.textAnchor + }, options.labelClass).text(options.labelInterpolationFnc(value)); + } + + return function ctPointLabels(chart: any) { + if (chart instanceof Chartist.Line || chart instanceof Chartist.Bar) { + chart.on('draw', function(data: any) { + // @ts-ignore + const positonCalculator = labelPositionCalculation[data.type] + // @ts-ignore + && labelPositionCalculation[data.type][options.align] || labelPositionCalculation[data.type]; + if (positonCalculator) { + addLabel(positonCalculator(data), data); + } + }); + } + }; + }; + +}(window, document, Chartist)); + +const defaultOptions = { + className: '', + classNames: false, + removeAll: false, + legendNames: false, + clickable: true, + onClick: null, + position: 'top' +}; + +Chartist.plugins.legend = function (options: any) { + let cachedDOMPosition; + // Catch invalid options + if (options && options.position) { + if (!(options.position === 'top' || options.position === 'bottom' || options.position instanceof HTMLElement)) { + throw Error('The position you entered is not a valid position'); + } + if (options.position instanceof HTMLElement) { + // Detatch DOM element from options object, because Chartist.extend + // currently chokes on circular references present in HTMLElements + cachedDOMPosition = options.position; + delete options.position; + } + } + + options = Chartist.extend({}, defaultOptions, options); + + if (cachedDOMPosition) { + // Reattatch the DOM Element position if it was removed before + options.position = cachedDOMPosition; + } + + return function legend(chart: any) { + + function removeLegendElement() { + const legendElement = chart.container.querySelector('.ct-legend'); + if (legendElement) { + legendElement.parentNode.removeChild(legendElement); + } + } + + // Set a unique className for each series so that when a series is removed, + // the other series still have the same color. + function setSeriesClassNames() { + chart.data.series = chart.data.series.map(function (series: any, seriesIndex: any) { + if (typeof series !== 'object') { + series = { + value: series + }; + } + series.className = series.className || chart.options.classNames.series + '-' + Chartist.alphaNumerate(seriesIndex); + return series; + }); + } + + function createLegendElement() { + const legendElement = document.createElement('ul'); + legendElement.className = 'ct-legend'; + if (chart instanceof Chartist.Pie) { + legendElement.classList.add('ct-legend-inside'); + } + if (typeof options.className === 'string' && options.className.length > 0) { + legendElement.classList.add(options.className); + } + if (chart.options.width) { + legendElement.style.cssText = 'width: ' + chart.options.width + 'px;margin: 0 auto;'; + } + return legendElement; + } + + // Get the right array to use for generating the legend. + function getLegendNames(useLabels: any) { + return options.legendNames || (useLabels ? chart.data.labels : chart.data.series); + } + + // Initialize the array that associates series with legends. + // -1 indicates that there is no legend associated with it. + function initSeriesMetadata(useLabels: any) { + const seriesMetadata = new Array(chart.data.series.length); + for (let i = 0; i < chart.data.series.length; i++) { + seriesMetadata[i] = { + data: chart.data.series[i], + label: useLabels ? chart.data.labels[i] : null, + legend: -1 + }; + } + return seriesMetadata; + } + + function createNameElement(i: any, legendText: any, classNamesViable: any) { + const li = document.createElement('li'); + li.classList.add('ct-series-' + i); + // Append specific class to a legend element, if viable classes are given + if (classNamesViable) { + li.classList.add(options.classNames[i]); + } + li.setAttribute('data-legend', i); + li.textContent = legendText; + return li; + } + + // Append the legend element to the DOM + function appendLegendToDOM(legendElement: any) { + if (!(options.position instanceof HTMLElement)) { + switch (options.position) { + case 'top': + chart.container.insertBefore(legendElement, chart.container.childNodes[0]); + break; + + case 'bottom': + chart.container.insertBefore(legendElement, null); + break; + } + } else { + // Appends the legend element as the last child of a given HTMLElement + options.position.insertBefore(legendElement, null); + } + } + + function addClickHandler(legendElement: any, legends: any, seriesMetadata: any, useLabels: any) { + legendElement.addEventListener('click', function(e: any) { + const li = e.target; + if (li.parentNode !== legendElement || !li.hasAttribute('data-legend')) + return; + e.preventDefault(); + + const legendIndex = parseInt(li.getAttribute('data-legend')); + const legend = legends[legendIndex]; + + if (!legend.active) { + legend.active = true; + li.classList.remove('inactive'); + } else { + legend.active = false; + li.classList.add('inactive'); + + const activeCount = legends.filter(function(legend: any) { return legend.active; }).length; + if (!options.removeAll && activeCount == 0) { + // If we can't disable all series at the same time, let's + // reenable all of them: + for (let i = 0; i < legends.length; i++) { + legends[i].active = true; + legendElement.childNodes[i].classList.remove('inactive'); + } + } + } + + const newSeries = []; + const newLabels = []; + + for (let i = 0; i < seriesMetadata.length; i++) { + if (seriesMetadata[i].legend !== -1 && legends[seriesMetadata[i].legend].active) { + newSeries.push(seriesMetadata[i].data); + newLabels.push(seriesMetadata[i].label); + } + } + + chart.data.series = newSeries; + if (useLabels) { + chart.data.labels = newLabels; + } + + chart.update(); + + if (options.onClick) { + options.onClick(chart, e); + } + }); + } + + removeLegendElement(); + + const legendElement = createLegendElement(); + const useLabels = chart instanceof Chartist.Pie && chart.data.labels && chart.data.labels.length; + const legendNames = getLegendNames(useLabels); + const seriesMetadata = initSeriesMetadata(useLabels); + const legends: any = []; + + // Check if given class names are viable to append to legends + const classNamesViable = Array.isArray(options.classNames) && options.classNames.length === legendNames.length; + + // Loop through all legends to set each name in a list item. + legendNames.forEach(function (legend: any, i: any) { + const legendText = legend.name || legend; + const legendSeries = legend.series || [i]; + + const li = createNameElement(i, legendText, classNamesViable); + legendElement.appendChild(li); + + legendSeries.forEach(function(seriesIndex: any) { + seriesMetadata[seriesIndex].legend = i; + }); + + legends.push({ + text: legendText, + series: legendSeries, + active: true + }); + }); + + chart.on('created', function (data: any) { + appendLegendToDOM(legendElement); + }); + + if (options.clickable) { + setSeriesClassNames(); + addClickHandler(legendElement, legends, seriesMetadata, useLabels); + } + }; +}; + +Chartist.plugins.tooltip = function (options: any) { + options = Chartist.extend({}, defaultOptions, options); + + return function tooltip(chart: any) { + let tooltipSelector = options.pointClass; + if (chart.constructor.name === Chartist.Bar.prototype.constructor.name) { + tooltipSelector = 'ct-bar'; + } else if (chart.constructor.name === Chartist.Pie.prototype.constructor.name) { + // Added support for donut graph + if (chart.options.donut) { + tooltipSelector = 'ct-slice-donut'; + } else { + tooltipSelector = 'ct-slice-pie'; + } + } + + const $chart = chart.container; + let $toolTip = $chart.querySelector('.chartist-tooltip'); + if (!$toolTip) { + $toolTip = document.createElement('div'); + $toolTip.className = (!options.class) ? 'chartist-tooltip' : 'chartist-tooltip ' + options.class; + if (!options.appendToBody) { + $chart.appendChild($toolTip); + } else { + document.body.appendChild($toolTip); + } + } + let height = $toolTip.offsetHeight; + let width = $toolTip.offsetWidth; + + hide($toolTip); + + function on(event: any, selector: any, callback: any) { + $chart.addEventListener(event, function (e: any) { + if (!selector || hasClass(e.target, selector)) { + callback(e); + } + }); + } + + on('mouseover', tooltipSelector, function (event: any) { + const $point = event.target; + let tooltipText = ''; + + const isPieChart = (chart instanceof Chartist.Pie) ? $point : $point.parentNode; + const seriesName = (isPieChart) ? $point.parentNode.getAttribute('ct:meta') || $point.parentNode.getAttribute('ct:series-name') : ''; + let meta = $point.getAttribute('ct:meta') || seriesName || ''; + const hasMeta = !!meta; + let value = $point.getAttribute('ct:value'); + + if (options.transformTooltipTextFnc && typeof options.transformTooltipTextFnc === 'function') { + value = options.transformTooltipTextFnc(value); + } + + if (options.tooltipFnc && typeof options.tooltipFnc === 'function') { + tooltipText = options.tooltipFnc(meta, value); + } else { + if (options.metaIsHTML) { + const txt = document.createElement('textarea'); + txt.innerHTML = meta; + meta = txt.value; + } + + meta = '' + meta + ''; + + if (hasMeta) { + tooltipText += meta + '
'; + } else { + // For Pie Charts also take the labels into account + // Could add support for more charts here as well! + if (chart instanceof Chartist.Pie) { + const label = next($point, 'ct-label'); + if (label) { + tooltipText += text(label) + '
'; + } + } + } + + if (value) { + if (options.currency) { + if (options.currencyFormatCallback != undefined) { + value = options.currencyFormatCallback(value, options); + } else { + value = options.currency + value.replace(/(\d)(?=(\d{3})+(?:\.\d+)?$)/g, '$1,'); + } + } + value = '' + value + ''; + tooltipText += value; + } + } + + if (tooltipText) { + $toolTip.innerHTML = tooltipText; + setPosition(event); + show($toolTip); + + // Remember height and width to avoid wrong position in IE + height = $toolTip.offsetHeight; + width = $toolTip.offsetWidth; + } + }); + + on('mouseout', tooltipSelector, function () { + hide($toolTip); + }); + + on('mousemove', null, function (event: any) { + if (false === options.anchorToPoint) { + setPosition(event); + } + }); + + function setPosition(event: any) { + height = height || $toolTip.offsetHeight; + width = width || $toolTip.offsetWidth; + const offsetX = - width / 2 + options.tooltipOffset.x + const offsetY = - height + options.tooltipOffset.y; + let anchorX, anchorY; + + if (!options.appendToBody) { + const box = $chart.getBoundingClientRect(); + const left = event.pageX - box.left - window.pageXOffset ; + const top = event.pageY - box.top - window.pageYOffset ; + + if (true === options.anchorToPoint && event.target.x2 && event.target.y2) { + anchorX = parseInt(event.target.x2.baseVal.value); + anchorY = parseInt(event.target.y2.baseVal.value); + } + + $toolTip.style.top = (anchorY || top) + offsetY + 'px'; + $toolTip.style.left = (anchorX || left) + offsetX + 'px'; + } else { + $toolTip.style.top = event.pageY + offsetY + 'px'; + $toolTip.style.left = event.pageX + offsetX + 'px'; + } + } + } +}; + +function show(element: any) { + if (!hasClass(element, 'tooltip-show')) { + element.className = element.className + ' tooltip-show'; + } +} + +function hide(element: any) { + const regex = new RegExp('tooltip-show' + '\\s*', 'gi'); + element.className = element.className.replace(regex, '').trim(); +} + +function hasClass(element: any, className: any) { + return (' ' + element.getAttribute('class') + ' ').indexOf(' ' + className + ' ') > -1; +} + +function next(element: any, className: any) { + do { + element = element.nextSibling; + } while (element && !hasClass(element, className)); + return element; +} + +function text(element: any) { + return element.innerText || element.textContent; +} diff --git a/frontend/src/app/statistics/statistics.component.html b/frontend/src/app/statistics/statistics.component.html new file mode 100644 index 000000000..5ec8ede1a --- /dev/null +++ b/frontend/src/app/statistics/statistics.component.html @@ -0,0 +1,108 @@ +
+ + +
+
+
+

Loading graphs...

+
+
+
+
+ +
+ +
+
+ Mempool by vbytes (satoshis/vbyte) + +
+
+
+ + + + + + + + +
+
+
+
+
+ + +
+
+
+
+ +
+
+
+ Transactions weight per second (vBytes/s)
+
+
+ + +
+ +
+
+
+ +
+
+
+ Transactions per second (tx/s)
+
+
+ + +
+ +
+
+
+ +
+ +
diff --git a/frontend/src/app/statistics/statistics.component.scss b/frontend/src/app/statistics/statistics.component.scss new file mode 100644 index 000000000..aef585cbe --- /dev/null +++ b/frontend/src/app/statistics/statistics.component.scss @@ -0,0 +1,16 @@ +.card-header { + border-bottom: 0; + background-color: none; + font-size: 20px; +} + +.card { + background-color: transparent; + border: 0; +} + +.bootstrap-spinner { + width: 22px; + height: 22px; + margin-right: 10px; +} diff --git a/frontend/src/app/statistics/statistics.component.ts b/frontend/src/app/statistics/statistics.component.ts new file mode 100644 index 000000000..abdea8584 --- /dev/null +++ b/frontend/src/app/statistics/statistics.component.ts @@ -0,0 +1,274 @@ +import { Component, OnInit, LOCALE_ID, Inject } from '@angular/core'; +import { ApiService } from '../services/api.service'; +import { formatDate } from '@angular/common'; +import { BytesPipe } from '../shared/pipes/bytes-pipe/bytes.pipe'; + +import * as Chartist from 'chartist'; +import { FormGroup, FormBuilder } from '@angular/forms'; +import { IMempoolStats } from '../blockchain/interfaces'; +import { Subject, of, merge} from 'rxjs'; +import { switchMap, tap } from 'rxjs/operators'; +import { ActivatedRoute } from '@angular/router'; + +@Component({ + selector: 'app-statistics', + templateUrl: './statistics.component.html', + styleUrls: ['./statistics.component.scss'] +}) +export class StatisticsComponent implements OnInit { + loading = true; + spinnerLoading = false; + + mempoolStats: IMempoolStats[] = []; + + mempoolVsizeFeesData: any; + mempoolUnconfirmedTransactionsData: any; + mempoolTransactionsPerSecondData: any; + mempoolTransactionsWeightPerSecondData: any; + + mempoolVsizeFeesOptions: any; + transactionsPerSecondOptions: any; + transactionsWeightPerSecondOptions: any; + + radioGroupForm: FormGroup; + + reloadData$: Subject = new Subject(); + + constructor( + private apiService: ApiService, + @Inject(LOCALE_ID) private locale: string, + private bytesPipe: BytesPipe, + private formBuilder: FormBuilder, + private route: ActivatedRoute, + ) { + this.radioGroupForm = this.formBuilder.group({ + 'dateSpan': '2h' + }); + } + + ngOnInit() { + const now = new Date(); + const nextInterval = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), + Math.floor(now.getMinutes() / 1) * 1 + 1, 0, 0); + const difference = nextInterval.getTime() - now.getTime(); + + setTimeout(() => { + setInterval(() => { + if (this.radioGroupForm.controls['dateSpan'].value === '2h') { + this.reloadData$.next(); + } + }, 60 * 1000); + }, difference + 1000); // Next whole minute + 1 second + + const labelInterpolationFnc = (value: any, index: any) => { + const nr = 6; + + switch (this.radioGroupForm.controls['dateSpan'].value) { + case '2h': + case '24h': + value = formatDate(value, 'HH:mm', this.locale); + break; + case '1w': + value = formatDate(value, 'dd/MM HH:mm', this.locale); + break; + case '1m': + case '3m': + case '6m': + value = formatDate(value, 'dd/MM', this.locale); + } + + return index % nr === 0 ? value : null; + }; + + this.mempoolVsizeFeesOptions = { + showArea: true, + showLine: false, + fullWidth: true, + showPoint: false, + low: 0, + axisX: { + labelInterpolationFnc: labelInterpolationFnc, + offset: 40 + }, + axisY: { + labelInterpolationFnc: (value: number): any => { + return this.bytesPipe.transform(value); + }, + offset: 160 + }, + plugins: [ + Chartist.plugins.ctTargetLine({ + value: 1000000 + }), + Chartist.plugins.legend({ + legendNames: [1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 30, 40, 50, 60, 70, 80, 90, 100, 125, 150, 175, 200, + 250, 300, 350, 400, 500, 600].map((sats, i, arr) => { + if (sats === 600) { + return '500+'; + } + if (i === 0) { + return '1 sat/vbyte'; + } + return arr[i - 1] + ' - ' + sats; + }) + }) + ] + }; + + this.transactionsWeightPerSecondOptions = { + showArea: false, + showLine: true, + showPoint: false, + low: 0, + axisY: { + offset: 40 + }, + axisX: { + labelInterpolationFnc: labelInterpolationFnc + }, + plugins: [ + Chartist.plugins.ctTargetLine({ + value: 1667 + }), + ] + }; + + this.transactionsPerSecondOptions = { + showArea: false, + showLine: true, + showPoint: false, + low: 0, + axisY: { + offset: 40 + }, + axisX: { + labelInterpolationFnc: labelInterpolationFnc + }, + }; + + this.route + .fragment + .subscribe((fragment) => { + if (['2h', '24h', '1w', '1m', '3m', '6m'].indexOf(fragment) > -1) { + this.radioGroupForm.controls['dateSpan'].setValue(fragment); + } + }); + + merge( + of(''), + this.reloadData$, + this.radioGroupForm.controls['dateSpan'].valueChanges + .pipe( + tap(() => { + this.mempoolStats = []; + }) + ) + ) + .pipe( + switchMap(() => { + this.spinnerLoading = true; + if (this.radioGroupForm.controls['dateSpan'].value === '6m') { + return this.apiService.list6MStatistics$(); + } + if (this.radioGroupForm.controls['dateSpan'].value === '3m') { + return this.apiService.list3MStatistics$(); + } + if (this.radioGroupForm.controls['dateSpan'].value === '1m') { + return this.apiService.list1MStatistics$(); + } + if (this.radioGroupForm.controls['dateSpan'].value === '1w') { + return this.apiService.list1WStatistics$(); + } + if (this.radioGroupForm.controls['dateSpan'].value === '24h') { + return this.apiService.list24HStatistics$(); + } + if (this.radioGroupForm.controls['dateSpan'].value === '2h' && !this.mempoolStats.length) { + return this.apiService.list2HStatistics$(); + } + const lastId = this.mempoolStats[0].id; + return this.apiService.listLiveStatistics$(lastId); + }) + ) + .subscribe((mempoolStats) => { + let hasChange = false; + if (this.radioGroupForm.controls['dateSpan'].value === '2h' && this.mempoolStats.length) { + if (mempoolStats.length) { + this.mempoolStats = mempoolStats.concat(this.mempoolStats); + this.mempoolStats = this.mempoolStats.slice(0, this.mempoolStats.length - mempoolStats.length); + hasChange = true; + } + } else { + this.mempoolStats = mempoolStats; + hasChange = true; + } + if (hasChange) { + this.handleNewMempoolData(this.mempoolStats.concat([])); + } + this.loading = false; + this.spinnerLoading = false; + }); + } + + handleNewMempoolData(mempoolStats: IMempoolStats[]) { + mempoolStats.reverse(); + const labels = mempoolStats.map(stats => stats.added); + + /** Active admins summed up */ + + this.mempoolTransactionsPerSecondData = { + labels: labels, + series: [mempoolStats.map((stats) => stats.tx_per_second)], + }; + + this.mempoolTransactionsWeightPerSecondData = { + labels: labels, + series: [mempoolStats.map((stats) => stats.vbytes_per_second)], + }; + + const finalArrayVbyte = this.generateArray(mempoolStats); + + // Remove the 0-1 fee vbyte since it's practially empty + finalArrayVbyte.shift(); + + this.mempoolVsizeFeesData = { + labels: labels, + series: finalArrayVbyte + }; + } + + getTimeToNextTenMinutes(): number { + const now = new Date(); + const nextInterval = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), + Math.floor(now.getMinutes() / 10) * 10 + 10, 0, 0); + return nextInterval.getTime() - now.getTime(); + } + + generateArray(mempoolStats: IMempoolStats[]) { + const logFees = [1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 30, 40, 50, 60, 70, 80, 90, 100, 125, 150, 175, 200, + 250, 300, 350, 400, 500, 600, 700, 800, 900, 1000, 1200, 1400, 1600, 1800, 2000]; + + logFees.reverse(); + + const finalArray: number[][] = []; + let feesArray: number[] = []; + + logFees.forEach((fee) => { + feesArray = []; + mempoolStats.forEach((stats) => { + // @ts-ignore + const theFee = stats['vsize_' + fee]; + if (theFee) { + feesArray.push(parseInt(theFee, 10)); + } else { + feesArray.push(0); + } + }); + if (finalArray.length) { + feesArray = feesArray.map((value, i) => value + finalArray[finalArray.length - 1][i]); + } + finalArray.push(feesArray); + }); + finalArray.reverse(); + return finalArray; + } +} diff --git a/frontend/src/app/tx-bubble/tx-bubble.component.html b/frontend/src/app/tx-bubble/tx-bubble.component.html new file mode 100644 index 000000000..612a6de6b --- /dev/null +++ b/frontend/src/app/tx-bubble/tx-bubble.component.html @@ -0,0 +1,26 @@ +
+ + + + + + + + + + + + + + +
Transaction hash{{ txIdShort }}
Fees:{{ tx?.fee }} BTC
Fee per vByte:{{ tx?.feePerVsize | number : '1.2-2' }} sat/vB
+
+ + + + + + + +
+
\ No newline at end of file diff --git a/frontend/src/app/tx-bubble/tx-bubble.component.scss b/frontend/src/app/tx-bubble/tx-bubble.component.scss new file mode 100644 index 000000000..4c759c4c1 --- /dev/null +++ b/frontend/src/app/tx-bubble/tx-bubble.component.scss @@ -0,0 +1,65 @@ +.txBubble { + position: relative; + display: inline-block; + border-bottom: 1px dotted #000000; + z-index: 99; +} + +.txBubble .txBubbleText { + width: 300px; + background-color: #ffffff; + color: #000; + text-align: center; + border-radius: 6px; + padding: 5px 0; + position: absolute; + z-index: 1; + top: 150%; + left: 50%; + margin-left: -100px; + padding: 10px; + font-size: 14px; +} + +.txBubble .txBubbleText::after { + content: ""; + position: absolute; + bottom: 100%; + left: 50%; + margin-left: -10px; + border-width: 10px; + border-style: solid; + border-color: transparent transparent white transparent; +} + +.txBubble .arrow-right.txBubbleText::after { + top: calc(50% - 10px); + border-color: transparent transparent transparent white; + right: -20px; + left: auto; +} + +.txBubble .arrow-left.txBubbleText::after { + top: calc(50% - 10px); + left: 0; + margin-left: -20px; + border-width: 10px; + border-color: transparent white transparent transparent; +} + +.txBubble .arrow-bottom.txBubbleText::after { + bottom: -20px; + left: 50%; + margin-left: -10px; + border-width: 10px; + border-style: solid; + border-color: white transparent transparent transparent; +} + +.txBubble .arrow-top-right.txBubbleText::after { + left: 80%; +} + +.txBubble .arrow-top-left.txBubbleText::after { + left: 20%; +} diff --git a/frontend/src/app/tx-bubble/tx-bubble.component.ts b/frontend/src/app/tx-bubble/tx-bubble.component.ts new file mode 100644 index 000000000..6e5b86c42 --- /dev/null +++ b/frontend/src/app/tx-bubble/tx-bubble.component.ts @@ -0,0 +1,26 @@ +import { Component, OnInit, Input, OnChanges } from '@angular/core'; +import { ITransaction } from '../blockchain/interfaces'; + +@Component({ + selector: 'app-tx-bubble', + templateUrl: './tx-bubble.component.html', + styleUrls: ['./tx-bubble.component.scss'] +}) +export class TxBubbleComponent implements OnChanges { + @Input() tx: ITransaction | null = null; + @Input() txTrackingBlockHeight = 0; + @Input() latestBlockHeight = 0; + @Input() arrowPosition: 'top' | 'right' | 'bottom' | 'top-right' | 'top-left' = 'top'; + + txIdShort = ''; + confirmations = 0; + + constructor() { } + + ngOnChanges() { + if (this.tx) { + this.txIdShort = this.tx.txid.substring(0, 6) + '...' + this.tx.txid.substring(this.tx.txid.length - 6); + } + this.confirmations = (this.latestBlockHeight - this.txTrackingBlockHeight) + 1; + } +} diff --git a/frontend/src/assets/.gitkeep b/frontend/src/assets/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/assets/btc-qr-code-segwit.png b/frontend/src/assets/btc-qr-code-segwit.png new file mode 100644 index 000000000..854de6e4d Binary files /dev/null and b/frontend/src/assets/btc-qr-code-segwit.png differ diff --git a/frontend/src/assets/btc-qr-code.png b/frontend/src/assets/btc-qr-code.png new file mode 100644 index 000000000..d9582cf81 Binary files /dev/null and b/frontend/src/assets/btc-qr-code.png differ diff --git a/frontend/src/assets/divider-new.png b/frontend/src/assets/divider-new.png new file mode 100644 index 000000000..6602f5ed2 Binary files /dev/null and b/frontend/src/assets/divider-new.png differ diff --git a/frontend/src/assets/favicon/android-icon-144x144.png b/frontend/src/assets/favicon/android-icon-144x144.png new file mode 100644 index 000000000..4ad06df19 Binary files /dev/null and b/frontend/src/assets/favicon/android-icon-144x144.png differ diff --git a/frontend/src/assets/favicon/android-icon-192x192.png b/frontend/src/assets/favicon/android-icon-192x192.png new file mode 100644 index 000000000..d66b5f206 Binary files /dev/null and b/frontend/src/assets/favicon/android-icon-192x192.png differ diff --git a/frontend/src/assets/favicon/android-icon-36x36.png b/frontend/src/assets/favicon/android-icon-36x36.png new file mode 100644 index 000000000..3f1fc5341 Binary files /dev/null and b/frontend/src/assets/favicon/android-icon-36x36.png differ diff --git a/frontend/src/assets/favicon/android-icon-48x48.png b/frontend/src/assets/favicon/android-icon-48x48.png new file mode 100644 index 000000000..00ada2b27 Binary files /dev/null and b/frontend/src/assets/favicon/android-icon-48x48.png differ diff --git a/frontend/src/assets/favicon/android-icon-72x72.png b/frontend/src/assets/favicon/android-icon-72x72.png new file mode 100644 index 000000000..1d414c558 Binary files /dev/null and b/frontend/src/assets/favicon/android-icon-72x72.png differ diff --git a/frontend/src/assets/favicon/android-icon-96x96.png b/frontend/src/assets/favicon/android-icon-96x96.png new file mode 100644 index 000000000..260a24d44 Binary files /dev/null and b/frontend/src/assets/favicon/android-icon-96x96.png differ diff --git a/frontend/src/assets/favicon/apple-icon-114x114.png b/frontend/src/assets/favicon/apple-icon-114x114.png new file mode 100644 index 000000000..c6797c37f Binary files /dev/null and b/frontend/src/assets/favicon/apple-icon-114x114.png differ diff --git a/frontend/src/assets/favicon/apple-icon-120x120.png b/frontend/src/assets/favicon/apple-icon-120x120.png new file mode 100644 index 000000000..734e9459e Binary files /dev/null and b/frontend/src/assets/favicon/apple-icon-120x120.png differ diff --git a/frontend/src/assets/favicon/apple-icon-144x144.png b/frontend/src/assets/favicon/apple-icon-144x144.png new file mode 100644 index 000000000..4ad06df19 Binary files /dev/null and b/frontend/src/assets/favicon/apple-icon-144x144.png differ diff --git a/frontend/src/assets/favicon/apple-icon-152x152.png b/frontend/src/assets/favicon/apple-icon-152x152.png new file mode 100644 index 000000000..455907c43 Binary files /dev/null and b/frontend/src/assets/favicon/apple-icon-152x152.png differ diff --git a/frontend/src/assets/favicon/apple-icon-180x180.png b/frontend/src/assets/favicon/apple-icon-180x180.png new file mode 100644 index 000000000..05322c739 Binary files /dev/null and b/frontend/src/assets/favicon/apple-icon-180x180.png differ diff --git a/frontend/src/assets/favicon/apple-icon-57x57.png b/frontend/src/assets/favicon/apple-icon-57x57.png new file mode 100644 index 000000000..ee8dded18 Binary files /dev/null and b/frontend/src/assets/favicon/apple-icon-57x57.png differ diff --git a/frontend/src/assets/favicon/apple-icon-60x60.png b/frontend/src/assets/favicon/apple-icon-60x60.png new file mode 100644 index 000000000..4d6e130bf Binary files /dev/null and b/frontend/src/assets/favicon/apple-icon-60x60.png differ diff --git a/frontend/src/assets/favicon/apple-icon-72x72.png b/frontend/src/assets/favicon/apple-icon-72x72.png new file mode 100644 index 000000000..1d414c558 Binary files /dev/null and b/frontend/src/assets/favicon/apple-icon-72x72.png differ diff --git a/frontend/src/assets/favicon/apple-icon-76x76.png b/frontend/src/assets/favicon/apple-icon-76x76.png new file mode 100644 index 000000000..e6bd962f2 Binary files /dev/null and b/frontend/src/assets/favicon/apple-icon-76x76.png differ diff --git a/frontend/src/assets/favicon/apple-icon-precomposed.png b/frontend/src/assets/favicon/apple-icon-precomposed.png new file mode 100644 index 000000000..fc019a5e0 Binary files /dev/null and b/frontend/src/assets/favicon/apple-icon-precomposed.png differ diff --git a/frontend/src/assets/favicon/apple-icon.png b/frontend/src/assets/favicon/apple-icon.png new file mode 100644 index 000000000..fc019a5e0 Binary files /dev/null and b/frontend/src/assets/favicon/apple-icon.png differ diff --git a/frontend/src/assets/favicon/browserconfig.xml b/frontend/src/assets/favicon/browserconfig.xml new file mode 100644 index 000000000..c55414822 --- /dev/null +++ b/frontend/src/assets/favicon/browserconfig.xml @@ -0,0 +1,2 @@ + +#ffffff \ No newline at end of file diff --git a/frontend/src/assets/favicon/favicon-16x16.png b/frontend/src/assets/favicon/favicon-16x16.png new file mode 100644 index 000000000..92688b187 Binary files /dev/null and b/frontend/src/assets/favicon/favicon-16x16.png differ diff --git a/frontend/src/assets/favicon/favicon-32x32.png b/frontend/src/assets/favicon/favicon-32x32.png new file mode 100644 index 000000000..c48348b5b Binary files /dev/null and b/frontend/src/assets/favicon/favicon-32x32.png differ diff --git a/frontend/src/assets/favicon/favicon-96x96.png b/frontend/src/assets/favicon/favicon-96x96.png new file mode 100644 index 000000000..260a24d44 Binary files /dev/null and b/frontend/src/assets/favicon/favicon-96x96.png differ diff --git a/frontend/src/assets/favicon/favicon.ico b/frontend/src/assets/favicon/favicon.ico new file mode 100644 index 000000000..1e15917e8 Binary files /dev/null and b/frontend/src/assets/favicon/favicon.ico differ diff --git a/frontend/src/assets/favicon/manifest.json b/frontend/src/assets/favicon/manifest.json new file mode 100644 index 000000000..013d4a6a5 --- /dev/null +++ b/frontend/src/assets/favicon/manifest.json @@ -0,0 +1,41 @@ +{ + "name": "App", + "icons": [ + { + "src": "\/android-icon-36x36.png", + "sizes": "36x36", + "type": "image\/png", + "density": "0.75" + }, + { + "src": "\/android-icon-48x48.png", + "sizes": "48x48", + "type": "image\/png", + "density": "1.0" + }, + { + "src": "\/android-icon-72x72.png", + "sizes": "72x72", + "type": "image\/png", + "density": "1.5" + }, + { + "src": "\/android-icon-96x96.png", + "sizes": "96x96", + "type": "image\/png", + "density": "2.0" + }, + { + "src": "\/android-icon-144x144.png", + "sizes": "144x144", + "type": "image\/png", + "density": "3.0" + }, + { + "src": "\/android-icon-192x192.png", + "sizes": "192x192", + "type": "image\/png", + "density": "4.0" + } + ] +} \ No newline at end of file diff --git a/frontend/src/assets/favicon/ms-icon-144x144.png b/frontend/src/assets/favicon/ms-icon-144x144.png new file mode 100644 index 000000000..4ad06df19 Binary files /dev/null and b/frontend/src/assets/favicon/ms-icon-144x144.png differ diff --git a/frontend/src/assets/favicon/ms-icon-150x150.png b/frontend/src/assets/favicon/ms-icon-150x150.png new file mode 100644 index 000000000..c51bab045 Binary files /dev/null and b/frontend/src/assets/favicon/ms-icon-150x150.png differ diff --git a/frontend/src/assets/favicon/ms-icon-310x310.png b/frontend/src/assets/favicon/ms-icon-310x310.png new file mode 100644 index 000000000..1e0876227 Binary files /dev/null and b/frontend/src/assets/favicon/ms-icon-310x310.png differ diff --git a/frontend/src/assets/favicon/ms-icon-70x70.png b/frontend/src/assets/favicon/ms-icon-70x70.png new file mode 100644 index 000000000..d5d467b0b Binary files /dev/null and b/frontend/src/assets/favicon/ms-icon-70x70.png differ diff --git a/frontend/src/assets/mempool-space-logo.png b/frontend/src/assets/mempool-space-logo.png new file mode 100644 index 000000000..306475ed1 Binary files /dev/null and b/frontend/src/assets/mempool-space-logo.png differ diff --git a/frontend/src/assets/mempool-tube.png b/frontend/src/assets/mempool-tube.png new file mode 100644 index 000000000..af2e58dac Binary files /dev/null and b/frontend/src/assets/mempool-tube.png differ diff --git a/frontend/src/assets/paynym-code.png b/frontend/src/assets/paynym-code.png new file mode 100644 index 000000000..28cae4a55 Binary files /dev/null and b/frontend/src/assets/paynym-code.png differ diff --git a/frontend/src/browserslist b/frontend/src/browserslist new file mode 100644 index 000000000..8e09ab492 --- /dev/null +++ b/frontend/src/browserslist @@ -0,0 +1,9 @@ +# This file is currently used by autoprefixer to adjust CSS to support the below specified browsers +# For additional information regarding the format and rule options, please see: +# https://github.com/browserslist/browserslist#queries +# For IE 9-11 support, please uncomment the last line of the file and adjust as needed +> 0.5% +last 2 versions +Firefox ESR +not dead +# IE 9-11 \ No newline at end of file diff --git a/frontend/src/environments/environment.prod.ts b/frontend/src/environments/environment.prod.ts new file mode 100644 index 000000000..3612073bc --- /dev/null +++ b/frontend/src/environments/environment.prod.ts @@ -0,0 +1,3 @@ +export const environment = { + production: true +}; diff --git a/frontend/src/environments/environment.ts b/frontend/src/environments/environment.ts new file mode 100644 index 000000000..012182efa --- /dev/null +++ b/frontend/src/environments/environment.ts @@ -0,0 +1,15 @@ +// This file can be replaced during build by using the `fileReplacements` array. +// `ng build ---prod` replaces `environment.ts` with `environment.prod.ts`. +// The list of file replacements can be found in `angular.json`. + +export const environment = { + production: false +}; + +/* + * In development mode, to ignore zone related error stack frames such as + * `zone.run`, `zoneDelegate.invokeTask` for easier debugging, you can + * import the following file, but please comment it out in production mode + * because it will have performance impact when throw error + */ +// import 'zone.js/dist/zone-error'; // Included with Angular CLI. diff --git a/frontend/src/favicon.ico b/frontend/src/favicon.ico new file mode 100644 index 000000000..1e15917e8 Binary files /dev/null and b/frontend/src/favicon.ico differ diff --git a/frontend/src/index.html b/frontend/src/index.html new file mode 100644 index 000000000..ea4b06957 --- /dev/null +++ b/frontend/src/index.html @@ -0,0 +1,35 @@ + + + + + mempool.space - Bitcoin mempool visualizer + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/karma.conf.js b/frontend/src/karma.conf.js new file mode 100644 index 000000000..b6e00421c --- /dev/null +++ b/frontend/src/karma.conf.js @@ -0,0 +1,31 @@ +// Karma configuration file, see link for more information +// https://karma-runner.github.io/1.0/config/configuration-file.html + +module.exports = function (config) { + config.set({ + basePath: '', + frameworks: ['jasmine', '@angular-devkit/build-angular'], + plugins: [ + require('karma-jasmine'), + require('karma-chrome-launcher'), + require('karma-jasmine-html-reporter'), + require('karma-coverage-istanbul-reporter'), + require('@angular-devkit/build-angular/plugins/karma') + ], + client: { + clearContext: false // leave Jasmine Spec Runner output visible in browser + }, + coverageIstanbulReporter: { + dir: require('path').join(__dirname, '../coverage'), + reports: ['html', 'lcovonly'], + fixWebpackSourcePaths: true + }, + reporters: ['progress', 'kjhtml'], + port: 9876, + colors: true, + logLevel: config.LOG_INFO, + autoWatch: true, + browsers: ['Chrome'], + singleRun: false + }); +}; \ No newline at end of file diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 000000000..91ec6da5f --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,12 @@ +import { enableProdMode } from '@angular/core'; +import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + +import { AppModule } from './app/app.module'; +import { environment } from './environments/environment'; + +if (environment.production) { + enableProdMode(); +} + +platformBrowserDynamic().bootstrapModule(AppModule) + .catch(err => console.log(err)); diff --git a/frontend/src/polyfills.ts b/frontend/src/polyfills.ts new file mode 100644 index 000000000..d310405a6 --- /dev/null +++ b/frontend/src/polyfills.ts @@ -0,0 +1,80 @@ +/** + * This file includes polyfills needed by Angular and is loaded before the app. + * You can add your own extra polyfills to this file. + * + * This file is divided into 2 sections: + * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. + * 2. Application imports. Files imported after ZoneJS that should be loaded before your main + * file. + * + * The current setup is for so-called "evergreen" browsers; the last versions of browsers that + * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), + * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. + * + * Learn more in https://angular.io/docs/ts/latest/guide/browser-support.html + */ + +/*************************************************************************************************** + * BROWSER POLYFILLS + */ + +/** IE9, IE10 and IE11 requires all of the following polyfills. **/ +// import 'core-js/es6/symbol'; +// import 'core-js/es6/object'; +// import 'core-js/es6/function'; +// import 'core-js/es6/parse-int'; +// import 'core-js/es6/parse-float'; +// import 'core-js/es6/number'; +// import 'core-js/es6/math'; +// import 'core-js/es6/string'; +// import 'core-js/es6/date'; +// import 'core-js/es6/array'; +// import 'core-js/es6/regexp'; +// import 'core-js/es6/map'; +// import 'core-js/es6/weak-map'; +// import 'core-js/es6/set'; + +/** IE10 and IE11 requires the following for NgClass support on SVG elements */ +// import 'classlist.js'; // Run `npm install --save classlist.js`. + +/** IE10 and IE11 requires the following for the Reflect API. */ +// import 'core-js/es6/reflect'; + + +/** Evergreen browsers require these. **/ +// Used for reflect-metadata in JIT. If you use AOT (and only Angular decorators), you can remove. +import 'core-js/es7/reflect'; + + +/** + * Web Animations `@angular/platform-browser/animations` + * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. + * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). + **/ +// import 'web-animations-js'; // Run `npm install --save web-animations-js`. + +/** + * By default, zone.js will patch all possible macroTask and DomEvents + * user can disable parts of macroTask/DomEvents patch by setting following flags + */ + + // (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame + // (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick + // (window as any).__zone_symbol__BLACK_LISTED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames + + /* + * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js + * with the following flag, it will bypass `zone.js` patch for IE/Edge + */ +// (window as any).__Zone_enable_cross_context_check = true; + +/*************************************************************************************************** + * Zone JS is required by default for Angular itself. + */ +import 'zone.js/dist/zone'; // Included with Angular CLI. + + + +/*************************************************************************************************** + * APPLICATION IMPORTS + */ diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss new file mode 100644 index 000000000..129ca1c2e --- /dev/null +++ b/frontend/src/styles.scss @@ -0,0 +1,131 @@ +/* You can add global styles to this file, and also import other style files */ + +$ct-series-names: (a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t, u, v, w, x, y, z); +$ct-series-colors: ( + #D81B60, + #8E24AA, + #5E35B1, + #3949AB, + #1E88E5, + #039BE5, + #00ACC1, + #00897B, + #43A047, + #7CB342, + #C0CA33, + #FDD835, + #FFB300, + #FB8C00, + #F4511E, + #6D4C41, + #757575, + #546E7A, + #b71c1c, + #880E4F, + #4A148C, + #311B92, + #1A237E, + #0D47A1, + #01579B, + #006064, + #004D40, + #1B5E20, + #33691E, + #827717, + #F57F17, + #FF6F00, + #E65100, + #BF360C, + #3E2723, + #212121, + #263238, + #a748ca, + #6188e2, + #a748ca, + #6188e2, + +); + + +$body-bg: #11131f; +$body-color: #fff; +$gray-800: #1d1f31; + +$primary: #2b89c7; + +$link-color: #1bd8f4; +$link-decoration: none !default; +$link-hover-color: darken($link-color, 15%) !default; +$link-hover-decoration: underline !default; + +// Required +@import "../node_modules/bootstrap/scss/bootstrap"; +@import "../node_modules/chartist/dist/scss/chartist.scss"; + +body { + margin-bottom: 60px; +} + +@media (min-width: 768px) { + body.disable-scroll { + overflow: hidden; + } +} + +.ng-invalid.ng-dirty { + border-color: #dc3545; +} + +.modal-content { + background-color: #11131f; +} + +.close { + color: #fff; +} + +.close:hover { + color: #fff; +} + + +.chartist-tooltip { + position: absolute; + display: inline-block; + opacity: 0; + min-width: 5em; + padding: .5em; + background: #F4C63D; + color: #453D3F; + font-family: Oxygen,Helvetica,Arial,sans-serif; + font-weight: 700; + text-align: center; + pointer-events: none; + z-index: 1; + -webkit-transition: opacity .2s linear; + -moz-transition: opacity .2s linear; + -o-transition: opacity .2s linear; + transition: opacity .2s linear; } + .chartist-tooltip:before { + content: ""; + position: absolute; + top: 100%; + left: 50%; + width: 0; + height: 0; + margin-left: -15px; + border: 15px solid transparent; + border-top-color: #F4C63D; } + .chartist-tooltip.tooltip-show { + opacity: 1; } + +.ct-area, .ct-line { + pointer-events: none; } + +.ct-bar { + stroke-width: 1px; +} + +hr { + border-top: 1px solid rgba(255, 255, 255, 0.1); +} \ No newline at end of file diff --git a/frontend/src/test.ts b/frontend/src/test.ts new file mode 100644 index 000000000..16317897b --- /dev/null +++ b/frontend/src/test.ts @@ -0,0 +1,20 @@ +// This file is required by karma.conf.js and loads recursively all the .spec and framework files + +import 'zone.js/dist/zone-testing'; +import { getTestBed } from '@angular/core/testing'; +import { + BrowserDynamicTestingModule, + platformBrowserDynamicTesting +} from '@angular/platform-browser-dynamic/testing'; + +declare const require: any; + +// First, initialize the Angular testing environment. +getTestBed().initTestEnvironment( + BrowserDynamicTestingModule, + platformBrowserDynamicTesting() +); +// Then we find all the tests. +const context = require.context('./', true, /\.spec\.ts$/); +// And load the modules. +context.keys().map(context); diff --git a/frontend/src/tsconfig.app.json b/frontend/src/tsconfig.app.json new file mode 100644 index 000000000..722c370d5 --- /dev/null +++ b/frontend/src/tsconfig.app.json @@ -0,0 +1,12 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "../out-tsc/app", + "module": "es2015", + "types": [] + }, + "exclude": [ + "src/test.ts", + "**/*.spec.ts" + ] +} diff --git a/frontend/src/tsconfig.spec.json b/frontend/src/tsconfig.spec.json new file mode 100644 index 000000000..8f7cedeca --- /dev/null +++ b/frontend/src/tsconfig.spec.json @@ -0,0 +1,19 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "../out-tsc/spec", + "module": "commonjs", + "types": [ + "jasmine", + "node" + ] + }, + "files": [ + "test.ts", + "polyfills.ts" + ], + "include": [ + "**/*.spec.ts", + "**/*.d.ts" + ] +} diff --git a/frontend/src/tslint.json b/frontend/src/tslint.json new file mode 100644 index 000000000..52e2c1a5a --- /dev/null +++ b/frontend/src/tslint.json @@ -0,0 +1,17 @@ +{ + "extends": "../tslint.json", + "rules": { + "directive-selector": [ + true, + "attribute", + "app", + "camelCase" + ], + "component-selector": [ + true, + "element", + "app", + "kebab-case" + ] + } +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 000000000..5ef11f65f --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist/out-tsc", + "sourceMap": true, + "strict": true, + "declaration": false, + "moduleResolution": "node", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "strictPropertyInitialization": false, + "target": "es5", + "typeRoots": [ + "node_modules/@types" + ], + "lib": [ + "es2018", + "dom" + ] + } +} diff --git a/frontend/tslint.json b/frontend/tslint.json new file mode 100644 index 000000000..3ea984c77 --- /dev/null +++ b/frontend/tslint.json @@ -0,0 +1,130 @@ +{ + "rulesDirectory": [ + "node_modules/codelyzer" + ], + "rules": { + "arrow-return-shorthand": true, + "callable-types": true, + "class-name": true, + "comment-format": [ + true, + "check-space" + ], + "curly": true, + "deprecation": { + "severity": "warn" + }, + "eofline": true, + "forin": true, + "import-blacklist": [ + true, + "rxjs/Rx" + ], + "import-spacing": true, + "indent": [ + true, + "spaces" + ], + "interface-over-type-literal": true, + "label-position": true, + "max-line-length": [ + true, + 140 + ], + "member-access": false, + "member-ordering": [ + true, + { + "order": [ + "static-field", + "instance-field", + "static-method", + "instance-method" + ] + } + ], + "no-arg": true, + "no-bitwise": true, + "no-console": [ + true, + "debug", + "info", + "time", + "timeEnd", + "trace" + ], + "no-construct": true, + "no-debugger": true, + "no-duplicate-super": true, + "no-empty": false, + "no-empty-interface": true, + "no-eval": true, + "no-inferrable-types": [ + true, + "ignore-params" + ], + "no-misused-new": true, + "no-non-null-assertion": true, + "no-shadowed-variable": true, + "no-string-literal": false, + "no-string-throw": true, + "no-switch-case-fall-through": true, + "no-trailing-whitespace": true, + "no-unnecessary-initializer": true, + "no-unused-expression": true, + "no-use-before-declare": true, + "no-var-keyword": true, + "object-literal-sort-keys": false, + "one-line": [ + true, + "check-open-brace", + "check-catch", + "check-else", + "check-whitespace" + ], + "prefer-const": true, + "quotemark": [ + true, + "single" + ], + "radix": true, + "semicolon": [ + true, + "always" + ], + "triple-equals": [ + true, + "allow-null-check" + ], + "typedef-whitespace": [ + true, + { + "call-signature": "nospace", + "index-signature": "nospace", + "parameter": "nospace", + "property-declaration": "nospace", + "variable-declaration": "nospace" + } + ], + "unified-signatures": true, + "variable-name": false, + "whitespace": [ + true, + "check-branch", + "check-decl", + "check-operator", + "check-separator", + "check-type" + ], + "no-output-on-prefix": true, + "use-input-property-decorator": true, + "use-output-property-decorator": true, + "use-host-property-decorator": true, + "no-input-rename": true, + "no-output-rename": true, + "use-life-cycle-interface": true, + "use-pipe-transform-interface": true, + "component-class-suffix": true, + "directive-class-suffix": true + } +} diff --git a/mariadb-structure.sql b/mariadb-structure.sql new file mode 100644 index 000000000..4d567ed91 --- /dev/null +++ b/mariadb-structure.sql @@ -0,0 +1,86 @@ +SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO"; +SET time_zone = "+00:00"; + +CREATE TABLE `blocks` ( + `height` int(11) NOT NULL, + `hash` varchar(65) NOT NULL, + `size` int(11) NOT NULL, + `weight` int(11) NOT NULL, + `minFee` int(11) NOT NULL, + `maxFee` int(11) NOT NULL, + `time` int(11) NOT NULL, + `fees` double NOT NULL, + `nTx` int(11) NOT NULL, + `medianFee` double NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE `statistics` ( + `id` int(11) NOT NULL, + `added` datetime NOT NULL, + `unconfirmed_transactions` int(11) UNSIGNED NOT NULL, + `tx_per_second` float UNSIGNED NOT NULL, + `vbytes_per_second` int(10) UNSIGNED NOT NULL, + `mempool_byte_weight` int(10) UNSIGNED NOT NULL, + `fee_data` longtext NOT NULL, + `total_fee` double UNSIGNED NOT NULL, + `vsize_1` int(11) NOT NULL, + `vsize_2` int(11) NOT NULL, + `vsize_3` int(11) NOT NULL, + `vsize_4` int(11) NOT NULL, + `vsize_5` int(11) NOT NULL, + `vsize_6` int(11) NOT NULL, + `vsize_8` int(11) NOT NULL, + `vsize_10` int(11) NOT NULL, + `vsize_12` int(11) NOT NULL, + `vsize_15` int(11) NOT NULL, + `vsize_20` int(11) NOT NULL, + `vsize_30` int(11) NOT NULL, + `vsize_40` int(11) NOT NULL, + `vsize_50` int(11) NOT NULL, + `vsize_60` int(11) NOT NULL, + `vsize_70` int(11) NOT NULL, + `vsize_80` int(11) NOT NULL, + `vsize_90` int(11) NOT NULL, + `vsize_100` int(11) NOT NULL, + `vsize_125` int(11) NOT NULL, + `vsize_150` int(11) NOT NULL, + `vsize_175` int(11) NOT NULL, + `vsize_200` int(11) NOT NULL, + `vsize_250` int(11) NOT NULL, + `vsize_300` int(11) NOT NULL, + `vsize_350` int(11) NOT NULL, + `vsize_400` int(11) NOT NULL, + `vsize_500` int(11) NOT NULL, + `vsize_600` int(11) NOT NULL, + `vsize_700` int(11) NOT NULL, + `vsize_800` int(11) NOT NULL, + `vsize_900` int(11) NOT NULL, + `vsize_1000` int(11) NOT NULL, + `vsize_1200` int(11) NOT NULL, + `vsize_1400` int(11) NOT NULL, + `vsize_1600` int(11) NOT NULL, + `vsize_1800` int(11) NOT NULL, + `vsize_2000` int(11) NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE `transactions` ( + `blockheight` int(11) NOT NULL, + `txid` varchar(65) NOT NULL, + `fee` double NOT NULL, + `feePerVsize` double NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + + +ALTER TABLE `blocks` + ADD PRIMARY KEY (`height`); + +ALTER TABLE `statistics` + ADD PRIMARY KEY (`id`); + +ALTER TABLE `transactions` + ADD PRIMARY KEY (`txid`), + ADD KEY `blockheight` (`blockheight`); + + +ALTER TABLE `statistics` + MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;