Merge pull request #1269 from nymkappa/feature/hashrate-chart
Created hashrate chart component
This commit is contained in:
		
						commit
						3f5a749352
					
				| @ -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() { | ||||
|  | ||||
| @ -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<void> { | ||||
|     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<any> { | ||||
|     if (!silent) { | ||||
|       logger.info('MIGRATIONS: Execute query:\n' + query); | ||||
|       logger.debug('MIGRATIONS: Execute query:\n' + query); | ||||
|     } | ||||
|     return connection.query<any>({ 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(); | ||||
|  | ||||
| @ -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<object> { | ||||
|   public async $getPoolsStats(interval: string | null): Promise<object> { | ||||
|     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<object> { | ||||
|     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<void> { | ||||
|     // 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`); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
|  | ||||
| @ -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) { | ||||
|  | ||||
| @ -149,6 +149,40 @@ class BlocksRepository { | ||||
|     return <number>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<number> { | ||||
|     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 <number>rows[0]; | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Get the oldest indexed block | ||||
|    */ | ||||
| @ -247,6 +281,13 @@ class BlocksRepository { | ||||
| 
 | ||||
|     return rows; | ||||
|   } | ||||
| 
 | ||||
|   public async $getOldestIndexedBlockHeight(): Promise<number> { | ||||
|     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(); | ||||
|  | ||||
							
								
								
									
										68
									
								
								backend/src/repositories/HashratesRepository.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								backend/src/repositories/HashratesRepository.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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<any[]> { | ||||
|     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 DESC`; | ||||
| 
 | ||||
|     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<any>(query, [Math.round(new Date().getTime() / 1000)]); | ||||
|     connection.release(); | ||||
|   } | ||||
| 
 | ||||
|   public async $getLatestRunTimestamp(): Promise<number> { | ||||
|     const connection = await DB.pool.getConnection(); | ||||
|     const query = `SELECT number FROM state WHERE name = 'last_hashrates_indexing'`; | ||||
|     const [rows] = await connection.query<any>(query); | ||||
|     connection.release(); | ||||
|     return rows[0]['number']; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export default new HashratesRepository(); | ||||
| @ -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); | ||||
|  | ||||
| @ -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', | ||||
|  | ||||
| @ -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' }), | ||||
|  | ||||
| @ -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; | ||||
| } | ||||
| @ -6,6 +6,7 @@ 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', | ||||
| @ -70,15 +71,8 @@ export class DifficultyChartComponent implements OnInit { | ||||
| 
 | ||||
|                 const tableData = []; | ||||
|                 for (let i = 0; i < data.adjustments.length - 1; ++i) { | ||||
|                   const selectedPowerOfTen: any = selectPowerOfTen(data.adjustments[i].difficulty); | ||||
|                   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' }; | ||||
|                   } | ||||
| 
 | ||||
|                   tableData.push(Object.assign(data.adjustments[i], { | ||||
|                     change: change, | ||||
| @ -122,8 +116,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 +129,18 @@ 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, | ||||
|         }, | ||||
|       ], | ||||
|         areaStyle: { | ||||
|           opacity: 0.25 | ||||
|         }, | ||||
|       }, | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -0,0 +1,33 @@ | ||||
| <div [class]="widget === false ? 'full-container' : ''"> | ||||
| 
 | ||||
|   <div class="card-header mb-0 mb-lg-4" [style]="widget ? 'display:none' : ''"> | ||||
|     <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(hashrateObservable$ | async) as hashrates"> | ||||
|       <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan"> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="hashrates.availableTimespanDay >= 90"> | ||||
|           <input ngbButton type="radio" [value]="'3m'"> 3M | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="hashrates.availableTimespanDay >= 180"> | ||||
|           <input ngbButton type="radio" [value]="'6m'"> 6M | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="hashrates.availableTimespanDay >= 365"> | ||||
|           <input ngbButton type="radio" [value]="'1y'"> 1Y | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="hashrates.availableTimespanDay >= 730"> | ||||
|           <input ngbButton type="radio" [value]="'2y'"> 2Y | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="hashrates.availableTimespanDay >= 1095"> | ||||
|           <input ngbButton type="radio" [value]="'3y'"> 3Y | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-primary btn-sm"> | ||||
|           <input ngbButton type="radio" [value]="'all'"> ALL | ||||
|         </label> | ||||
|       </div> | ||||
|     </form> | ||||
|   </div> | ||||
| 
 | ||||
|   <div *ngIf="hashrateObservable$ | async" [class]="widget === false ? 'chart' : ''" echarts [initOpts]="chartInitOptions" [options]="chartOptions"></div> | ||||
|   <div class="text-center loadingGraphs" *ngIf="isLoading"> | ||||
|     <div class="spinner-border text-light"></div> | ||||
|   </div> | ||||
|    | ||||
| </div> | ||||
| @ -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; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,163 @@ | ||||
| import { Component, Inject, Input, LOCALE_ID, OnInit } from '@angular/core'; | ||||
| import { EChartsOption } 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<any>; | ||||
|   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 = { | ||||
|       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', | ||||
|       }, | ||||
|       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, | ||||
|         }, | ||||
|         areaStyle: { | ||||
|           opacity: 0.25 | ||||
|         }, | ||||
|       }, | ||||
|       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); | ||||
|   } | ||||
| } | ||||
| @ -31,7 +31,7 @@ | ||||
|       <li class="nav-item" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" id="btn-home"> | ||||
|         <a class="nav-link" [routerLink]="['/' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'tachometer-alt']" [fixedWidth]="true" i18n-title="master-page.dashboard" title="Dashboard"></fa-icon></a> | ||||
|       </li> | ||||
|       <li class="nav-item" routerLinkActive="active" id="btn-pools" *ngIf="stateService.env.MINING_DASHBOARD"> | ||||
|       <li class="nav-item" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" id="btn-pools" *ngIf="stateService.env.MINING_DASHBOARD"> | ||||
|         <a class="nav-link" [routerLink]="['/mining' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'hammer']" [fixedWidth]="true" i18n-title="master-page.mining-dashboard" title="Mining Dashboard"></fa-icon></a> | ||||
|       </li> | ||||
|       <li class="nav-item" routerLinkActive="active" id="btn-blocks" *ngIf="!stateService.env.MINING_DASHBOARD"> | ||||
|  | ||||
| @ -14,6 +14,18 @@ | ||||
|       </div> | ||||
|     </div> | ||||
| 
 | ||||
|     <!-- hashrate --> | ||||
|     <div class="col"> | ||||
|       <div class="main-title" i18n="mining.hashrate">Hashrate (1y)</div> | ||||
|       <div class="card"> | ||||
|         <div class="card-body"> | ||||
|           <app-hashrate-chart [widget]=true></app-hashrate-chart> | ||||
|           <div class="text-center"><a href="" [routerLink]="['/mining/hashrate' | relativeUrl]" i18n="dashboard.view-more">View more | ||||
|               »</a></div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
| 
 | ||||
|     <!-- difficulty --> | ||||
|     <div class="col"> | ||||
|       <div class="main-title" i18n="mining.difficulty">Difficulty (1y)</div> | ||||
|  | ||||
| @ -0,0 +1 @@ | ||||
| <router-outlet></router-outlet> | ||||
| @ -0,0 +1,14 @@ | ||||
| import { Component, OnInit } from '@angular/core'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-mining-start', | ||||
|   templateUrl: './mining-start.component.html', | ||||
| }) | ||||
| export class MiningStartComponent implements OnInit { | ||||
| 
 | ||||
|   constructor() { } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| @ -156,4 +156,11 @@ export class ApiService { | ||||
|         (interval !== undefined ? `/${interval}` : '') | ||||
|       ); | ||||
|   } | ||||
| 
 | ||||
|   getHistoricalHashrate$(interval: string | undefined): Observable<any> { | ||||
|     return this.httpClient.get<any[]>( | ||||
|         this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/hashrate` + | ||||
|         (interval !== undefined ? `/${interval}` : '') | ||||
|       ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -21,6 +21,12 @@ do for url in / \ | ||||
| 	'/api/v1/mining/pools/2y' \ | ||||
| 	'/api/v1/mining/pools/3y' \ | ||||
| 	'/api/v1/mining/pools/all' \ | ||||
| 	'/api/v1/mining/hashrate/3m' \ | ||||
| 	'/api/v1/mining/hashrate/6m' \ | ||||
| 	'/api/v1/mining/hashrate/1y' \ | ||||
| 	'/api/v1/mining/hashrate/2y' \ | ||||
| 	'/api/v1/mining/hashrate/3y' \ | ||||
| 	'/api/v1/mining/hashrate/all' \ | ||||
| 
 | ||||
| 	do | ||||
| 		curl -s "https://${hostname}${url}" >/dev/null | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user