diff --git a/backend/src/api/bitcoin/bitcoin-api.ts b/backend/src/api/bitcoin/bitcoin-api.ts index 7b1fc161d..51ed99b6c 100644 --- a/backend/src/api/bitcoin/bitcoin-api.ts +++ b/backend/src/api/bitcoin/bitcoin-api.ts @@ -25,7 +25,7 @@ class BitcoinApi implements AbstractBitcoinApi { .then((transaction: IBitcoinApi.Transaction) => { if (skipConversion) { transaction.vout.forEach((vout) => { - vout.value = vout.value * 100000000; + vout.value = Math.round(vout.value * 100000000); }); return transaction; } @@ -143,7 +143,7 @@ class BitcoinApi implements AbstractBitcoinApi { esploraTransaction.vout = transaction.vout.map((vout) => { return { - value: vout.value * 100000000, + value: Math.round(vout.value * 100000000), scriptpubkey: vout.scriptPubKey.hex, scriptpubkey_address: vout.scriptPubKey && vout.scriptPubKey.address ? vout.scriptPubKey.address : vout.scriptPubKey.addresses ? vout.scriptPubKey.addresses[0] : '', @@ -236,7 +236,7 @@ class BitcoinApi implements AbstractBitcoinApi { } else { mempoolEntry = await this.$getMempoolEntry(transaction.txid); } - transaction.fee = mempoolEntry.fees.base * 100000000; + transaction.fee = Math.round(mempoolEntry.fees.base * 100000000); return transaction; } diff --git a/backend/src/api/blocks.ts b/backend/src/api/blocks.ts index 80e7a4e1f..1024107d0 100644 --- a/backend/src/api/blocks.ts +++ b/backend/src/api/blocks.ts @@ -23,6 +23,7 @@ class Blocks { private newBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => void)[] = []; private blockIndexingStarted = false; public blockIndexingCompleted = false; + public reindexFlag = true; // Always re-index the latest indexed data in case the node went offline with an invalid block tip (reorg) constructor() { } @@ -135,6 +136,12 @@ class Blocks { } else { pool = await poolsRepository.$getUnknownPool(); } + + if (!pool) { // Something is wrong with the pools table, ignore pool indexing + logger.err('Unable to find pool, nor getting the unknown pool. Is the "pools" table empty?'); + return blockExtended; + } + blockExtended.extras.pool = { id: pool.id, name: pool.name, @@ -183,16 +190,19 @@ class Blocks { * [INDEXING] Index all blocks metadata for the mining dashboard */ public async $generateBlockDatabase() { - if (this.blockIndexingStarted) { + if (this.blockIndexingStarted && !this.reindexFlag) { return; } + this.reindexFlag = false; + const blockchainInfo = await bitcoinClient.getBlockchainInfo(); if (blockchainInfo.blocks !== blockchainInfo.headers) { // Wait for node to sync return; } this.blockIndexingStarted = true; + this.blockIndexingCompleted = false; try { let currentBlockHeight = blockchainInfo.blocks; @@ -310,6 +320,12 @@ class Blocks { if (Common.indexingEnabled()) { await blocksRepository.$saveBlockInDatabase(blockExtended); + + // If the last 10 blocks chain is not valid, re-index them (reorg) + const chainValid = await blocksRepository.$validateRecentBlocks(); + if (!chainValid) { + this.reindexFlag = true; + } } if (block.height % 2016 === 0) { diff --git a/backend/src/api/mining.ts b/backend/src/api/mining.ts index db69b7621..6b7d2e01d 100644 --- a/backend/src/api/mining.ts +++ b/backend/src/api/mining.ts @@ -5,6 +5,7 @@ import HashratesRepository from '../repositories/HashratesRepository'; import bitcoinClient from './bitcoin/bitcoin-client'; import logger from '../logger'; import blocks from './blocks'; +import { Common } from './common'; class Mining { hashrateIndexingStarted = false; @@ -13,6 +14,26 @@ class Mining { constructor() { } + /** + * Get historical block reward and total fee + */ + public async $getHistoricalBlockFees(interval: string | null = null): Promise { + return await BlocksRepository.$getHistoricalBlockFees( + this.getTimeRange(interval), + Common.getSqlInterval(interval) + ); + } + + /** + * Get historical block rewards + */ + public async $getHistoricalBlockRewards(interval: string | null = null): Promise { + return await BlocksRepository.$getHistoricalBlockRewards( + this.getTimeRange(interval), + Common.getSqlInterval(interval) + ); + } + /** * Generate high level overview of the pool ranks and general stats */ @@ -45,8 +66,8 @@ class Mining { const blockCount: number = await BlocksRepository.$blockCount(null, interval); poolsStatistics['blockCount'] = blockCount; - const blockHeightTip = await bitcoinClient.getBlockCount(); - const lastBlockHashrate = await bitcoinClient.getNetworkHashPs(144, blockHeightTip); + const totalBlock24h: number = await BlocksRepository.$blockCount(null, '24h'); + const lastBlockHashrate = await bitcoinClient.getNetworkHashPs(totalBlock24h); poolsStatistics['lastEstimatedHashrate'] = lastBlockHashrate; return poolsStatistics; @@ -62,12 +83,30 @@ class Mining { } const blockCount: number = await BlocksRepository.$blockCount(pool.id); - const emptyBlocksCount = await BlocksRepository.$countEmptyBlocks(pool.id); + const totalBlock: number = await BlocksRepository.$blockCount(null, null); + + const blockCount24h: number = await BlocksRepository.$blockCount(pool.id, '24h'); + const totalBlock24h: number = await BlocksRepository.$blockCount(null, '24h'); + + const blockCount1w: number = await BlocksRepository.$blockCount(pool.id, '1w'); + const totalBlock1w: number = await BlocksRepository.$blockCount(null, '1w'); + + const currentEstimatedkHashrate = await bitcoinClient.getNetworkHashPs(totalBlock24h); return { pool: pool, - blockCount: blockCount, - emptyBlocks: emptyBlocksCount.length > 0 ? emptyBlocksCount[0]['count'] : 0, + blockCount: { + 'all': blockCount, + '24h': blockCount24h, + '1w': blockCount1w, + }, + blockShare: { + 'all': blockCount / totalBlock, + '24h': blockCount24h / totalBlock24h, + '1w': blockCount1w / totalBlock1w, + }, + estimatedHashrate: currentEstimatedkHashrate * (blockCount24h / totalBlock24h), + reportedHashrate: null, }; } @@ -303,6 +342,21 @@ class Mining { return date; } + + private getTimeRange(interval: string | null): number { + switch (interval) { + case '3y': return 43200; // 12h + case '2y': return 28800; // 8h + case '1y': return 28800; // 8h + case '6m': return 10800; // 3h + case '3m': return 7200; // 2h + case '1m': return 1800; // 30min + case '1w': return 300; // 5min + case '3d': return 1; + case '24h': return 1; + default: return 86400; // 24h + } + } } export default new Mining(); diff --git a/backend/src/api/pools-parser.ts b/backend/src/api/pools-parser.ts index 005806c1d..dee95912a 100644 --- a/backend/src/api/pools-parser.ts +++ b/backend/src/api/pools-parser.ts @@ -17,23 +17,11 @@ class PoolsParser { /** * Parse the pools.json file, consolidate the data and dump it into the database */ - public async migratePoolsJson() { + public async migratePoolsJson(poolsJson: object) { if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) === false) { return; } - logger.debug('Importing pools.json to the database, open ./pools.json'); - - let poolsJson: object = {}; - try { - const fileContent: string = readFileSync('./pools.json', 'utf8'); - poolsJson = JSON.parse(fileContent); - } catch (e) { - logger.err('Unable to open ./pools.json, does the file exist?'); - await this.insertUnknownPool(); - return; - } - // First we save every entries without paying attention to pool duplication const poolsDuplicated: Pool[] = []; diff --git a/backend/src/index.ts b/backend/src/index.ts index 008d987eb..c8e98c0b7 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -22,12 +22,13 @@ import loadingIndicators from './api/loading-indicators'; import mempool from './api/mempool'; import elementsParser from './api/liquid/elements-parser'; import databaseMigration from './api/database-migration'; -import poolsParser from './api/pools-parser'; import syncAssets from './sync-assets'; import icons from './api/liquid/icons'; import { Common } from './api/common'; import mining from './api/mining'; import HashratesRepository from './repositories/HashratesRepository'; +import BlocksRepository from './repositories/BlocksRepository'; +import poolsUpdater from './tasks/pools-updater'; class Server { private wss: WebSocket.Server | undefined; @@ -99,7 +100,6 @@ class Server { await databaseMigration.$initializeOrMigrateDatabase(); if (Common.indexingEnabled()) { await this.$resetHashratesIndexingState(); - await poolsParser.migratePoolsJson(); } } catch (e) { throw new Error(e instanceof Error ? e.message : 'Error'); @@ -179,6 +179,11 @@ class Server { } try { + await poolsUpdater.updatePoolsJson(); + if (blocks.reindexFlag) { + await BlocksRepository.$deleteBlocks(10); + await HashratesRepository.$deleteLastEntries(); + } blocks.$generateBlockDatabase(); await mining.$generateNetworkHashrateHistory(); await mining.$generatePoolHashrateHistory(); @@ -311,6 +316,8 @@ class Server { .get(config.MEMPOOL.API_URL_PREFIX + 'mining/hashrate', routes.$getHistoricalHashrate) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/hashrate/:interval', routes.$getHistoricalHashrate) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/reward-stats/:blockCount', routes.$getRewardStats) + .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/fees/:interval', routes.$getHistoricalBlockFees) + .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/rewards/:interval', routes.$getHistoricalBlockRewards) ; } diff --git a/backend/src/repositories/BlocksRepository.ts b/backend/src/repositories/BlocksRepository.ts index b426e77d2..2ec97ce88 100644 --- a/backend/src/repositories/BlocksRepository.ts +++ b/backend/src/repositories/BlocksRepository.ts @@ -10,9 +10,11 @@ class BlocksRepository { * Save indexed block data in the database */ public async $saveBlockInDatabase(block: BlockExtended) { - const connection = await DB.getConnection(); + let connection; try { + connection = await DB.getConnection(); + const query = `INSERT INTO blocks( height, hash, blockTimestamp, size, weight, tx_count, coinbase_raw, difficulty, @@ -72,8 +74,9 @@ class BlocksRepository { return []; } - const connection = await DB.getConnection(); + let connection; try { + connection = await DB.getConnection(); const [rows]: any[] = await connection.query(` SELECT height FROM blocks @@ -118,8 +121,9 @@ class BlocksRepository { query += ` GROUP by pools.id`; - const connection = await DB.getConnection(); + let connection; try { + connection = await DB.getConnection(); const [rows] = await connection.query(query, params); connection.release(); @@ -155,8 +159,9 @@ class BlocksRepository { query += ` blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`; } - const connection = await DB.getConnection(); + let connection; try { + connection = await DB.getConnection(); const [rows] = await connection.query(query, params); connection.release(); @@ -194,8 +199,9 @@ class BlocksRepository { } query += ` blockTimestamp BETWEEN FROM_UNIXTIME('${from}') AND FROM_UNIXTIME('${to}')`; - const connection = await DB.getConnection(); + let connection; try { + connection = await DB.getConnection(); const [rows] = await connection.query(query, params); connection.release(); @@ -216,8 +222,9 @@ class BlocksRepository { ORDER BY height LIMIT 1;`; - const connection = await DB.getConnection(); + let connection; try { + connection = await DB.getConnection(); const [rows]: any[] = await connection.query(query); connection.release(); @@ -257,8 +264,9 @@ class BlocksRepository { query += ` ORDER BY height DESC LIMIT 10`; - const connection = await DB.getConnection(); + let connection; try { + connection = await DB.getConnection(); const [rows] = await connection.query(query, params); connection.release(); @@ -279,8 +287,9 @@ class BlocksRepository { * Get one block by height */ public async $getBlockByHeight(height: number): Promise { - const connection = await DB.getConnection(); + let connection; try { + connection = await DB.getConnection(); const [rows]: any[] = await connection.query(` SELECT *, UNIX_TIMESTAMP(blocks.blockTimestamp) as blockTimestamp, pools.id as pool_id, pools.name as pool_name, pools.link as pool_link, pools.slug as pool_slug, @@ -310,8 +319,6 @@ class BlocksRepository { public async $getBlocksDifficulty(interval: string | null): Promise { interval = Common.getSqlInterval(interval); - const connection = await DB.getConnection(); - // :D ... Yeah don't ask me about this one https://stackoverflow.com/a/40303162 // Basically, using temporary user defined fields, we are able to extract all // difficulty adjustments from the blocks tables. @@ -344,14 +351,17 @@ class BlocksRepository { ORDER BY t.height `; + let connection; try { + connection = await DB.getConnection(); const [rows]: any[] = await connection.query(query); connection.release(); - for (let row of rows) { + for (const row of rows) { delete row['rn']; } + connection.release(); return rows; } catch (e) { connection.release(); @@ -360,23 +370,6 @@ class BlocksRepository { } } - /** - * Return oldest blocks height - */ - public async $getOldestIndexedBlockHeight(): Promise { - const connection = await DB.getConnection(); - try { - const [rows]: any[] = await connection.query(`SELECT MIN(height) as minHeight FROM blocks`); - connection.release(); - - return rows[0].minHeight; - } catch (e) { - connection.release(); - logger.err('$getOldestIndexedBlockHeight() error' + (e instanceof Error ? e.message : e)); - throw e; - } - } - /** * Get general block stats */ @@ -403,6 +396,107 @@ class BlocksRepository { throw e; } } + + /* + * Check if the last 10 blocks chain is valid + */ + public async $validateRecentBlocks(): Promise { + let connection; + + try { + connection = await DB.getConnection(); + const [lastBlocks] = await connection.query(`SELECT height, hash, previous_block_hash FROM blocks ORDER BY height DESC LIMIT 10`); + connection.release(); + + for (let i = 0; i < lastBlocks.length - 1; ++i) { + if (lastBlocks[i].previous_block_hash !== lastBlocks[i + 1].hash) { + logger.notice(`Chain divergence detected at block ${lastBlocks[i].height}, re-indexing most recent data`); + return false; + } + } + + return true; + } catch (e) { + connection.release(); + + return true; // Don't do anything if there is a db error + } + } + + /** + * Delete $count blocks from the database + */ + public async $deleteBlocks(count: number) { + let connection; + + try { + connection = await DB.getConnection(); + logger.debug(`Delete ${count} most recent indexed blocks from the database`); + await connection.query(`DELETE FROM blocks ORDER BY height DESC LIMIT ${count};`); + } catch (e) { + logger.err('$deleteBlocks() error' + (e instanceof Error ? e.message : e)); + } + + connection.release(); + } + + /** + * Get the historical averaged block reward and total fees + */ + public async $getHistoricalBlockFees(div: number, interval: string | null): Promise { + let connection; + try { + connection = await DB.getConnection(); + + let query = `SELECT CAST(AVG(UNIX_TIMESTAMP(blockTimestamp)) as INT) as timestamp, + CAST(AVG(fees) as INT) as avg_fees + FROM blocks`; + + if (interval !== null) { + query += ` WHERE blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`; + } + + query += ` GROUP BY UNIX_TIMESTAMP(blockTimestamp) DIV ${div}`; + + const [rows]: any = await connection.query(query); + connection.release(); + + return rows; + } catch (e) { + connection.release(); + logger.err('$getHistoricalBlockFees() error: ' + (e instanceof Error ? e.message : e)); + throw e; + } + } + + /** + * Get the historical averaged block rewards + */ + public async $getHistoricalBlockRewards(div: number, interval: string | null): Promise { + let connection; + try { + connection = await DB.getConnection(); + + let query = `SELECT CAST(AVG(UNIX_TIMESTAMP(blockTimestamp)) as INT) as timestamp, + CAST(AVG(reward) as INT) as avg_rewards + FROM blocks`; + + if (interval !== null) { + query += ` WHERE blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`; + } + + query += ` GROUP BY UNIX_TIMESTAMP(blockTimestamp) DIV ${div}`; + + const [rows]: any = await connection.query(query); + connection.release(); + + return rows; + } catch (e) { + connection.release(); + logger.err('$getHistoricalBlockRewards() error: ' + (e instanceof Error ? e.message : e)); + throw e; + } + } } export default new BlocksRepository(); diff --git a/backend/src/repositories/HashratesRepository.ts b/backend/src/repositories/HashratesRepository.ts index 5efce29fe..6f994342a 100644 --- a/backend/src/repositories/HashratesRepository.ts +++ b/backend/src/repositories/HashratesRepository.ts @@ -169,6 +169,9 @@ class HashratesRepository { } } + /** + * Set latest run timestamp + */ public async $setLatestRunTimestamp(key: string, val: any = null) { const connection = await DB.getConnection(); const query = `UPDATE state SET number = ? WHERE name = ?`; @@ -181,6 +184,9 @@ class HashratesRepository { } } + /** + * Get latest run timestamp + */ public async $getLatestRunTimestamp(key: string): Promise { const connection = await DB.getConnection(); const query = `SELECT number FROM state WHERE name = ?`; @@ -199,6 +205,29 @@ class HashratesRepository { throw e; } } + + /** + * Delete most recent data points for re-indexing + */ + public async $deleteLastEntries() { + logger.debug(`Delete latest hashrates data points from the database`); + + let connection; + try { + connection = await DB.getConnection(); + const [rows] = await connection.query(`SELECT MAX(hashrate_timestamp) as timestamp FROM hashrates GROUP BY type`); + for (const row of rows) { + await connection.query(`DELETE FROM hashrates WHERE hashrate_timestamp = ?`, [row.timestamp]); + } + // Re-run the hashrate indexing to fill up missing data + await this.$setLatestRunTimestamp('last_hashrates_indexing', 0); + await this.$setLatestRunTimestamp('last_weekly_hashrates_indexing', 0); + } catch (e) { + logger.err('$deleteLastEntries() error' + (e instanceof Error ? e.message : e)); + } + + connection.release(); + } } export default new HashratesRepository(); diff --git a/backend/src/routes.ts b/backend/src/routes.ts index d558e3061..9d4adb796 100644 --- a/backend/src/routes.ts +++ b/backend/src/routes.ts @@ -638,6 +638,38 @@ class Routes { } } + public async $getHistoricalBlockFees(req: Request, res: Response) { + try { + const blockFees = await mining.$getHistoricalBlockFees(req.params.interval ?? null); + const oldestIndexedBlockTimestamp = await BlocksRepository.$oldestBlockTimestamp(); + res.header('Pragma', 'public'); + res.header('Cache-control', 'public'); + res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString()); + res.json({ + oldestIndexedBlockTimestamp: oldestIndexedBlockTimestamp, + blockFees: blockFees, + }); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + + public async $getHistoricalBlockRewards(req: Request, res: Response) { + try { + const blockRewards = await mining.$getHistoricalBlockRewards(req.params.interval ?? null); + const oldestIndexedBlockTimestamp = await BlocksRepository.$oldestBlockTimestamp(); + res.header('Pragma', 'public'); + res.header('Cache-control', 'public'); + res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString()); + res.json({ + oldestIndexedBlockTimestamp: oldestIndexedBlockTimestamp, + blockRewards: blockRewards, + }); + } catch (e) { + res.status(500).send(e instanceof Error ? e.message : e); + } + } + public async getBlock(req: Request, res: Response) { try { const result = await bitcoinApi.$getBlock(req.params.hash); diff --git a/backend/src/tasks/pools-updater.ts b/backend/src/tasks/pools-updater.ts new file mode 100644 index 000000000..b3838244a --- /dev/null +++ b/backend/src/tasks/pools-updater.ts @@ -0,0 +1,148 @@ +const https = require('https'); +import poolsParser from "../api/pools-parser"; +import config from "../config"; +import { DB } from "../database"; +import logger from "../logger"; + +/** + * Maintain the most recent version of pools.json + */ +class PoolsUpdater { + lastRun: number = 0; + + constructor() { + } + + public async updatePoolsJson() { + if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) === false) { + return; + } + + const oneWeek = 604800; + const oneDay = 86400; + + const now = new Date().getTime() / 1000; + if (now - this.lastRun < oneWeek) { // Execute the PoolsUpdate only once a week, or upon restart + return; + } + + this.lastRun = now; + + try { + const dbSha = await this.getShaFromDb(); + const githubSha = await this.fetchPoolsSha(); // Fetch pools.json sha from github + if (githubSha === undefined) { + return; + } + + logger.debug(`Pools.json sha | Current: ${dbSha} | Github: ${githubSha}`); + if (dbSha !== undefined && dbSha === githubSha) { + return; + } + + logger.warn('Pools.json is outdated, fetch latest from github'); + const poolsJson = await this.fetchPools(); + await poolsParser.migratePoolsJson(poolsJson); + await this.updateDBSha(githubSha); + logger.notice('PoolsUpdater completed'); + + } catch (e) { + this.lastRun = now - (oneWeek - oneDay); // Try again in 24h instead of waiting next week + logger.err('PoolsUpdater failed. Will try again in 24h. Error: ' + e); + } + } + + /** + * Fetch pools.json from github repo + */ + private async fetchPools(): Promise { + const response = await this.query('/repos/mempool/mining-pools/contents/pools.json'); + return JSON.parse(Buffer.from(response['content'], 'base64').toString('utf8')); + } + + /** + * Fetch our latest pools.json sha from the db + */ + private async updateDBSha(githubSha: string) { + let connection; + try { + connection = await DB.getConnection(); + await connection.query('DELETE FROM state where name="pools_json_sha"'); + await connection.query(`INSERT INTO state VALUES('pools_json_sha', NULL, '${githubSha}')`); + connection.release(); + } catch (e) { + logger.err('Unable save github pools.json sha into the DB, error: ' + e); + connection.release(); + return undefined; + } + } + + /** + * Fetch our latest pools.json sha from the db + */ + private async getShaFromDb(): Promise { + let connection; + try { + connection = await DB.getConnection(); + const [rows] = await connection.query('SELECT string FROM state WHERE name="pools_json_sha"'); + connection.release(); + return (rows.length > 0 ? rows[0].string : undefined); + } catch (e) { + logger.err('Unable fetch pools.json sha from DB, error: ' + e); + connection.release(); + return undefined; + } + } + + /** + * Fetch our latest pools.json sha from github + */ + private async fetchPoolsSha(): Promise { + const response = await this.query('/repos/mempool/mining-pools/git/trees/master'); + + for (const file of response['tree']) { + if (file['path'] === 'pools.json') { + return file['sha']; + } + } + + logger.err('Unable to find latest pools.json sha from github'); + return undefined; + } + + /** + * Http request wrapper + */ + private query(path): Promise { + return new Promise((resolve, reject) => { + const options = { + host: 'api.github.com', + path: path, + method: 'GET', + headers: { 'user-agent': 'node.js' } + }; + + logger.debug('Querying: api.github.com' + path); + + const request = https.get(options, (response) => { + const chunks_of_data: any[] = []; + response.on('data', (fragments) => { + chunks_of_data.push(fragments); + }); + response.on('end', () => { + resolve(JSON.parse(Buffer.concat(chunks_of_data).toString())); + }); + response.on('error', (error) => { + reject(error); + }); + }); + + request.on('error', (error) => { + logger.err('Query failed with error: ' + error); + reject(error); + }) + }); + } +} + +export default new PoolsUpdater(); diff --git a/contributors/TechMiX.txt b/contributors/TechMiX.txt new file mode 100644 index 000000000..e6a382eae --- /dev/null +++ b/contributors/TechMiX.txt @@ -0,0 +1,3 @@ +I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of January 25, 2022. + +Signed: TechMiX diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index d46da5696..57f91b946 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -33,6 +33,8 @@ import { HashrateChartPoolsComponent } from './components/hashrates-chart-pools/ import { MiningStartComponent } from './components/mining-start/mining-start.component'; import { GraphsComponent } from './components/graphs/graphs.component'; import { BlocksList } from './components/blocks-list/blocks-list.component'; +import { BlockFeesGraphComponent } from './components/block-fees-graph/block-fees-graph.component'; +import { BlockRewardsGraphComponent } from './components/block-rewards-graph/block-rewards-graph.component'; let routes: Routes = [ { @@ -117,6 +119,14 @@ let routes: Routes = [ path: 'mining/pools', component: PoolRankingComponent, }, + { + path: 'mining/block-fees', + component: BlockFeesGraphComponent, + }, + { + path: 'mining/block-rewards', + component: BlockRewardsGraphComponent, + } ], }, { @@ -211,18 +221,6 @@ let routes: Routes = [ path: 'blocks', component: BlocksList, }, - { - path: 'hashrate', - component: HashrateChartComponent, - }, - { - path: 'hashrate/pools', - component: HashrateChartPoolsComponent, - }, - { - path: 'pools', - component: PoolRankingComponent, - }, { path: 'pool', children: [ @@ -259,6 +257,14 @@ let routes: Routes = [ path: 'mining/pools', component: PoolRankingComponent, }, + { + path: 'mining/block-fees', + component: BlockFeesGraphComponent, + }, + { + path: 'mining/block-rewards', + component: BlockRewardsGraphComponent, + } ] }, { @@ -347,18 +353,6 @@ let routes: Routes = [ path: 'blocks', component: BlocksList, }, - { - path: 'hashrate', - component: HashrateChartComponent, - }, - { - path: 'hashrate/pools', - component: HashrateChartPoolsComponent, - }, - { - path: 'pools', - component: PoolRankingComponent, - }, { path: 'pool', children: [ @@ -395,6 +389,14 @@ let routes: Routes = [ path: 'mining/pools', component: PoolRankingComponent, }, + { + path: 'mining/block-fees', + component: BlockFeesGraphComponent, + }, + { + path: 'mining/block-rewards', + component: BlockRewardsGraphComponent, + } ] }, { @@ -507,19 +509,7 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') { { path: 'mempool', component: StatisticsComponent, - }, - { - path: 'mining/hashrate-difficulty', - component: HashrateChartComponent, - }, - { - path: 'mining/pools-dominance', - component: HashrateChartPoolsComponent, - }, - { - path: 'mining/pools', - component: PoolRankingComponent, - }, + } ] }, { @@ -639,19 +629,7 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') { { path: 'mempool', component: StatisticsComponent, - }, - { - path: 'mining/hashrate-difficulty', - component: HashrateChartComponent, - }, - { - path: 'mining/pools-dominance', - component: HashrateChartPoolsComponent, - }, - { - path: 'mining/pools', - component: PoolRankingComponent, - }, + } ] }, { diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 807c88ade..470284591 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -80,6 +80,8 @@ import { DifficultyAdjustmentsTable } from './components/difficulty-adjustments- import { BlocksList } from './components/blocks-list/blocks-list.component'; import { RewardStatsComponent } from './components/reward-stats/reward-stats.component'; import { DataCyDirective } from './data-cy.directive'; +import { BlockFeesGraphComponent } from './components/block-fees-graph/block-fees-graph.component'; +import { BlockRewardsGraphComponent } from './components/block-rewards-graph/block-rewards-graph.component'; @NgModule({ declarations: [ @@ -141,6 +143,8 @@ import { DataCyDirective } from './data-cy.directive'; BlocksList, DataCyDirective, RewardStatsComponent, + BlockFeesGraphComponent, + BlockRewardsGraphComponent, ], imports: [ BrowserModule.withServerTransition({ appId: 'serverApp' }), diff --git a/frontend/src/app/components/address-labels/address-labels.component.ts b/frontend/src/app/components/address-labels/address-labels.component.ts index ee8e26de6..b22d66e42 100644 --- a/frontend/src/app/components/address-labels/address-labels.component.ts +++ b/frontend/src/app/components/address-labels/address-labels.component.ts @@ -52,7 +52,7 @@ export class AddressLabelsComponent implements OnInit { this.label = 'Lightning Force Close'; } return; - } else if (/^OP_DUP OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUAL OP_IF OP_CHECKSIG OP_ELSE OP_PUSHBYTES_33 \w{66} OP_SWAP OP_SIZE OP_PUSHBYTES_1 20 OP_EQUAL OP_NOTIF OP_DROP OP_PUSHNUM_2 OP_SWAP OP_PUSHBYTES_33 \w{66} OP_PUSHNUM_2 OP_CHECKMULTISIG OP_ELSE OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUALVERIFY OP_CHECKSIG OP_ENDIF (OP_PUSHNUM_1 OP_CHECKSEQUENCEVERIFY OP_DROP |)OP_ENDIF$/.test(this.vin.inner_witnessscript_asm)) { + } else if (/^OP_DUP OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUAL OP_IF OP_CHECKSIG OP_ELSE OP_PUSHBYTES_33 \w{66} OP_SWAP OP_SIZE OP_PUSHBYTES_1 20 OP_EQUAL OP_NOTIF OP_DROP OP_PUSHNUM_2 OP_SWAP OP_PUSHBYTES_33 \w{66} OP_PUSHNUM_2 OP_CHECKMULTISIG OP_ELSE OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUALVERIFY OP_CHECKSIG OP_ENDIF (OP_PUSHNUM_1 OP_CSV OP_DROP |)OP_ENDIF$/.test(this.vin.inner_witnessscript_asm)) { // https://github.com/lightning/bolts/blob/master/03-transactions.md#offered-htlc-outputs if (topElement.length === 66) { // top element is a public key diff --git a/frontend/src/app/components/address/address.component.html b/frontend/src/app/components/address/address.component.html index 0c030f5de..0ac64f86d 100644 --- a/frontend/src/app/components/address/address.component.html +++ b/frontend/src/app/components/address/address.component.html @@ -55,7 +55,7 @@
-

+

  {{ (transactions?.length | number) || '?' }} of {{ txCount | number }} transaction {{ (transactions?.length | number) || '?' }} of {{ txCount | number }} transactions diff --git a/frontend/src/app/components/block-fees-graph/block-fees-graph.component.html b/frontend/src/app/components/block-fees-graph/block-fees-graph.component.html new file mode 100644 index 000000000..fc811c5ea --- /dev/null +++ b/frontend/src/app/components/block-fees-graph/block-fees-graph.component.html @@ -0,0 +1,63 @@ +
+
+ Block fees +
+
+ + + + + + + + + + +
+
+
+ +
+
+
+
+
+ +
+ + +
+
+
Hashrate
+

+ +

+
+
+
Difficulty
+

+ +

+
+
+
\ No newline at end of file diff --git a/frontend/src/app/components/block-fees-graph/block-fees-graph.component.scss b/frontend/src/app/components/block-fees-graph/block-fees-graph.component.scss new file mode 100644 index 000000000..54dbe5fad --- /dev/null +++ b/frontend/src/app/components/block-fees-graph/block-fees-graph.component.scss @@ -0,0 +1,135 @@ +.card-header { + border-bottom: 0; + font-size: 18px; + @media (min-width: 465px) { + font-size: 20px; + } +} + +.main-title { + position: relative; + color: #ffffff91; + margin-top: -13px; + font-size: 10px; + text-transform: uppercase; + font-weight: 500; + text-align: center; + padding-bottom: 3px; +} + +.full-container { + padding: 0px 15px; + width: 100%; + min-height: 500px; + height: calc(100% - 150px); + @media (max-width: 992px) { + height: 100%; + padding-bottom: 100px; + }; +} + +.chart { + width: 100%; + height: 100%; + padding-bottom: 20px; + padding-right: 10px; + @media (max-width: 992px) { + padding-bottom: 25px; + } + @media (max-width: 829px) { + padding-bottom: 50px; + } + @media (max-width: 767px) { + padding-bottom: 25px; + } + @media (max-width: 629px) { + padding-bottom: 55px; + } + @media (max-width: 567px) { + padding-bottom: 55px; + } +} +.chart-widget { + width: 100%; + height: 100%; + max-height: 270px; +} + +.formRadioGroup { + margin-top: 6px; + display: flex; + flex-direction: column; + @media (min-width: 1130px) { + position: relative; + top: -65px; + } + @media (min-width: 830px) and (max-width: 1130px) { + position: relative; + top: 0px; + } + @media (min-width: 830px) { + flex-direction: row; + float: right; + margin-top: 0px; + } + .btn-sm { + font-size: 9px; + @media (min-width: 830px) { + font-size: 14px; + } + } +} + +.pool-distribution { + min-height: 56px; + display: block; + @media (min-width: 485px) { + display: flex; + flex-direction: row; + } + h5 { + margin-bottom: 10px; + } + .item { + width: 50%; + display: inline-block; + margin: 0px auto 20px; + &:nth-child(2) { + order: 2; + @media (min-width: 485px) { + order: 3; + } + } + &:nth-child(3) { + order: 3; + @media (min-width: 485px) { + order: 2; + display: block; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: block; + } + } + .card-title { + font-size: 1rem; + color: #4a68b9; + } + .card-text { + font-size: 18px; + span { + color: #ffffff66; + font-size: 12px; + } + } + } +} + +.skeleton-loader { + width: 100%; + display: block; + max-width: 80px; + margin: 15px auto 3px; +} diff --git a/frontend/src/app/components/block-fees-graph/block-fees-graph.component.ts b/frontend/src/app/components/block-fees-graph/block-fees-graph.component.ts new file mode 100644 index 000000000..74de3c317 --- /dev/null +++ b/frontend/src/app/components/block-fees-graph/block-fees-graph.component.ts @@ -0,0 +1,201 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnInit } from '@angular/core'; +import { EChartsOption, graphic } from 'echarts'; +import { Observable } from 'rxjs'; +import { map, share, startWith, switchMap, tap } from 'rxjs/operators'; +import { ApiService } from 'src/app/services/api.service'; +import { SeoService } from 'src/app/services/seo.service'; +import { formatNumber } from '@angular/common'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { formatterXAxisLabel } from 'src/app/shared/graphs.utils'; +import { StorageService } from 'src/app/services/storage.service'; +import { MiningService } from 'src/app/services/mining.service'; + +@Component({ + selector: 'app-block-fees-graph', + templateUrl: './block-fees-graph.component.html', + styleUrls: ['./block-fees-graph.component.scss'], + styles: [` + .loadingGraphs { + position: absolute; + top: 50%; + left: calc(50% - 15px); + z-index: 100; + } + `], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BlockFeesGraphComponent implements OnInit { + @Input() tableOnly = false; + @Input() right: number | string = 45; + @Input() left: number | string = 75; + + miningWindowPreference: string; + radioGroupForm: FormGroup; + + chartOptions: EChartsOption = {}; + chartInitOptions = { + renderer: 'svg', + }; + + statsObservable$: Observable; + isLoading = true; + formatNumber = formatNumber; + timespan = ''; + + constructor( + @Inject(LOCALE_ID) public locale: string, + private seoService: SeoService, + private apiService: ApiService, + private formBuilder: FormBuilder, + private storageService: StorageService, + private miningService: MiningService + ) { + this.radioGroupForm = this.formBuilder.group({ dateSpan: '1y' }); + this.radioGroupForm.controls.dateSpan.setValue('1y'); + } + + ngOnInit(): void { + this.seoService.setTitle($localize`:@@mining.block-fees:Block Fees`); + this.miningWindowPreference = this.miningService.getDefaultTimespan('24h'); + this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); + this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference); + + this.statsObservable$ = this.radioGroupForm.get('dateSpan').valueChanges + .pipe( + startWith(this.miningWindowPreference), + switchMap((timespan) => { + this.storageService.setValue('miningWindowPreference', timespan); + this.timespan = timespan; + this.isLoading = true; + return this.apiService.getHistoricalBlockFees$(timespan) + .pipe( + tap((data: any) => { + this.prepareChartOptions({ + blockFees: data.blockFees.map(val => [val.timestamp * 1000, val.avg_fees / 100000000]), + }); + this.isLoading = false; + }), + map((data: any) => { + const availableTimespanDay = ( + (new Date().getTime() / 1000) - (data.oldestIndexedBlockTimestamp) + ) / 3600 / 24; + + return { + availableTimespanDay: availableTimespanDay, + }; + }), + ); + }), + share() + ); + } + + prepareChartOptions(data) { + this.chartOptions = { + animation: false, + color: [ + new graphic.LinearGradient(0, 0, 0, 0.65, [ + { offset: 0, color: '#F4511E' }, + { offset: 0.25, color: '#FB8C00' }, + { offset: 0.5, color: '#FFB300' }, + { offset: 0.75, color: '#FDD835' }, + { offset: 1, color: '#7CB342' } + ]), + ], + grid: { + top: 30, + bottom: 80, + right: this.right, + left: this.left, + }, + tooltip: { + show: !this.isMobile(), + trigger: 'axis', + axisPointer: { + type: 'line' + }, + backgroundColor: 'rgba(17, 19, 31, 1)', + borderRadius: 4, + shadowColor: 'rgba(0, 0, 0, 0.5)', + textStyle: { + color: '#b1b1b1', + align: 'left', + }, + borderColor: '#000', + formatter: (ticks) => { + const tick = ticks[0]; + const feesString = `${tick.marker} ${tick.seriesName}: ${formatNumber(tick.data[1], this.locale, '1.3-3')} BTC`; + return ` + ${tick.axisValueLabel}
+ ${feesString} + `; + } + }, + xAxis: { + name: formatterXAxisLabel(this.locale, this.timespan), + nameLocation: 'middle', + nameTextStyle: { + padding: [10, 0, 0, 0], + }, + type: 'time', + splitNumber: this.isMobile() ? 5 : 10, + }, + yAxis: [ + { + type: 'value', + axisLabel: { + color: 'rgb(110, 112, 121)', + formatter: (val) => { + return `${val} BTC`; + } + }, + splitLine: { + show: false, + } + }, + ], + series: [ + { + zlevel: 0, + name: 'Fees', + showSymbol: false, + symbol: 'none', + data: data.blockFees, + type: 'line', + lineStyle: { + width: 2, + }, + }, + ], + dataZoom: [{ + type: 'inside', + realtime: true, + zoomLock: true, + maxSpan: 100, + minSpan: 10, + moveOnMouseMove: false, + }, { + showDetail: false, + show: true, + type: 'slider', + brushSelect: false, + realtime: true, + left: 20, + right: 15, + selectedDataBackground: { + lineStyle: { + color: '#fff', + opacity: 0.45, + }, + areaStyle: { + opacity: 0, + } + }, + }], + }; + } + + isMobile() { + return (window.innerWidth <= 767.98); + } +} diff --git a/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.html b/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.html new file mode 100644 index 000000000..c2a3bcf00 --- /dev/null +++ b/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.html @@ -0,0 +1,64 @@ +
+ +
+ Block rewards +
+
+ + + + + + + + + + +
+
+
+ +
+
+
+
+
+ +
+ + +
+
+
Hashrate
+

+ +

+
+
+
Difficulty
+

+ +

+
+
+
\ No newline at end of file diff --git a/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.scss b/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.scss new file mode 100644 index 000000000..54dbe5fad --- /dev/null +++ b/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.scss @@ -0,0 +1,135 @@ +.card-header { + border-bottom: 0; + font-size: 18px; + @media (min-width: 465px) { + font-size: 20px; + } +} + +.main-title { + position: relative; + color: #ffffff91; + margin-top: -13px; + font-size: 10px; + text-transform: uppercase; + font-weight: 500; + text-align: center; + padding-bottom: 3px; +} + +.full-container { + padding: 0px 15px; + width: 100%; + min-height: 500px; + height: calc(100% - 150px); + @media (max-width: 992px) { + height: 100%; + padding-bottom: 100px; + }; +} + +.chart { + width: 100%; + height: 100%; + padding-bottom: 20px; + padding-right: 10px; + @media (max-width: 992px) { + padding-bottom: 25px; + } + @media (max-width: 829px) { + padding-bottom: 50px; + } + @media (max-width: 767px) { + padding-bottom: 25px; + } + @media (max-width: 629px) { + padding-bottom: 55px; + } + @media (max-width: 567px) { + padding-bottom: 55px; + } +} +.chart-widget { + width: 100%; + height: 100%; + max-height: 270px; +} + +.formRadioGroup { + margin-top: 6px; + display: flex; + flex-direction: column; + @media (min-width: 1130px) { + position: relative; + top: -65px; + } + @media (min-width: 830px) and (max-width: 1130px) { + position: relative; + top: 0px; + } + @media (min-width: 830px) { + flex-direction: row; + float: right; + margin-top: 0px; + } + .btn-sm { + font-size: 9px; + @media (min-width: 830px) { + font-size: 14px; + } + } +} + +.pool-distribution { + min-height: 56px; + display: block; + @media (min-width: 485px) { + display: flex; + flex-direction: row; + } + h5 { + margin-bottom: 10px; + } + .item { + width: 50%; + display: inline-block; + margin: 0px auto 20px; + &:nth-child(2) { + order: 2; + @media (min-width: 485px) { + order: 3; + } + } + &:nth-child(3) { + order: 3; + @media (min-width: 485px) { + order: 2; + display: block; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: block; + } + } + .card-title { + font-size: 1rem; + color: #4a68b9; + } + .card-text { + font-size: 18px; + span { + color: #ffffff66; + font-size: 12px; + } + } + } +} + +.skeleton-loader { + width: 100%; + display: block; + max-width: 80px; + margin: 15px auto 3px; +} diff --git a/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.ts b/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.ts new file mode 100644 index 000000000..a22617922 --- /dev/null +++ b/frontend/src/app/components/block-rewards-graph/block-rewards-graph.component.ts @@ -0,0 +1,200 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnInit } from '@angular/core'; +import { EChartsOption, graphic } from 'echarts'; +import { Observable } from 'rxjs'; +import { map, share, startWith, switchMap, tap } from 'rxjs/operators'; +import { ApiService } from 'src/app/services/api.service'; +import { SeoService } from 'src/app/services/seo.service'; +import { formatNumber } from '@angular/common'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { formatterXAxisLabel } from 'src/app/shared/graphs.utils'; +import { MiningService } from 'src/app/services/mining.service'; +import { StorageService } from 'src/app/services/storage.service'; + +@Component({ + selector: 'app-block-rewards-graph', + templateUrl: './block-rewards-graph.component.html', + styleUrls: ['./block-rewards-graph.component.scss'], + styles: [` + .loadingGraphs { + position: absolute; + top: 50%; + left: calc(50% - 15px); + z-index: 100; + } + `], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BlockRewardsGraphComponent implements OnInit { + @Input() right: number | string = 45; + @Input() left: number | string = 75; + + miningWindowPreference: string; + radioGroupForm: FormGroup; + + chartOptions: EChartsOption = {}; + chartInitOptions = { + renderer: 'svg', + }; + + statsObservable$: Observable; + isLoading = true; + formatNumber = formatNumber; + timespan = ''; + + constructor( + @Inject(LOCALE_ID) public locale: string, + private seoService: SeoService, + private apiService: ApiService, + private formBuilder: FormBuilder, + private miningService: MiningService, + private storageService: StorageService + ) { + } + + ngOnInit(): void { + this.seoService.setTitle($localize`:@@mining.block-reward:Block Reward`); + this.miningWindowPreference = this.miningService.getDefaultTimespan('24h'); + this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); + this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference); + + this.statsObservable$ = this.radioGroupForm.get('dateSpan').valueChanges + .pipe( + startWith(this.miningWindowPreference), + switchMap((timespan) => { + this.storageService.setValue('miningWindowPreference', timespan); + this.timespan = timespan; + this.isLoading = true; + return this.apiService.getHistoricalBlockRewards$(timespan) + .pipe( + tap((data: any) => { + this.prepareChartOptions({ + blockRewards: data.blockRewards.map(val => [val.timestamp * 1000, val.avg_rewards / 100000000]), + }); + this.isLoading = false; + }), + map((data: any) => { + const availableTimespanDay = ( + (new Date().getTime() / 1000) - (data.oldestIndexedBlockTimestamp) + ) / 3600 / 24; + + return { + availableTimespanDay: availableTimespanDay, + }; + }), + ); + }), + share() + ); + } + + prepareChartOptions(data) { + this.chartOptions = { + animation: false, + color: [ + new graphic.LinearGradient(0, 0, 0, 0.65, [ + { offset: 0, color: '#F4511E' }, + { offset: 0.25, color: '#FB8C00' }, + { offset: 0.5, color: '#FFB300' }, + { offset: 0.75, color: '#FDD835' }, + { offset: 1, color: '#7CB342' } + ]), + ], + grid: { + top: 20, + bottom: 80, + right: this.right, + left: this.left, + }, + tooltip: { + show: !this.isMobile(), + trigger: 'axis', + axisPointer: { + type: 'line' + }, + backgroundColor: 'rgba(17, 19, 31, 1)', + borderRadius: 4, + shadowColor: 'rgba(0, 0, 0, 0.5)', + textStyle: { + color: '#b1b1b1', + align: 'left', + }, + borderColor: '#000', + formatter: (ticks) => { + const tick = ticks[0]; + const rewardsString = `${tick.marker} ${tick.seriesName}: ${formatNumber(tick.data[1], this.locale, '1.3-3')} BTC`; + return ` + ${tick.axisValueLabel}
+ ${rewardsString} + `; + } + }, + xAxis: { + name: formatterXAxisLabel(this.locale, this.timespan), + nameLocation: 'middle', + nameTextStyle: { + padding: [10, 0, 0, 0], + }, + type: 'time', + splitNumber: this.isMobile() ? 5 : 10, + }, + yAxis: [ + { + min: value => Math.round(10 * value.min * 0.99) / 10, + max: value => Math.round(10 * value.max * 1.01) / 10, + type: 'value', + axisLabel: { + color: 'rgb(110, 112, 121)', + formatter: (val) => { + return `${val} BTC`; + } + }, + splitLine: { + show: false, + } + }, + ], + series: [ + { + zlevel: 0, + name: 'Reward', + showSymbol: false, + symbol: 'none', + data: data.blockRewards, + type: 'line', + lineStyle: { + width: 2, + }, + }, + ], + dataZoom: [{ + type: 'inside', + realtime: true, + zoomLock: true, + maxSpan: 100, + minSpan: 10, + moveOnMouseMove: false, + }, { + showDetail: false, + show: true, + type: 'slider', + brushSelect: false, + realtime: true, + left: 20, + right: 15, + selectedDataBackground: { + lineStyle: { + color: '#fff', + opacity: 0.45, + }, + areaStyle: { + opacity: 0, + } + }, + }], + }; + } + + isMobile() { + return (window.innerWidth <= 767.98); + } +} diff --git a/frontend/src/app/components/block/block.component.html b/frontend/src/app/components/block/block.component.html index 8970bd372..8b511b30c 100644 --- a/frontend/src/app/components/block/block.component.html +++ b/frontend/src/app/components/block/block.component.html @@ -163,7 +163,7 @@

-

+

{{ i }} transaction {{ i }} transactions diff --git a/frontend/src/app/components/docs/docs.component.ts b/frontend/src/app/components/docs/docs.component.ts index 7ef6cade6..e2de9113d 100644 --- a/frontend/src/app/components/docs/docs.component.ts +++ b/frontend/src/app/components/docs/docs.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, HostBinding } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { Env, StateService } from 'src/app/services/state.service'; @@ -13,6 +13,8 @@ export class DocsComponent implements OnInit { env: Env; showWebSocketTab = true; + @HostBinding('attr.dir') dir = 'ltr'; + constructor( private route: ActivatedRoute, private stateService: StateService, diff --git a/frontend/src/app/components/graphs/graphs.component.html b/frontend/src/app/components/graphs/graphs.component.html index d5cc61e91..e3bdb0629 100644 --- a/frontend/src/app/components/graphs/graphs.component.html +++ b/frontend/src/app/components/graphs/graphs.component.html @@ -1,25 +1,31 @@ -

- + - \ No newline at end of file + diff --git a/frontend/src/app/components/graphs/graphs.component.scss b/frontend/src/app/components/graphs/graphs.component.scss index c4ca483bd..b952137b9 100644 --- a/frontend/src/app/components/graphs/graphs.component.scss +++ b/frontend/src/app/components/graphs/graphs.component.scss @@ -1,9 +1,6 @@ .menu { flex-grow: 1; - max-width: 600px; -} - -.menu-li { - flex-grow: 1; - text-align: center; + @media (min-width: 576px) { + max-width: 400px; + } } diff --git a/frontend/src/app/components/hashrate-chart/hashrate-chart.component.html b/frontend/src/app/components/hashrate-chart/hashrate-chart.component.html index 4e9c66495..93f17dcdf 100644 --- a/frontend/src/app/components/hashrate-chart/hashrate-chart.component.html +++ b/frontend/src/app/components/hashrate-chart/hashrate-chart.component.html @@ -19,26 +19,29 @@
- Hashrate & Difficulty -
+ Hashrate & Difficulty +
-
@@ -67,4 +70,4 @@

- \ No newline at end of file + diff --git a/frontend/src/app/components/hashrate-chart/hashrate-chart.component.scss b/frontend/src/app/components/hashrate-chart/hashrate-chart.component.scss index 54dbe5fad..86c1f8ec3 100644 --- a/frontend/src/app/components/hashrate-chart/hashrate-chart.component.scss +++ b/frontend/src/app/components/hashrate-chart/hashrate-chart.component.scss @@ -132,4 +132,4 @@ display: block; max-width: 80px; margin: 15px auto 3px; -} +} \ No newline at end of file diff --git a/frontend/src/app/components/hashrate-chart/hashrate-chart.component.ts b/frontend/src/app/components/hashrate-chart/hashrate-chart.component.ts index fd2a52b5e..4cac95557 100644 --- a/frontend/src/app/components/hashrate-chart/hashrate-chart.component.ts +++ b/frontend/src/app/components/hashrate-chart/hashrate-chart.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnInit, HostBinding } from '@angular/core'; import { EChartsOption, graphic } from 'echarts'; import { Observable } from 'rxjs'; import { delay, map, retryWhen, share, startWith, switchMap, tap } from 'rxjs/operators'; @@ -7,6 +7,8 @@ import { SeoService } from 'src/app/services/seo.service'; import { formatNumber } from '@angular/common'; import { FormBuilder, FormGroup } from '@angular/forms'; import { selectPowerOfTen } from 'src/app/bitcoin.utils'; +import { StorageService } from 'src/app/services/storage.service'; +import { MiningService } from 'src/app/services/mining.service'; @Component({ selector: 'app-hashrate-chart', @@ -28,6 +30,7 @@ export class HashrateChartComponent implements OnInit { @Input() right: number | string = 45; @Input() left: number | string = 75; + miningWindowPreference: string; radioGroupForm: FormGroup; chartOptions: EChartsOption = {}; @@ -35,6 +38,8 @@ export class HashrateChartComponent implements OnInit { renderer: 'svg', }; + @HostBinding('attr.dir') dir = 'ltr'; + hashrateObservable$: Observable; isLoading = true; formatNumber = formatNumber; @@ -45,20 +50,32 @@ export class HashrateChartComponent implements OnInit { private apiService: ApiService, private formBuilder: FormBuilder, private cd: ChangeDetectorRef, + private storageService: StorageService, + private miningService: MiningService ) { - this.radioGroupForm = this.formBuilder.group({ dateSpan: '1y' }); - this.radioGroupForm.controls.dateSpan.setValue('1y'); } ngOnInit(): void { - if (!this.widget) { + let firstRun = true; + + if (this.widget) { + this.miningWindowPreference = '1y'; + } else { this.seoService.setTitle($localize`:@@mining.hashrate-difficulty:Hashrate and Difficulty`); + this.miningWindowPreference = this.miningService.getDefaultTimespan('1m'); } + this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); + this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference); this.hashrateObservable$ = this.radioGroupForm.get('dateSpan').valueChanges .pipe( - startWith('1y'), + startWith(this.miningWindowPreference), switchMap((timespan) => { + if (!this.widget && !firstRun) { + this.storageService.setValue('miningWindowPreference', timespan); + } + firstRun = false; + this.miningWindowPreference = timespan; this.isLoading = true; return this.apiService.getHistoricalHashrate$(timespan) .pipe( @@ -210,6 +227,9 @@ export class HashrateChartComponent implements OnInit { xAxis: data.hashrates.length === 0 ? undefined : { type: 'time', splitNumber: (this.isMobile() || this.widget) ? 5 : 10, + axisLabel: { + hideOverlap: true, + } }, legend: (this.widget || data.hashrates.length === 0) ? undefined : { data: [ @@ -248,7 +268,7 @@ export class HashrateChartComponent implements OnInit { formatter: (val) => { const selectedPowerOfTen: any = selectPowerOfTen(val); const newVal = Math.round(val / selectedPowerOfTen.divider); - return `${newVal} ${selectedPowerOfTen.unit}H/s` + return `${newVal} ${selectedPowerOfTen.unit}H/s`; } }, splitLine: { @@ -276,6 +296,7 @@ export class HashrateChartComponent implements OnInit { ], series: data.hashrates.length === 0 ? [] : [ { + zlevel: 0, name: 'Hashrate', showSymbol: false, symbol: 'none', @@ -286,6 +307,7 @@ export class HashrateChartComponent implements OnInit { }, }, { + zlevel: 1, yAxisIndex: 1, name: 'Difficulty', showSymbol: false, diff --git a/frontend/src/app/components/hashrates-chart-pools/hashrate-chart-pools.component.html b/frontend/src/app/components/hashrates-chart-pools/hashrate-chart-pools.component.html index 1ee088c7e..f3d547dd6 100644 --- a/frontend/src/app/components/hashrates-chart-pools/hashrate-chart-pools.component.html +++ b/frontend/src/app/components/hashrates-chart-pools/hashrate-chart-pools.component.html @@ -1,32 +1,35 @@ -
+
-
- Mining pools dominance -
+
+ Mining pools dominance +
-
-
diff --git a/frontend/src/app/components/hashrates-chart-pools/hashrate-chart-pools.component.ts b/frontend/src/app/components/hashrates-chart-pools/hashrate-chart-pools.component.ts index abfa8f61d..85bea9840 100644 --- a/frontend/src/app/components/hashrates-chart-pools/hashrate-chart-pools.component.ts +++ b/frontend/src/app/components/hashrates-chart-pools/hashrate-chart-pools.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnInit, HostBinding } from '@angular/core'; import { EChartsOption } from 'echarts'; import { Observable } from 'rxjs'; import { delay, map, retryWhen, share, startWith, switchMap, tap } from 'rxjs/operators'; @@ -6,6 +6,8 @@ import { ApiService } from 'src/app/services/api.service'; import { SeoService } from 'src/app/services/seo.service'; import { FormBuilder, FormGroup } from '@angular/forms'; import { poolsColor } from 'src/app/app.constants'; +import { StorageService } from 'src/app/services/storage.service'; +import { MiningService } from 'src/app/services/mining.service'; @Component({ selector: 'app-hashrate-chart-pools', @@ -22,10 +24,10 @@ import { poolsColor } from 'src/app/app.constants'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class HashrateChartPoolsComponent implements OnInit { - @Input() widget = false; @Input() right: number | string = 45; @Input() left: number | string = 25; + miningWindowPreference: string; radioGroupForm: FormGroup; chartOptions: EChartsOption = {}; @@ -33,6 +35,8 @@ export class HashrateChartPoolsComponent implements OnInit { renderer: 'svg', }; + @HostBinding('attr.dir') dir = 'ltr'; + hashrateObservable$: Observable; isLoading = true; @@ -42,20 +46,29 @@ export class HashrateChartPoolsComponent implements OnInit { private apiService: ApiService, private formBuilder: FormBuilder, private cd: ChangeDetectorRef, + private storageService: StorageService, + private miningService: MiningService ) { this.radioGroupForm = this.formBuilder.group({ dateSpan: '1y' }); this.radioGroupForm.controls.dateSpan.setValue('1y'); } ngOnInit(): void { - if (!this.widget) { - this.seoService.setTitle($localize`:@@mining.pools-historical-dominance:Pools Historical Dominance`); - } + let firstRun = true; + + this.seoService.setTitle($localize`:@@mining.pools-historical-dominance:Pools Historical Dominance`); + this.miningWindowPreference = this.miningService.getDefaultTimespan('1m'); + this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); + this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference); this.hashrateObservable$ = this.radioGroupForm.get('dateSpan').valueChanges .pipe( - startWith('1y'), + startWith(this.miningWindowPreference), switchMap((timespan) => { + if (!firstRun) { + this.storageService.setValue('miningWindowPreference', timespan); + } + firstRun = false; this.isLoading = true; return this.apiService.getHistoricalPoolsHashrate$(timespan) .pipe( @@ -73,6 +86,7 @@ export class HashrateChartPoolsComponent implements OnInit { const legends = []; for (const name in grouped) { series.push({ + zlevel: 0, stack: 'Total', name: name, showSymbol: false, @@ -82,7 +96,7 @@ export class HashrateChartPoolsComponent implements OnInit { lineStyle: { width: 0 }, areaStyle: { opacity: 1 }, smooth: true, - color: poolsColor[name.replace(/[^a-zA-Z0-9]/g, "").toLowerCase()], + color: poolsColor[name.replace(/[^a-zA-Z0-9]/g, '').toLowerCase()], emphasis: { disabled: true, scale: false, @@ -155,11 +169,11 @@ export class HashrateChartPoolsComponent implements OnInit { grid: { right: this.right, left: this.left, - bottom: this.widget ? 30 : 70, - top: this.widget || this.isMobile() ? 10 : 50, + bottom: 70, + top: this.isMobile() ? 10 : 50, }, tooltip: { - show: !this.isMobile() || !this.widget, + show: !this.isMobile(), trigger: 'axis', axisPointer: { type: 'line' @@ -186,9 +200,12 @@ export class HashrateChartPoolsComponent implements OnInit { }, xAxis: data.series.length === 0 ? undefined : { type: 'time', - splitNumber: (this.isMobile() || this.widget) ? 5 : 10, + splitNumber: this.isMobile() ? 5 : 10, + axisLabel: { + hideOverlap: true, + } }, - legend: (this.isMobile() || this.widget || data.series.length === 0) ? undefined : { + legend: (this.isMobile() || data.series.length === 0) ? undefined : { data: data.legends }, yAxis: data.series.length === 0 ? undefined : { @@ -205,7 +222,7 @@ export class HashrateChartPoolsComponent implements OnInit { min: 0, }, series: data.series, - dataZoom: this.widget ? null : [{ + dataZoom: [{ type: 'inside', realtime: true, zoomLock: true, diff --git a/frontend/src/app/components/incoming-transactions-graph/incoming-transactions-graph.component.ts b/frontend/src/app/components/incoming-transactions-graph/incoming-transactions-graph.component.ts index af188bbb3..89a089f06 100644 --- a/frontend/src/app/components/incoming-transactions-graph/incoming-transactions-graph.component.ts +++ b/frontend/src/app/components/incoming-transactions-graph/incoming-transactions-graph.component.ts @@ -157,6 +157,7 @@ export class IncomingTransactionsGraphComponent implements OnInit, OnChanges { }, series: [ { + zlevel: 0, data: this.data.series[0], type: 'line', smooth: false, diff --git a/frontend/src/app/components/mempool-graph/mempool-graph.component.ts b/frontend/src/app/components/mempool-graph/mempool-graph.component.ts index f290d91ca..3b4e18fb4 100644 --- a/frontend/src/app/components/mempool-graph/mempool-graph.component.ts +++ b/frontend/src/app/components/mempool-graph/mempool-graph.component.ts @@ -122,6 +122,7 @@ export class MempoolGraphComponent implements OnInit, OnChanges { if (index >= this.feeLimitIndex) { newColors.push(this.chartColorsOrdered[index]); seriesGraph.push({ + zlevel: 0, name: this.feeLevelsOrdered[index], type: 'line', stack: 'fees', diff --git a/frontend/src/app/components/miner/miner.component.ts b/frontend/src/app/components/miner/miner.component.ts index dd3bc86d4..733204120 100644 --- a/frontend/src/app/components/miner/miner.component.ts +++ b/frontend/src/app/components/miner/miner.component.ts @@ -27,6 +27,11 @@ export class MinerComponent implements OnChanges { ngOnChanges() { this.miner = ''; + if (this.stateService.env.MINING_DASHBOARD) { + this.miner = 'Unknown'; + this.url = this.relativeUrlPipe.transform(`/mining/pool/unknown`); + this.target = ''; + } this.loading = true; this.findMinerFromCoinbase(); } diff --git a/frontend/src/app/components/pool-ranking/pool-ranking.component.html b/frontend/src/app/components/pool-ranking/pool-ranking.component.html index 1de95755c..3c038df80 100644 --- a/frontend/src/app/components/pool-ranking/pool-ranking.component.html +++ b/frontend/src/app/components/pool-ranking/pool-ranking.component.html @@ -55,7 +55,7 @@ -
@@ -79,7 +79,7 @@ Rank Pool - Hashrate + Hashrate Blocks Empty Blocks @@ -90,7 +90,7 @@ {{ pool.name }} - {{ pool.lastEstimatedHashrate }} {{ + {{ pool.lastEstimatedHashrate }} {{ miningStats.miningUnits.hashrateUnit }} {{ pool['blockText'] }} {{ pool.emptyBlocks }} ({{ pool.emptyBlockRatio }}%) @@ -99,7 +99,7 @@ All miners - {{ miningStats.lastEstimatedHashrate}} {{ + {{ miningStats.lastEstimatedHashrate}} {{ miningStats.miningUnits.hashrateUnit }} {{ miningStats.blockCount }} {{ miningStats.totalEmptyBlock }} ({{ miningStats.totalEmptyBlockRatio diff --git a/frontend/src/app/components/pool-ranking/pool-ranking.component.ts b/frontend/src/app/components/pool-ranking/pool-ranking.component.ts index 236ac3b2d..95c2be2b7 100644 --- a/frontend/src/app/components/pool-ranking/pool-ranking.component.ts +++ b/frontend/src/app/components/pool-ranking/pool-ranking.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, Input, NgZone, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Input, NgZone, OnInit, HostBinding } from '@angular/core'; import { FormBuilder, FormGroup } from '@angular/forms'; import { Router } from '@angular/router'; import { EChartsOption, PieSeriesOption } from 'echarts'; @@ -19,9 +19,9 @@ import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url. changeDetection: ChangeDetectionStrategy.OnPush, }) export class PoolRankingComponent implements OnInit { - @Input() widget: boolean = false; + @Input() widget = false; - poolsWindowPreference: string; + miningWindowPreference: string; radioGroupForm: FormGroup; isLoading = true; @@ -31,6 +31,8 @@ export class PoolRankingComponent implements OnInit { }; chartInstance: any = undefined; + @HostBinding('attr.dir') dir = 'ltr'; + miningStatsObservable$: Observable; constructor( @@ -46,13 +48,13 @@ export class PoolRankingComponent implements OnInit { ngOnInit(): void { if (this.widget) { - this.poolsWindowPreference = '1w'; + this.miningWindowPreference = '1w'; } else { this.seoService.setTitle($localize`:@@mining.mining-pools:Mining Pools`); - this.poolsWindowPreference = this.storageService.getValue('poolsWindowPreference') ? this.storageService.getValue('poolsWindowPreference') : '1w'; + this.miningWindowPreference = this.miningService.getDefaultTimespan('24h'); } - this.radioGroupForm = this.formBuilder.group({ dateSpan: this.poolsWindowPreference }); - this.radioGroupForm.controls.dateSpan.setValue(this.poolsWindowPreference); + this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); + this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference); // When... this.miningStatsObservable$ = combineLatest([ @@ -65,12 +67,12 @@ export class PoolRankingComponent implements OnInit { // ...or we change the timespan this.radioGroupForm.get('dateSpan').valueChanges .pipe( - startWith(this.poolsWindowPreference), // (trigger when the page loads) + startWith(this.miningWindowPreference), // (trigger when the page loads) tap((value) => { if (!this.widget) { - this.storageService.setValue('poolsWindowPreference', value); + this.storageService.setValue('miningWindowPreference', value); } - this.poolsWindowPreference = value; + this.miningWindowPreference = value; }) ) ]) @@ -78,7 +80,7 @@ export class PoolRankingComponent implements OnInit { .pipe( switchMap(() => { this.isLoading = true; - return this.miningService.getMiningStats(this.poolsWindowPreference) + return this.miningService.getMiningStats(this.miningWindowPreference) .pipe( catchError((e) => of(this.getEmptyMiningStat())) ); @@ -148,7 +150,7 @@ export class PoolRankingComponent implements OnInit { }, borderColor: '#000', formatter: () => { - if (this.poolsWindowPreference === '24h') { + if (this.miningWindowPreference === '24h') { return `${pool.name} (${pool.share}%)
` + pool.lastEstimatedHashrate.toString() + ' PH/s' + `
` + pool.blockCount.toString() + ` blocks`; @@ -184,7 +186,7 @@ export class PoolRankingComponent implements OnInit { }, borderColor: '#000', formatter: () => { - if (this.poolsWindowPreference === '24h') { + if (this.miningWindowPreference === '24h') { return `${'Other'} (${totalShareOther.toFixed(2)}%)
` + totalEstimatedHashrateOther.toString() + ' PH/s' + `
` + totalBlockOther.toString() + ` blocks`; @@ -212,6 +214,7 @@ export class PoolRankingComponent implements OnInit { }, series: [ { + zlevel: 0, minShowLabelAngle: 3.6, name: 'Mining pool', type: 'pie', diff --git a/frontend/src/app/components/pool/pool.component.html b/frontend/src/app/components/pool/pool.component.html index 962a3ba9f..c51360a2d 100644 --- a/frontend/src/app/components/pool/pool.component.html +++ b/frontend/src/app/components/pool/pool.component.html @@ -1,5 +1,6 @@
+
-
- +
+
- + - - - - - + + -
Tags - {{ poolStats.pool.regexes }} + +
{{ poolStats.pool.regexes }}
- Tags + Tags
{{ poolStats.pool.regexes }}
@@ -33,32 +33,35 @@
Addresses +
Addresses {{ poolStats.pool.addresses[0] }} - -
- {{ - address }}
+
+ +
Addresses
@@ -77,105 +80,198 @@
-
+
- - - - + + + + - + - - - - - + + + + + + + + + - + -
Mined Blocks{{ formatNumber(poolStats.blockCount, this.locale, '1.0-0') }}
Hashrate (24h) + + + + + + + + + + + + + + + +
Estimated + Reported + Luck
{{ poolStats.estimatedHashrate | amountShortener : 1 : 'H/s' }}{{ poolStats.reportedHashrate | amountShortener : 1 : 'H/s' }}{{ formatNumber(poolStats.luck, this.locale, '1.2-2') }}%
+
- Mined Blocks -
{{ formatNumber(poolStats.blockCount, this.locale, '1.0-0') }}
+
+ Hashrate (24h) + + + + + + + + + + + + + + + +
Estimated + Reported + Luck
{{ poolStats.estimatedHashrate | amountShortener : 1 : 'H/s' }}{{ poolStats.reportedHashrate | amountShortener : 1 : 'H/s' }}{{ formatNumber(poolStats.luck, this.locale, '1.2-2') }}%
Empty Blocks{{ formatNumber(poolStats.emptyBlocks, this.locale, '1.0-0') }}~~
Mined Blocks + + + + + + + + + + + + + +
24h1wAll
{{ formatNumber(poolStats.blockCount['24h'], this.locale, '1.0-0') }} ({{ formatNumber(100 * + poolStats.blockShare['24h'], this.locale, '1.0-0') }}%){{ formatNumber(poolStats.blockCount['1w'], this.locale, '1.0-0') }} ({{ formatNumber(100 * + poolStats.blockShare['1w'], this.locale, '1.0-0') }}%){{ formatNumber(poolStats.blockCount['all'], this.locale, '1.0-0') }} ({{ formatNumber(100 * + poolStats.blockShare['all'], this.locale, '1.0-0') }}%)
+
- Empty Blocks -
{{ formatNumber(poolStats.emptyBlocks, this.locale, '1.0-0') }}
+
+ Mined Blocks + + + + + + + + + + + + + +
24h1wAll
{{ formatNumber(poolStats.blockCount['24h'], this.locale, '1.0-0') }} ({{ formatNumber(100 * + poolStats.blockShare['24h'], this.locale, '1.0-0') }}%){{ formatNumber(poolStats.blockCount['1w'], this.locale, '1.0-0') }} ({{ formatNumber(100 * + poolStats.blockShare['1w'], this.locale, '1.0-0') }}%){{ formatNumber(poolStats.blockCount['all'], this.locale, '1.0-0') }} ({{ formatNumber(100 * + poolStats.blockShare['all'], this.locale, '1.0-0') }}%)
+
- ~ + ~
~
+
+ - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
HeightTimestampMined - Coinbase Tag - RewardFeesTxsSize
- {{ block.height - }} - - ‎{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }} - - - - - {{ block.extras.coinbaseRaw | hex2ascii }} - - - - - - - {{ block.tx_count | number }} - -
-
-
-
-
HeightTimestampMined + Coinbase Tag + RewardFeesTxsSize
+ {{ block.height + }} + + ‎{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }} + + + + + {{ block.extras.coinbaseRaw | hex2ascii }} + + + + + + + {{ block.tx_count | number }} + +
+
+
+
+
HeightTimestampMined + Coinbase Tag + RewardFeesTxsSize
@@ -209,6 +305,7 @@ +
@@ -220,18 +317,18 @@
-
- + +
+
- + - - - + - - - - - -
Tags +
@@ -243,71 +340,149 @@
Addresses +
+
+
+
~
Addresses
+
+
+
-
+
- - - + + + - + - - - - + + + - + -
Mined Blocks
Hashrate (24h) -
+ + + + + + + + + + + + + +
Estimated + Reported + Luck
+
+
+
+
+
+
- Mined Blocks -
-
-
+
+ Hashrate (24h) + + + + + + + + + + + + + +
Estimated + Reported + Luck
+
+
+
+
+
+
Empty Blocks
Mined Blocks -
+ + + + + + + + + + + + + +
24h1wAll
+
+
+
+
+
+
- Empty Blocks -
-
-
+
+ Mined Blocks + + + + + + + + + + + + + +
24h1wAll
+
+
+
+
+
+
+
diff --git a/frontend/src/app/components/pool/pool.component.scss b/frontend/src/app/components/pool/pool.component.scss index 60bc4ab7d..9103f38f5 100644 --- a/frontend/src/app/components/pool/pool.component.scss +++ b/frontend/src/app/components/pool/pool.component.scss @@ -36,6 +36,7 @@ @media (max-width: 768px) { margin-bottom: 10px; } + height: 400px; } div.scrollable { @@ -49,18 +50,28 @@ div.scrollable { .box { padding-bottom: 5px; + @media (min-width: 767.98px) { + min-height: 187px; + } } .label { - width: 30%; + width: 25%; + @media (min-width: 767.98px) { + vertical-align: middle; + } @media (max-width: 767.98px) { font-weight: bold; } } +.label.addresses { + vertical-align: top; + padding-top: 25px; +} .data { text-align: right; - padding-left: 25%; + padding-left: 5%; @media (max-width: 992px) { text-align: left; padding-left: 12px; @@ -114,10 +125,6 @@ div.scrollable { } } -.fees { - width: 0%; -} - .size { width: 12%; @media (max-width: 1000px) { @@ -146,6 +153,10 @@ div.scrollable { .skeleton-loader { max-width: 200px; } +.skeleton-loader.data { + max-width: 70px; +} + .loadingGraphs { position: absolute; @@ -159,8 +170,38 @@ div.scrollable { .small-button { height: 20px; - transform: translateY(-20px); font-size: 10px; padding-top: 0; padding-bottom: 0; + outline: none; + box-shadow: none; +} +.small-button.mobile { + transform: translateY(-20px); + @media (min-width: 767.98px) { + transform: translateY(-17px); + } +} + +.block-count-title { + color: #4a68b9; + font-size: 14px; + text-align: left; + @media (max-width: 767.98px) { + text-align: center; + } +} + +.table-data tr { + background-color: transparent; +} +.table-data td { + text-align: left; + @media (max-width: 767.98px) { + text-align: center; + } +} + +.taller-row { + height: 75px; } \ No newline at end of file diff --git a/frontend/src/app/components/pool/pool.component.ts b/frontend/src/app/components/pool/pool.component.ts index c41cb4971..4d41c2437 100644 --- a/frontend/src/app/components/pool/pool.component.ts +++ b/frontend/src/app/components/pool/pool.component.ts @@ -8,6 +8,7 @@ import { ApiService } from 'src/app/services/api.service'; import { StateService } from 'src/app/services/state.service'; import { selectPowerOfTen } from 'src/app/bitcoin.utils'; import { formatNumber } from '@angular/common'; +import { SeoService } from 'src/app/services/seo.service'; @Component({ selector: 'app-pool', @@ -41,6 +42,7 @@ export class PoolComponent implements OnInit { private apiService: ApiService, private route: ActivatedRoute, public stateService: StateService, + private seoService: SeoService, ) { } @@ -66,6 +68,7 @@ export class PoolComponent implements OnInit { this.loadMoreSubject.next(this.blocks[this.blocks.length - 1]?.height); }), map((poolStats) => { + this.seoService.setTitle(poolStats.pool.name); let regexes = '"'; for (const regex of poolStats.pool.regexes) { regexes += regex + '", "'; @@ -73,6 +76,10 @@ export class PoolComponent implements OnInit { poolStats.pool.regexes = regexes.slice(0, -3); poolStats.pool.addresses = poolStats.pool.addresses; + if (poolStats.reportedHashrate) { + poolStats.luck = poolStats.estimatedHashrate / poolStats.reportedHashrate * 100; + } + return Object.assign({ logo: `./resources/mining-pools/` + poolStats.pool.name.toLowerCase().replace(' ', '').replace('.', '') + '.svg' }, poolStats); @@ -97,7 +104,21 @@ export class PoolComponent implements OnInit { } prepareChartOptions(data) { + let title: object; + if (data.length === 0) { + title = { + textStyle: { + color: 'grey', + fontSize: 15 + }, + text: `No data`, + left: 'center', + top: 'center' + }; + } + this.chartOptions = { + title: title, animation: false, color: [ new graphic.LinearGradient(0, 0, 0, 0.65, [ @@ -146,6 +167,9 @@ export class PoolComponent implements OnInit { xAxis: { type: 'time', splitNumber: (this.isMobile()) ? 5 : 10, + axisLabel: { + hideOverlap: true, + } }, yAxis: [ { @@ -168,6 +192,7 @@ export class PoolComponent implements OnInit { ], series: [ { + zlevel: 0, name: 'Hashrate', showSymbol: false, symbol: 'none', @@ -178,7 +203,7 @@ export class PoolComponent implements OnInit { }, }, ], - dataZoom: [{ + dataZoom: data.length === 0 ? undefined : [{ type: 'inside', realtime: true, zoomLock: true, diff --git a/frontend/src/app/components/privacy-policy/privacy-policy.component.html b/frontend/src/app/components/privacy-policy/privacy-policy.component.html index de2ec69ba..7df3db8c1 100644 --- a/frontend/src/app/components/privacy-policy/privacy-policy.component.html +++ b/frontend/src/app/components/privacy-policy/privacy-policy.component.html @@ -11,7 +11,7 @@
-

The mempool.space website, the liquid.network website, the bisq.markets website, their associated API services, and related network and server infrastructure (collectively, the "Website") are operated by Mempool Space K.K. in Japan ("Mempool", "We", or "Us") and self-hosted from AS142052.

+

The mempool.space website, the liquid.network website, the bisq.markets website, their associated API services, and related network and server infrastructure (collectively, the "Website") are operated by Mempool Space K.K. in Japan ("Mempool", "We", or "Us") and self-hosted from AS142052.

This website and its API service (collectively, the "Website") are operated by a member of the Bitcoin community ("We" or "Us"). Mempool Space K.K. in Japan ("Mempool") has no affiliation with the operator of this Website, and does not sponsor or endorse the information provided herein.

diff --git a/frontend/src/app/components/terms-of-service/terms-of-service.component.html b/frontend/src/app/components/terms-of-service/terms-of-service.component.html index 44643c855..35a6413bd 100644 --- a/frontend/src/app/components/terms-of-service/terms-of-service.component.html +++ b/frontend/src/app/components/terms-of-service/terms-of-service.component.html @@ -11,7 +11,7 @@
-

The mempool.space website, the liquid.network website, the bisq.markets website, their associated API services, and related network and server infrastructure (collectively, the "Website") are operated by Mempool Space K.K. in Japan ("Mempool", "We", or "Us") and self-hosted from AS142052.

+

The mempool.space website, the liquid.network website, the bisq.markets website, their associated API services, and related network and server infrastructure (collectively, the "Website") are operated by Mempool Space K.K. in Japan ("Mempool", "We", or "Us") and self-hosted from AS142052.

This website and its API service (collectively, the "Website") are operated by a member of the Bitcoin community ("We" or "Us"). Mempool Space K.K. in Japan ("Mempool") has no affiliation with the operator of this Website, and does not sponsor or endorse the information provided herein.

diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html index a0c92cbb4..1b6844cda 100644 --- a/frontend/src/app/components/transaction/transaction.component.html +++ b/frontend/src/app/components/transaction/transaction.component.html @@ -200,7 +200,7 @@ -
+

Details

diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index bcda5ff4c..4998a0d70 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -93,8 +93,19 @@ export interface PoolInfo { } export interface PoolStat { pool: PoolInfo; - blockCount: number; - emptyBlocks: number; + blockCount: { + all: number, + '24h': number, + '1w': number, + }; + blockShare: { + all: number, + '24h': number, + '1w': number, + }; + estimatedHashrate: number; + reportedHashrate: number; + luck?: number; } export interface BlockExtension { diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index 9efe9f782..16a8d21d5 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -168,6 +168,20 @@ export class ApiService { ); } + getHistoricalBlockFees$(interval: string | undefined) : Observable { + return this.httpClient.get( + this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/blocks/fees` + + (interval !== undefined ? `/${interval}` : '') + ); + } + + getHistoricalBlockRewards$(interval: string | undefined) : Observable { + return this.httpClient.get( + this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/blocks/rewards` + + (interval !== undefined ? `/${interval}` : '') + ); + } + getRewardStats$(blockCount: number = 144): Observable { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/reward-stats/${blockCount}`); } diff --git a/frontend/src/app/services/mining.service.ts b/frontend/src/app/services/mining.service.ts index 68f7e9da1..0480b09cd 100644 --- a/frontend/src/app/services/mining.service.ts +++ b/frontend/src/app/services/mining.service.ts @@ -4,6 +4,7 @@ import { map } from 'rxjs/operators'; import { PoolsStats, SinglePoolStats } from '../interfaces/node-api.interface'; import { ApiService } from '../services/api.service'; import { StateService } from './state.service'; +import { StorageService } from './storage.service'; export interface MiningUnits { hashrateDivider: number; @@ -28,8 +29,12 @@ export class MiningService { constructor( private stateService: StateService, private apiService: ApiService, + private storageService: StorageService, ) { } + /** + * Generate pool ranking stats + */ public getMiningStats(interval: string): Observable { return this.apiService.listPools$(interval).pipe( map(pools => this.generateMiningStats(pools)) @@ -63,6 +68,20 @@ export class MiningService { }; } + /** + * Get the default selection timespan, cap with `min` + */ + public getDefaultTimespan(min: string): string { + const timespans = [ + '24h', '3d', '1w', '1m', '3m', '6m', '1y', '2y', '3y', 'all' + ]; + const preference = this.storageService.getValue('miningWindowPreference') ?? '1w'; + if (timespans.indexOf(preference) < timespans.indexOf(min)) { + return min; + } + return preference; + } + private generateMiningStats(stats: PoolsStats): MiningStats { const miningUnits = this.getMiningUnits(); const hashrateDivider = miningUnits.hashrateDivider; diff --git a/frontend/src/app/services/storage.service.ts b/frontend/src/app/services/storage.service.ts index aa848a21c..f3ea694b2 100644 --- a/frontend/src/app/services/storage.service.ts +++ b/frontend/src/app/services/storage.service.ts @@ -7,21 +7,21 @@ import { Router, ActivatedRoute } from '@angular/router'; export class StorageService { constructor(private router: Router, private route: ActivatedRoute) { this.setDefaultValueIfNeeded('graphWindowPreference', '2h'); - this.setDefaultValueIfNeeded('poolsWindowPreference', '1w'); + this.setDefaultValueIfNeeded('miningWindowPreference', '1w'); } setDefaultValueIfNeeded(key: string, defaultValue: string) { - let graphWindowPreference: string = this.getValue(key); + const graphWindowPreference: string = this.getValue(key); if (graphWindowPreference === null) { // First visit to mempool.space if (this.router.url.includes('graphs') && key === 'graphWindowPreference' || - this.router.url.includes('pools') && key === 'poolsWindowPreference' + this.router.url.includes('pools') && key === 'miningWindowPreference' ) { this.setValue(key, this.route.snapshot.fragment ? this.route.snapshot.fragment : defaultValue); } else { this.setValue(key, defaultValue); } } else if (this.router.url.includes('graphs') && key === 'graphWindowPreference' || - this.router.url.includes('pools') && key === 'poolsWindowPreference' + this.router.url.includes('pools') && key === 'miningWindowPreference' ) { // Visit a different graphs#fragment from last visit if (this.route.snapshot.fragment !== null && graphWindowPreference !== this.route.snapshot.fragment) { diff --git a/frontend/src/app/shared/pipes/amount-shortener.pipe.ts b/frontend/src/app/shared/pipes/amount-shortener.pipe.ts index 319dc2a5a..a31a5712e 100644 --- a/frontend/src/app/shared/pipes/amount-shortener.pipe.ts +++ b/frontend/src/app/shared/pipes/amount-shortener.pipe.ts @@ -4,8 +4,9 @@ import { Pipe, PipeTransform } from '@angular/core'; name: 'amountShortener' }) export class AmountShortenerPipe implements PipeTransform { - transform(num: number, ...args: number[]): unknown { + transform(num: number, ...args: any[]): unknown { const digits = args[0] || 1; + const unit = args[1] || undefined; if (num < 1000) { return num.toFixed(digits); @@ -21,7 +22,12 @@ export class AmountShortenerPipe implements PipeTransform { { value: 1e18, symbol: 'E' } ]; const rx = /\.0+$|(\.[0-9]*[1-9])0+$/; - var item = lookup.slice().reverse().find((item) => num >= item.value); - return item ? (num / item.value).toFixed(digits).replace(rx, '$1') + item.symbol : '0'; + const item = lookup.slice().reverse().find((item) => num >= item.value); + + if (unit !== undefined) { + return item ? (num / item.value).toFixed(digits).replace(rx, '$1') + ' ' + item.symbol + unit : '0'; + } else { + return item ? (num / item.value).toFixed(digits).replace(rx, '$1') + item.symbol : '0'; + } } } \ No newline at end of file diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index 09c885987..aee1456e4 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -698,6 +698,16 @@ th { margin-right: 0px; text-align: right; } + + .nav-pills { + @extend .nav-pills; + display: inline-block; + } + + .description { + direction: rtl; + } + .dropdown { margin-right: 1rem; margin-left: 0; @@ -712,12 +722,29 @@ th { left: 0px; right: auto; } - .fa-arrow-alt-circle-right { - @extend .fa-arrow-alt-circle-right; + .fa-circle-right { + @extend .fa-circle-right; -webkit-transform: scaleX(-1); transform: scaleX(-1); } + .btn.ml-2 { + margin-right: 0.5rem !important; + } + + .pool-name { + @extend .pool-name; + padding-right: 10px; + } + + .endpoint-container { + @extend .endpoint-container; + .section-header { + @extend .section-header; + text-align: left; + } + } + .table td { text-align: right; .fiat { @@ -809,6 +836,14 @@ th { } } + .full-container { + @extend .full-container; + .formRadioGroup { + @extend .formRadioGroup; + direction: ltr; + } + } + .mempool-graph { @extend .mempool-graph; direction: ltr; diff --git a/production/nginx-cache-warmer b/production/nginx-cache-warmer index 1720c3604..3c3204493 100755 --- a/production/nginx-cache-warmer +++ b/production/nginx-cache-warmer @@ -37,6 +37,26 @@ do for url in / \ '/api/v1/mining/hashrate/pools/all' \ '/api/v1/mining/reward-stats/144' \ '/api/v1/mining/blocks-extras' \ + '/api/v1/mining/blocks/fees/24h' \ + '/api/v1/mining/blocks/fees/3d' \ + '/api/v1/mining/blocks/fees/1w' \ + '/api/v1/mining/blocks/fees/1m' \ + '/api/v1/mining/blocks/fees/3m' \ + '/api/v1/mining/blocks/fees/6m' \ + '/api/v1/mining/blocks/fees/1y' \ + '/api/v1/mining/blocks/fees/2y' \ + '/api/v1/mining/blocks/fees/3y' \ + '/api/v1/mining/blocks/fees/all' \ + '/api/v1/mining/blocks/rewards/24h' \ + '/api/v1/mining/blocks/rewards/3d' \ + '/api/v1/mining/blocks/rewards/1w' \ + '/api/v1/mining/blocks/rewards/1m' \ + '/api/v1/mining/blocks/rewards/3m' \ + '/api/v1/mining/blocks/rewards/6m' \ + '/api/v1/mining/blocks/rewards/1y' \ + '/api/v1/mining/blocks/rewards/2y' \ + '/api/v1/mining/blocks/rewards/3y' \ + '/api/v1/mining/blocks/rewards/all' \ do curl -s "https://${hostname}${url}" >/dev/null