diff --git a/backend/src/api/blocks.ts b/backend/src/api/blocks.ts index 1452b6fc8..de461e095 100644 --- a/backend/src/api/blocks.ts +++ b/backend/src/api/blocks.ts @@ -20,6 +20,7 @@ class Blocks { private previousDifficultyRetarget = 0; private newBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => void)[] = []; private blockIndexingStarted = false; + public blockIndexingCompleted = false; constructor() { } @@ -170,10 +171,7 @@ class Blocks { * Index all blocks metadata for the mining dashboard */ public async $generateBlockDatabase() { - if (this.blockIndexingStarted === true || - !Common.indexingEnabled() || - memPool.hasPriority() - ) { + if (this.blockIndexingStarted) { return; } @@ -243,6 +241,8 @@ class Blocks { logger.err('An error occured in $generateBlockDatabase(). Skipping block indexing. ' + e); console.log(e); } + + this.blockIndexingCompleted = true; } public async $updateBlocks() { diff --git a/backend/src/api/database-migration.ts b/backend/src/api/database-migration.ts index b44585580..179c56110 100644 --- a/backend/src/api/database-migration.ts +++ b/backend/src/api/database-migration.ts @@ -6,7 +6,7 @@ import logger from '../logger'; const sleep = (ms: number) => new Promise(res => setTimeout(res, ms)); class DatabaseMigration { - private static currentVersion = 6; + private static currentVersion = 7; private queryTimeout = 120000; private statisticsAddedIndexed = false; @@ -15,13 +15,13 @@ class DatabaseMigration { * Entry point */ public async $initializeOrMigrateDatabase(): Promise { - logger.info('MIGRATIONS: Running migrations'); + logger.debug('MIGRATIONS: Running migrations'); await this.$printDatabaseVersion(); // First of all, if the `state` database does not exist, create it so we can track migration version if (!await this.$checkIfTableExists('state')) { - logger.info('MIGRATIONS: `state` table does not exist. Creating it.'); + logger.debug('MIGRATIONS: `state` table does not exist. Creating it.'); try { await this.$createMigrationStateTable(); } catch (e) { @@ -29,7 +29,7 @@ class DatabaseMigration { await sleep(10000); process.exit(-1); } - logger.info('MIGRATIONS: `state` table initialized.'); + logger.debug('MIGRATIONS: `state` table initialized.'); } let databaseSchemaVersion = 0; @@ -41,10 +41,10 @@ class DatabaseMigration { process.exit(-1); } - logger.info('MIGRATIONS: Current state.schema_version ' + databaseSchemaVersion); - logger.info('MIGRATIONS: Latest DatabaseMigration.version is ' + DatabaseMigration.currentVersion); + logger.debug('MIGRATIONS: Current state.schema_version ' + databaseSchemaVersion); + logger.debug('MIGRATIONS: Latest DatabaseMigration.version is ' + DatabaseMigration.currentVersion); if (databaseSchemaVersion >= DatabaseMigration.currentVersion) { - logger.info('MIGRATIONS: Nothing to do.'); + logger.debug('MIGRATIONS: Nothing to do.'); return; } @@ -58,10 +58,10 @@ class DatabaseMigration { } if (DatabaseMigration.currentVersion > databaseSchemaVersion) { - logger.info('MIGRATIONS: Upgrading datababse schema'); + logger.notice('MIGRATIONS: Upgrading datababse schema'); try { await this.$migrateTableSchemaFromVersion(databaseSchemaVersion); - logger.info(`MIGRATIONS: OK. Database schema have been migrated from version ${databaseSchemaVersion} to ${DatabaseMigration.currentVersion} (latest version)`); + logger.notice(`MIGRATIONS: OK. Database schema have been migrated from version ${databaseSchemaVersion} to ${DatabaseMigration.currentVersion} (latest version)`); } catch (e) { logger.err('MIGRATIONS: Unable to migrate database, aborting. ' + e); } @@ -116,6 +116,12 @@ class DatabaseMigration { await this.$executeQuery(connection, 'ALTER TABLE blocks ADD `merkle_root` varchar(65) NOT NULL DEFAULT ""'); await this.$executeQuery(connection, 'ALTER TABLE blocks ADD `previous_block_hash` varchar(65) NULL'); } + + if (databaseSchemaVersion < 7 && isBitcoin === true) { + await this.$executeQuery(connection, 'DROP table IF EXISTS hashrates;'); + await this.$executeQuery(connection, this.getCreateDailyStatsTableQuery(), await this.$checkIfTableExists('hashrates')); + } + connection.release(); } catch (e) { connection.release(); @@ -143,10 +149,10 @@ class DatabaseMigration { WHERE table_schema=DATABASE() AND table_name='statistics' AND index_name='added';`; const [rows] = await this.$executeQuery(connection, query, true); if (rows[0].hasIndex === 0) { - logger.info('MIGRATIONS: `statistics.added` is not indexed'); + logger.debug('MIGRATIONS: `statistics.added` is not indexed'); this.statisticsAddedIndexed = false; } else if (rows[0].hasIndex === 1) { - logger.info('MIGRATIONS: `statistics.added` is already indexed'); + logger.debug('MIGRATIONS: `statistics.added` is already indexed'); this.statisticsAddedIndexed = true; } } catch (e) { @@ -164,7 +170,7 @@ class DatabaseMigration { */ private async $executeQuery(connection: PoolConnection, query: string, silent: boolean = false): Promise { if (!silent) { - logger.info('MIGRATIONS: Execute query:\n' + query); + logger.debug('MIGRATIONS: Execute query:\n' + query); } return connection.query({ sql: query, timeout: this.queryTimeout }); } @@ -255,6 +261,10 @@ class DatabaseMigration { } } + if (version < 7) { + queries.push(`INSERT INTO state(name, number, string) VALUES ('last_hashrates_indexing', 0, NULL)`); + } + return queries; } @@ -272,9 +282,9 @@ class DatabaseMigration { const connection = await DB.pool.getConnection(); try { const [rows] = await this.$executeQuery(connection, 'SELECT VERSION() as version;', true); - logger.info(`MIGRATIONS: Database engine version '${rows[0].version}'`); + logger.debug(`MIGRATIONS: Database engine version '${rows[0].version}'`); } catch (e) { - logger.info(`MIGRATIONS: Could not fetch database engine version. ` + e); + logger.debug(`MIGRATIONS: Could not fetch database engine version. ` + e); } connection.release(); } @@ -398,6 +408,40 @@ class DatabaseMigration { FOREIGN KEY (pool_id) REFERENCES pools (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;`; } + + private getCreateDailyStatsTableQuery(): string { + return `CREATE TABLE IF NOT EXISTS hashrates ( + hashrate_timestamp timestamp NOT NULL, + avg_hashrate double unsigned DEFAULT '0', + pool_id smallint unsigned NULL, + PRIMARY KEY (hashrate_timestamp), + INDEX (pool_id), + FOREIGN KEY (pool_id) REFERENCES pools (id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8;`; + } + + public async $truncateIndexedData(tables: string[]) { + const allowedTables = ['blocks', 'hashrates']; + + const connection = await DB.pool.getConnection(); + try { + for (const table of tables) { + if (!allowedTables.includes(table)) { + logger.debug(`Table ${table} cannot to be re-indexed (not allowed)`); + continue; + }; + + await this.$executeQuery(connection, `TRUNCATE ${table}`, true); + if (table === 'hashrates') { + await this.$executeQuery(connection, 'UPDATE state set number = 0 where name = "last_hashrates_indexing"', true); + } + logger.notice(`Table ${table} has been truncated`); + } + } catch (e) { + logger.warn(`Unable to erase indexed data`); + } + connection.release(); + } } export default new DatabaseMigration(); diff --git a/backend/src/api/mining.ts b/backend/src/api/mining.ts index beca52893..1d5142080 100644 --- a/backend/src/api/mining.ts +++ b/backend/src/api/mining.ts @@ -1,16 +1,21 @@ import { PoolInfo, PoolStats } from '../mempool.interfaces'; import BlocksRepository, { EmptyBlocks } from '../repositories/BlocksRepository'; import PoolsRepository from '../repositories/PoolsRepository'; +import HashratesRepository from '../repositories/HashratesRepository'; import bitcoinClient from './bitcoin/bitcoin-client'; +import logger from '../logger'; +import blocks from './blocks'; class Mining { + hashrateIndexingStarted = false; + constructor() { } /** * Generate high level overview of the pool ranks and general stats */ - public async $getPoolsStats(interval: string | null) : Promise { + public async $getPoolsStats(interval: string | null): Promise { const poolsStatistics = {}; const poolsInfo: PoolInfo[] = await PoolsRepository.$getPoolsInfo(interval); @@ -26,8 +31,8 @@ class Mining { link: poolInfo.link, blockCount: poolInfo.blockCount, rank: rank++, - emptyBlocks: 0, - } + emptyBlocks: 0 + }; for (let i = 0; i < emptyBlocks.length; ++i) { if (emptyBlocks[i].poolId === poolInfo.poolId) { poolStat.emptyBlocks++; @@ -45,7 +50,7 @@ class Mining { poolsStatistics['blockCount'] = blockCount; const blockHeightTip = await bitcoinClient.getBlockCount(); - const lastBlockHashrate = await bitcoinClient.getNetworkHashPs(120, blockHeightTip); + const lastBlockHashrate = await bitcoinClient.getNetworkHashPs(144, blockHeightTip); poolsStatistics['lastEstimatedHashrate'] = lastBlockHashrate; return poolsStatistics; @@ -80,7 +85,101 @@ class Mining { return { adjustments: difficultyAdjustments, oldestIndexedBlockTimestamp: oldestBlock.getTime(), + }; + } + + /** + * Return the historical hashrates and oldest indexed block timestamp + */ + public async $getHistoricalHashrates(interval: string | null): Promise { + const hashrates = await HashratesRepository.$get(interval); + const oldestBlock = new Date(await BlocksRepository.$oldestBlockTimestamp()); + + return { + hashrates: hashrates, + oldestIndexedBlockTimestamp: oldestBlock.getTime(), + }; + } + + /** + * Generate daily hashrate data + */ + public async $generateNetworkHashrateHistory(): Promise { + // We only run this once a day + const latestTimestamp = await HashratesRepository.$getLatestRunTimestamp(); + const now = new Date().getTime() / 1000; + if (now - latestTimestamp < 86400) { + return; } + + if (!blocks.blockIndexingCompleted || this.hashrateIndexingStarted) { + return; + } + this.hashrateIndexingStarted = true; + + logger.info(`Indexing hashrates`); + + const totalDayIndexed = (await BlocksRepository.$blockCount(null, null)) / 144; + const indexedTimestamp = (await HashratesRepository.$get(null)).map(hashrate => hashrate.timestamp); + let startedAt = new Date().getTime() / 1000; + const genesisTimestamp = 1231006505; // bitcoin-cli getblock 000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f + const lastMidnight = new Date(); + lastMidnight.setUTCHours(0); lastMidnight.setUTCMinutes(0); lastMidnight.setUTCSeconds(0); lastMidnight.setUTCMilliseconds(0); + let toTimestamp = Math.round(lastMidnight.getTime() / 1000); + let indexedThisRun = 0; + let totalIndexed = 0; + + const hashrates: any[] = []; + + while (toTimestamp > genesisTimestamp) { + const fromTimestamp = toTimestamp - 86400; + if (indexedTimestamp.includes(fromTimestamp)) { + toTimestamp -= 86400; + ++totalIndexed; + continue; + } + + const blockStats: any = await BlocksRepository.$blockCountBetweenTimestamp( + null, fromTimestamp, toTimestamp); + if (blockStats.blockCount === 0) { // We are done indexing, no blocks left + break; + } + + let lastBlockHashrate = 0; + lastBlockHashrate = await bitcoinClient.getNetworkHashPs(blockStats.blockCount, + blockStats.lastBlockHeight); + + const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt)); + if (elapsedSeconds > 10) { + const daysPerSeconds = Math.max(1, Math.round(indexedThisRun / elapsedSeconds)); + const formattedDate = new Date(fromTimestamp * 1000).toUTCString(); + const daysLeft = Math.round(totalDayIndexed - totalIndexed); + logger.debug(`Getting hashrate for ${formattedDate} | ~${daysPerSeconds} days/sec | ~${daysLeft} days left to index`); + startedAt = new Date().getTime() / 1000; + indexedThisRun = 0; + } + + hashrates.push({ + hashrateTimestamp: fromTimestamp, + avgHashrate: lastBlockHashrate, + poolId: null, + }); + + if (hashrates.length > 100) { + await HashratesRepository.$saveHashrates(hashrates); + hashrates.length = 0; + } + + toTimestamp -= 86400; + ++indexedThisRun; + ++totalIndexed; + } + + await HashratesRepository.$saveHashrates(hashrates); + await HashratesRepository.$setLatestRunTimestamp(); + this.hashrateIndexingStarted = false; + + logger.info(`Hashrates indexing completed`); } } diff --git a/backend/src/index.ts b/backend/src/index.ts index 23c70f59d..65d453859 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -26,6 +26,7 @@ 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'; class Server { private wss: WebSocket.Server | undefined; @@ -88,6 +89,12 @@ class Server { if (config.DATABASE.ENABLED) { await checkDbConnection(); try { + if (process.env.npm_config_reindex != undefined) { // Re-index requests + const tables = process.env.npm_config_reindex.split(','); + logger.warn(`Indexed data for "${process.env.npm_config_reindex}" tables will be erased in 5 seconds from now (using '--reindex') ...`); + await Common.sleep(5000); + await databaseMigration.$truncateIndexedData(tables); + } await databaseMigration.$initializeOrMigrateDatabase(); await poolsParser.migratePoolsJson(); } catch (e) { @@ -138,7 +145,7 @@ class Server { } await blocks.$updateBlocks(); await memPool.$updateMempool(); - blocks.$generateBlockDatabase(); + this.runIndexingWhenReady(); setTimeout(this.runMainUpdateLoop.bind(this), config.MEMPOOL.POLL_RATE_MS); this.currentBackendRetryInterval = 5; @@ -157,6 +164,19 @@ class Server { } } + async runIndexingWhenReady() { + if (!Common.indexingEnabled() || mempool.hasPriority()) { + return; + } + + try { + await blocks.$generateBlockDatabase(); + await mining.$generateNetworkHashrateHistory(); + } catch (e) { + logger.err(`Unable to run indexing right now, trying again later. ` + e); + } + } + setUpWebsocketHandling() { if (this.wss) { websocketHandler.setWebsocketServer(this.wss); @@ -276,7 +296,9 @@ class Server { .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:poolId', routes.$getPool) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:poolId/:interval', routes.$getPool) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/difficulty', routes.$getHistoricalDifficulty) - .get(config.MEMPOOL.API_URL_PREFIX + 'mining/difficulty/:interval', routes.$getHistoricalDifficulty); + .get(config.MEMPOOL.API_URL_PREFIX + 'mining/difficulty/:interval', routes.$getHistoricalDifficulty) + .get(config.MEMPOOL.API_URL_PREFIX + 'mining/hashrate', routes.$getHistoricalHashrate) + .get(config.MEMPOOL.API_URL_PREFIX + 'mining/hashrate/:interval', routes.$getHistoricalHashrate); } if (config.BISQ.ENABLED) { diff --git a/backend/src/repositories/BlocksRepository.ts b/backend/src/repositories/BlocksRepository.ts index ac0ea25bc..235dc9ebd 100644 --- a/backend/src/repositories/BlocksRepository.ts +++ b/backend/src/repositories/BlocksRepository.ts @@ -149,6 +149,40 @@ class BlocksRepository { return rows[0].blockCount; } + /** + * Get blocks count between two dates + * @param poolId + * @param from - The oldest timestamp + * @param to - The newest timestamp + * @returns + */ + public async $blockCountBetweenTimestamp(poolId: number | null, from: number, to: number): Promise { + const params: any[] = []; + let query = `SELECT + count(height) as blockCount, + max(height) as lastBlockHeight + FROM blocks`; + + if (poolId) { + query += ` WHERE pool_id = ?`; + params.push(poolId); + } + + if (poolId) { + query += ` AND`; + } else { + query += ` WHERE`; + } + query += ` UNIX_TIMESTAMP(blockTimestamp) BETWEEN '${from}' AND '${to}'`; + + // logger.debug(query); + const connection = await DB.pool.getConnection(); + const [rows] = await connection.query(query, params); + connection.release(); + + return rows[0]; + } + /** * Get the oldest indexed block */ @@ -240,13 +274,20 @@ class BlocksRepository { } query += ` GROUP BY difficulty - ORDER BY blockTimestamp DESC`; + ORDER BY blockTimestamp`; const [rows]: any[] = await connection.query(query); connection.release(); return rows; } + + public async $getOldestIndexedBlockHeight(): Promise { + const connection = await DB.pool.getConnection(); + const [rows]: any[] = await connection.query(`SELECT MIN(height) as minHeight FROM blocks`); + connection.release(); + return rows[0].minHeight; + } } export default new BlocksRepository(); diff --git a/backend/src/repositories/HashratesRepository.ts b/backend/src/repositories/HashratesRepository.ts new file mode 100644 index 000000000..fd4340d4e --- /dev/null +++ b/backend/src/repositories/HashratesRepository.ts @@ -0,0 +1,68 @@ +import { Common } from '../api/common'; +import { DB } from '../database'; +import logger from '../logger'; + +class HashratesRepository { + /** + * Save indexed block data in the database + */ + public async $saveHashrates(hashrates: any) { + let query = `INSERT INTO + hashrates(hashrate_timestamp, avg_hashrate, pool_id) VALUES`; + + for (const hashrate of hashrates) { + query += ` (FROM_UNIXTIME(${hashrate.hashrateTimestamp}), ${hashrate.avgHashrate}, ${hashrate.poolId}),`; + } + query = query.slice(0, -1); + + const connection = await DB.pool.getConnection(); + try { + // logger.debug(query); + await connection.query(query); + } catch (e: any) { + logger.err('$saveHashrateInDatabase() error' + (e instanceof Error ? e.message : e)); + } + + connection.release(); + } + + /** + * Returns an array of all timestamp we've already indexed + */ + public async $get(interval: string | null): Promise { + interval = Common.getSqlInterval(interval); + + const connection = await DB.pool.getConnection(); + + let query = `SELECT UNIX_TIMESTAMP(hashrate_timestamp) as timestamp, avg_hashrate as avgHashrate + FROM hashrates`; + + if (interval) { + query += ` WHERE hashrate_timestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`; + } + + query += ` ORDER by hashrate_timestamp`; + + const [rows]: any[] = await connection.query(query); + connection.release(); + + return rows; + } + + public async $setLatestRunTimestamp() { + const connection = await DB.pool.getConnection(); + const query = `UPDATE state SET number = ? WHERE name = 'last_hashrates_indexing'`; + await connection.query(query, [Math.round(new Date().getTime() / 1000)]); + connection.release(); + } + + public async $getLatestRunTimestamp(): Promise { + const connection = await DB.pool.getConnection(); + const query = `SELECT number FROM state WHERE name = 'last_hashrates_indexing'`; + const [rows] = await connection.query(query); + connection.release(); + return rows[0]['number']; + } +} + +export default new HashratesRepository(); diff --git a/backend/src/routes.ts b/backend/src/routes.ts index 4a9cb1f8f..1bf1c3434 100644 --- a/backend/src/routes.ts +++ b/backend/src/routes.ts @@ -22,7 +22,6 @@ import elementsParser from './api/liquid/elements-parser'; import icons from './api/liquid/icons'; import miningStats from './api/mining'; import axios from 'axios'; -import PoolsRepository from './repositories/PoolsRepository'; import mining from './api/mining'; import BlocksRepository from './repositories/BlocksRepository'; @@ -587,6 +586,18 @@ class Routes { } } + public async $getHistoricalHashrate(req: Request, res: Response) { + try { + const stats = await mining.$getHistoricalHashrates(req.params.interval ?? null); + res.header('Pragma', 'public'); + res.header('Cache-control', 'public'); + res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString()); + res.json(stats); + } 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/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index f0aa73e3d..c8a1d98e6 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -29,6 +29,8 @@ import { AssetsComponent } from './components/assets/assets.component'; import { PoolComponent } from './components/pool/pool.component'; import { MiningDashboardComponent } from './components/mining-dashboard/mining-dashboard.component'; import { DifficultyChartComponent } from './components/difficulty-chart/difficulty-chart.component'; +import { HashrateChartComponent } from './components/hashrate-chart/hashrate-chart.component'; +import { MiningStartComponent } from './components/mining-start/mining-start.component'; let routes: Routes = [ { @@ -70,16 +72,35 @@ let routes: Routes = [ component: LatestBlocksComponent, }, { - path: 'mining/difficulty', - component: DifficultyChartComponent, - }, - { - path: 'mining/pools', - component: PoolRankingComponent, - }, - { - path: 'mining/pool/:poolId', - component: PoolComponent, + path: 'mining', + component: MiningStartComponent, + children: [ + { + path: 'difficulty', + component: DifficultyChartComponent, + }, + { + path: 'hashrate', + component: HashrateChartComponent, + }, + { + path: 'pools', + component: PoolRankingComponent, + }, + { + path: 'pool', + children: [ + { + path: ':poolId', + component: PoolComponent, + }, + { + path: ':poolId/hashrate', + component: HashrateChartComponent, + }, + ] + }, + ] }, { path: 'graphs', @@ -170,16 +191,35 @@ let routes: Routes = [ component: LatestBlocksComponent, }, { - path: 'mining/difficulty', - component: DifficultyChartComponent, - }, - { - path: 'mining/pools', - component: PoolRankingComponent, - }, - { - path: 'mining/pool/:poolId', - component: PoolComponent, + path: 'mining', + component: MiningStartComponent, + children: [ + { + path: 'difficulty', + component: DifficultyChartComponent, + }, + { + path: 'hashrate', + component: HashrateChartComponent, + }, + { + path: 'pools', + component: PoolRankingComponent, + }, + { + path: 'pool', + children: [ + { + path: ':poolId', + component: PoolComponent, + }, + { + path: ':poolId/hashrate', + component: HashrateChartComponent, + }, + ] + }, + ] }, { path: 'graphs', @@ -264,16 +304,35 @@ let routes: Routes = [ component: LatestBlocksComponent, }, { - path: 'mining/difficulty', - component: DifficultyChartComponent, - }, - { - path: 'mining/pools', - component: PoolRankingComponent, - }, - { - path: 'mining/pool/:poolId', - component: PoolComponent, + path: 'mining', + component: MiningStartComponent, + children: [ + { + path: 'difficulty', + component: DifficultyChartComponent, + }, + { + path: 'hashrate', + component: HashrateChartComponent, + }, + { + path: 'pools', + component: PoolRankingComponent, + }, + { + path: 'pool', + children: [ + { + path: ':poolId', + component: PoolComponent, + }, + { + path: ':poolId/hashrate', + component: HashrateChartComponent, + }, + ] + }, + ] }, { path: 'graphs', diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 4fe24ceb8..9e8fea464 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -71,6 +71,8 @@ import { AssetGroupComponent } from './components/assets/asset-group/asset-group import { AssetCirculationComponent } from './components/asset-circulation/asset-circulation.component'; import { MiningDashboardComponent } from './components/mining-dashboard/mining-dashboard.component'; import { DifficultyChartComponent } from './components/difficulty-chart/difficulty-chart.component'; +import { HashrateChartComponent } from './components/hashrate-chart/hashrate-chart.component'; +import { MiningStartComponent } from './components/mining-start/mining-start.component'; @NgModule({ declarations: [ @@ -124,6 +126,8 @@ import { DifficultyChartComponent } from './components/difficulty-chart/difficul AssetCirculationComponent, MiningDashboardComponent, DifficultyChartComponent, + HashrateChartComponent, + MiningStartComponent, ], imports: [ BrowserModule.withServerTransition({ appId: 'serverApp' }), diff --git a/frontend/src/app/bitcoin.utils.ts b/frontend/src/app/bitcoin.utils.ts index ff2d7a885..72fde7471 100644 --- a/frontend/src/app/bitcoin.utils.ts +++ b/frontend/src/app/bitcoin.utils.ts @@ -130,3 +130,32 @@ export const formatNumber = (s, precision = null) => { // Utilities for segwitFeeGains const witnessSize = (vin: Vin) => vin.witness.reduce((S, w) => S + (w.length / 2), 0); const scriptSigSize = (vin: Vin) => vin.scriptsig ? vin.scriptsig.length / 2 : 0; + +// Power of ten wrapper +export function selectPowerOfTen(val: number) { + const powerOfTen = { + exa: Math.pow(10, 18), + peta: Math.pow(10, 15), + terra: Math.pow(10, 12), + giga: Math.pow(10, 9), + mega: Math.pow(10, 6), + kilo: Math.pow(10, 3), + }; + + let selectedPowerOfTen; + if (val < powerOfTen.mega) { + selectedPowerOfTen = { divider: 1, unit: '' }; // no scaling + } else if (val < powerOfTen.giga) { + selectedPowerOfTen = { divider: powerOfTen.mega, unit: 'M' }; + } else if (val < powerOfTen.terra) { + selectedPowerOfTen = { divider: powerOfTen.giga, unit: 'G' }; + } else if (val < powerOfTen.peta) { + selectedPowerOfTen = { divider: powerOfTen.terra, unit: 'T' }; + } else if (val < powerOfTen.exa) { + selectedPowerOfTen = { divider: powerOfTen.peta, unit: 'P' }; + } else { + selectedPowerOfTen = { divider: powerOfTen.exa, unit: 'E' }; + } + + return selectedPowerOfTen; +} \ No newline at end of file diff --git a/frontend/src/app/components/difficulty-chart/difficulty-chart.component.html b/frontend/src/app/components/difficulty-chart/difficulty-chart.component.html index ca005f2d4..eb34d4075 100644 --- a/frontend/src/app/components/difficulty-chart/difficulty-chart.component.html +++ b/frontend/src/app/components/difficulty-chart/difficulty-chart.component.html @@ -1,10 +1,5 @@
-
-
-
-
-
@@ -30,6 +25,11 @@
+
+
+
+
+ diff --git a/frontend/src/app/components/difficulty-chart/difficulty-chart.component.scss b/frontend/src/app/components/difficulty-chart/difficulty-chart.component.scss index c3a63e9fa..4205c9db7 100644 --- a/frontend/src/app/components/difficulty-chart/difficulty-chart.component.scss +++ b/frontend/src/app/components/difficulty-chart/difficulty-chart.component.scss @@ -8,3 +8,20 @@ text-align: center; padding-bottom: 3px; } + +.formRadioGroup { + margin-top: 6px; + display: flex; + flex-direction: column; + @media (min-width: 830px) { + flex-direction: row; + float: right; + margin-top: 0px; + } + .btn-sm { + font-size: 9px; + @media (min-width: 830px) { + font-size: 14px; + } + } +} diff --git a/frontend/src/app/components/difficulty-chart/difficulty-chart.component.ts b/frontend/src/app/components/difficulty-chart/difficulty-chart.component.ts index 350e3c4be..4bbc9520a 100644 --- a/frontend/src/app/components/difficulty-chart/difficulty-chart.component.ts +++ b/frontend/src/app/components/difficulty-chart/difficulty-chart.component.ts @@ -1,11 +1,12 @@ import { Component, Inject, Input, LOCALE_ID, OnInit } from '@angular/core'; -import { EChartsOption } from 'echarts'; +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 { selectPowerOfTen } from 'src/app/bitcoin.utils'; @Component({ selector: 'app-difficulty-chart', @@ -46,13 +47,6 @@ export class DifficultyChartComponent implements OnInit { } ngOnInit(): void { - const powerOfTen = { - terra: Math.pow(10, 12), - giga: Math.pow(10, 9), - mega: Math.pow(10, 6), - kilo: Math.pow(10, 3), - } - this.difficultyObservable$ = this.radioGroupForm.get('dateSpan').valueChanges .pipe( startWith('1y'), @@ -69,16 +63,9 @@ export class DifficultyChartComponent implements OnInit { ) / 3600 / 24; const tableData = []; - for (let i = 0; i < data.adjustments.length - 1; ++i) { - const change = (data.adjustments[i].difficulty / data.adjustments[i + 1].difficulty - 1) * 100; - let selectedPowerOfTen = { divider: powerOfTen.terra, unit: 'T' }; - if (data.adjustments[i].difficulty < powerOfTen.mega) { - selectedPowerOfTen = { divider: 1, unit: '' }; // no scaling - } else if (data.adjustments[i].difficulty < powerOfTen.giga) { - selectedPowerOfTen = { divider: powerOfTen.mega, unit: 'M' }; - } else if (data.adjustments[i].difficulty < powerOfTen.terra) { - selectedPowerOfTen = { divider: powerOfTen.giga, unit: 'G' }; - } + for (let i = data.adjustments.length - 1; i > 0; --i) { + const selectedPowerOfTen: any = selectPowerOfTen(data.adjustments[i].difficulty); + const change = (data.adjustments[i].difficulty / data.adjustments[i - 1].difficulty - 1) * 100; tableData.push(Object.assign(data.adjustments[i], { change: change, @@ -100,6 +87,13 @@ export class DifficultyChartComponent implements OnInit { prepareChartOptions(data) { this.chartOptions = { + color: new graphic.LinearGradient(0, 0, 0, 0.65, [ + { offset: 0, color: '#D81B60' }, + { offset: 0.25, color: '#8E24AA' }, + { offset: 0.5, color: '#5E35B1' }, + { offset: 0.75, color: '#3949AB' }, + { offset: 1, color: '#1E88E5' } + ]), title: { text: this.widget? '' : $localize`:@@mining.difficulty:Difficulty`, left: 'center', @@ -110,6 +104,17 @@ export class DifficultyChartComponent implements OnInit { tooltip: { show: true, trigger: 'axis', + backgroundColor: 'rgba(17, 19, 31, 1)', + borderRadius: 4, + shadowColor: 'rgba(0, 0, 0, 0.5)', + textStyle: { + color: '#b1b1b1', + }, + borderColor: '#000', + formatter: params => { + return `${params[0].axisValueLabel}
+ ${params[0].marker} ${formatNumber(params[0].value[1], this.locale, '1.0-0')}` + } }, axisPointer: { type: 'line', @@ -122,8 +127,9 @@ export class DifficultyChartComponent implements OnInit { type: 'value', axisLabel: { formatter: (val) => { - const diff = val / Math.pow(10, 12); // terra - return diff.toString() + 'T'; + const selectedPowerOfTen: any = selectPowerOfTen(val); + const diff = val / selectedPowerOfTen.divider; + return `${diff} ${selectedPowerOfTen.unit}`; } }, splitLine: { @@ -134,17 +140,40 @@ export class DifficultyChartComponent implements OnInit { } } }, - series: [ - { - data: data, - type: 'line', - smooth: false, - lineStyle: { - width: 3, - }, - areaStyle: {} + series: { + showSymbol: false, + data: data, + type: 'line', + smooth: false, + lineStyle: { + width: 2, }, - ], + }, + dataZoom: this.widget ? null : [{ + type: 'inside', + realtime: true, + zoomLock: true, + zoomOnMouseWheel: true, + moveOnMouseMove: true, + maxSpan: 100, + minSpan: 10, + }, { + showDetail: false, + show: true, + type: 'slider', + brushSelect: false, + realtime: true, + bottom: 0, + selectedDataBackground: { + lineStyle: { + color: '#fff', + opacity: 0.45, + }, + areaStyle: { + opacity: 0, + } + }, + }], }; } diff --git a/frontend/src/app/components/hashrate-chart/hashrate-chart.component.html b/frontend/src/app/components/hashrate-chart/hashrate-chart.component.html new file mode 100644 index 000000000..263df95b2 --- /dev/null +++ b/frontend/src/app/components/hashrate-chart/hashrate-chart.component.html @@ -0,0 +1,33 @@ +
+ +
+
+
+ + + + + + +
+ +
+ +
+
+
+
+ +
diff --git a/frontend/src/app/components/hashrate-chart/hashrate-chart.component.scss b/frontend/src/app/components/hashrate-chart/hashrate-chart.component.scss new file mode 100644 index 000000000..62eac44f5 --- /dev/null +++ b/frontend/src/app/components/hashrate-chart/hashrate-chart.component.scss @@ -0,0 +1,45 @@ +.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 { + width: 100%; + height: calc(100% - 100px); + @media (max-width: 992px) { + height: calc(100% - 140px); + }; + @media (max-width: 576px) { + height: calc(100% - 180px); + }; +} + +.chart { + width: 100%; + height: 100%; + padding-bottom: 20px; + padding-right: 20px; +} + +.formRadioGroup { + margin-top: 6px; + display: flex; + flex-direction: column; + @media (min-width: 830px) { + flex-direction: row; + float: right; + margin-top: 0px; + } + .btn-sm { + font-size: 9px; + @media (min-width: 830px) { + font-size: 14px; + } + } +} diff --git a/frontend/src/app/components/hashrate-chart/hashrate-chart.component.ts b/frontend/src/app/components/hashrate-chart/hashrate-chart.component.ts new file mode 100644 index 000000000..9a202b69a --- /dev/null +++ b/frontend/src/app/components/hashrate-chart/hashrate-chart.component.ts @@ -0,0 +1,178 @@ +import { 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 { selectPowerOfTen } from 'src/app/bitcoin.utils'; + +@Component({ + selector: 'app-hashrate-chart', + templateUrl: './hashrate-chart.component.html', + styleUrls: ['./hashrate-chart.component.scss'], + styles: [` + .loadingGraphs { + position: absolute; + top: 38%; + left: calc(50% - 15px); + z-index: 100; + } + `], +}) +export class HashrateChartComponent implements OnInit { + @Input() widget: boolean = false; + @Input() right: number | string = 10; + @Input() left: number | string = 75; + + radioGroupForm: FormGroup; + + chartOptions: EChartsOption = {}; + chartInitOptions = { + renderer: 'svg', + width: 'auto', + height: 'auto', + }; + + hashrateObservable$: Observable; + isLoading = true; + formatNumber = formatNumber; + + constructor( + @Inject(LOCALE_ID) public locale: string, + private seoService: SeoService, + private apiService: ApiService, + private formBuilder: FormBuilder, + ) { + this.seoService.setTitle($localize`:@@mining.hashrate:hashrate`); + this.radioGroupForm = this.formBuilder.group({ dateSpan: '1y' }); + this.radioGroupForm.controls.dateSpan.setValue('1y'); + } + + ngOnInit(): void { + this.hashrateObservable$ = this.radioGroupForm.get('dateSpan').valueChanges + .pipe( + startWith('1y'), + switchMap((timespan) => { + return this.apiService.getHistoricalHashrate$(timespan) + .pipe( + tap(data => { + this.prepareChartOptions(data.hashrates.map(val => [val.timestamp * 1000, val.avgHashrate])); + this.isLoading = false; + }), + map(data => { + const availableTimespanDay = ( + (new Date().getTime() / 1000) - (data.oldestIndexedBlockTimestamp / 1000) + ) / 3600 / 24; + return { + availableTimespanDay: availableTimespanDay, + data: data.hashrates + }; + }), + ); + }), + share() + ); + } + + prepareChartOptions(data) { + this.chartOptions = { + 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: { + right: this.right, + left: this.left, + }, + title: { + text: this.widget ? '' : $localize`:@@mining.hashrate:Hashrate`, + left: 'center', + textStyle: { + color: '#FFF', + }, + }, + tooltip: { + show: true, + trigger: 'axis', + backgroundColor: 'rgba(17, 19, 31, 1)', + borderRadius: 4, + shadowColor: 'rgba(0, 0, 0, 0.5)', + textStyle: { + color: '#b1b1b1', + }, + borderColor: '#000', + formatter: params => { + return `${params[0].axisValueLabel}
+ ${params[0].marker} ${formatNumber(params[0].value[1], this.locale, '1.0-0')} H/s` + } + }, + axisPointer: { + type: 'line', + }, + xAxis: { + type: 'time', + splitNumber: this.isMobile() ? 5 : 10, + }, + yAxis: { + type: 'value', + axisLabel: { + formatter: (val) => { + const selectedPowerOfTen: any = selectPowerOfTen(val); + const newVal = val / selectedPowerOfTen.divider; + return `${newVal} ${selectedPowerOfTen.unit}H/s` + } + }, + splitLine: { + lineStyle: { + type: 'dotted', + color: '#ffffff66', + opacity: 0.25, + } + }, + }, + series: { + showSymbol: false, + data: data, + type: 'line', + smooth: false, + lineStyle: { + width: 2, + }, + }, + dataZoom: this.widget ? null : [{ + type: 'inside', + realtime: true, + zoomLock: true, + zoomOnMouseWheel: true, + moveOnMouseMove: true, + maxSpan: 100, + minSpan: 10, + }, { + showDetail: false, + show: true, + type: 'slider', + brushSelect: false, + realtime: true, + bottom: 0, + selectedDataBackground: { + lineStyle: { + color: '#fff', + opacity: 0.45, + }, + areaStyle: { + opacity: 0, + } + }, + }], + }; + } + + isMobile() { + return (window.innerWidth <= 767.98); + } +} diff --git a/frontend/src/app/components/master-page/master-page.component.html b/frontend/src/app/components/master-page/master-page.component.html index bab0f42e9..28db64bbd 100644 --- a/frontend/src/app/components/master-page/master-page.component.html +++ b/frontend/src/app/components/master-page/master-page.component.html @@ -31,7 +31,7 @@ -