Merge branch 'master' into feature/mempool-blocks-reward

This commit is contained in:
wiz 2022-02-22 02:59:08 +00:00 committed by GitHub
commit e748775f96
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 827 additions and 100 deletions

View File

@ -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() {

View File

@ -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();

View File

@ -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`);
}
}

View File

@ -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) {

View File

@ -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
*/
@ -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<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();

View 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`;
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();

View File

@ -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);

View File

@ -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',

View File

@ -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' }),

View File

@ -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;
}

View File

@ -1,10 +1,5 @@
<div [class]="widget === false ? 'container-xl' : ''">
<div *ngIf="difficultyObservable$ | async" class="" echarts [initOpts]="chartInitOptions" [options]="chartOptions"></div>
<div class="text-center loadingGraphs" *ngIf="isLoading">
<div class="spinner-border text-light"></div>
</div>
<div class="card-header mb-0 mb-lg-4" [style]="widget ? 'display:none' : ''">
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(difficultyObservable$ | async) as diffChanges">
<div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
@ -30,6 +25,11 @@
</form>
</div>
<div *ngIf="difficultyObservable$ | async" class="mb-5" echarts [initOpts]="chartInitOptions" [options]="chartOptions"></div>
<div class="text-center loadingGraphs" *ngIf="isLoading">
<div class="spinner-border text-light"></div>
</div>
<table class="table table-borderless table-sm text-center" *ngIf="!widget">
<thead>
<tr>

View File

@ -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;
}
}
}

View File

@ -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 `<b style="color: white">${params[0].axisValueLabel}</b><br>
${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,
}
},
}],
};
}

View File

@ -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>

View File

@ -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;
}
}
}

View File

@ -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<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 = {
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 `<b style="color: white">${params[0].axisValueLabel}</b><br>
${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);
}
}

View File

@ -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">

View File

@ -4,9 +4,9 @@
<!-- pool distribution -->
<div class="col">
<div class="main-title" i18n="mining.pool-share">Mining Pools Share (1w)</div>
<div class="card">
<div class="card-body">
<h5 class="card-title" i18n="mining.pool-share">Mining Pools Share (1w)</h5>
<app-pool-ranking [widget]=true></app-pool-ranking>
<div class="text-center"><a href="" [routerLink]="['/mining/pools' | relativeUrl]" i18n="dashboard.view-more">View more
&raquo;</a></div>
@ -14,11 +14,23 @@
</div>
</div>
<!-- difficulty -->
<!-- hashrate -->
<div class="col">
<div class="main-title" i18n="mining.difficulty">Difficulty (1y)</div>
<div class="card">
<div class="card-body">
<h5 class="card-title" i18n="mining.hashrate">Hashrate (1y)</h5>
<app-hashrate-chart [widget]=true></app-hashrate-chart>
<div class="text-center"><a href="" [routerLink]="['/mining/hashrate' | relativeUrl]" i18n="dashboard.view-more">View more
&raquo;</a></div>
</div>
</div>
</div>
<!-- difficulty -->
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title" i18n="ning.difficulty">Difficulty (1y)</h5>
<app-difficulty-chart [widget]=true></app-difficulty-chart>
<div class="text-center"><a href="" [routerLink]="['/mining/difficulty' | relativeUrl]" i18n="dashboard.view-more">View more
&raquo;</a></div>

View File

@ -15,6 +15,11 @@
height: 100%;
}
.card-title {
color: #4a68b9;
font-size: 1rem;
}
.card-wrapper {
.card {
height: auto !important;

View File

@ -0,0 +1 @@
<router-outlet></router-outlet>

View File

@ -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 {
}
}

View File

@ -121,21 +121,24 @@ export class PoolRankingComponent implements OnInit {
value: pool.share,
name: pool.name + (this.isMobile() ? `` : ` (${pool.share}%)`),
label: {
color: '#FFFFFF',
color: '#b1b1b1',
overflow: 'break',
},
tooltip: {
backgroundColor: '#282d47',
backgroundColor: 'rgba(17, 19, 31, 1)',
borderRadius: 4,
shadowColor: 'rgba(0, 0, 0, 0.5)',
textStyle: {
color: '#FFFFFF',
color: '#b1b1b1',
},
borderColor: '#000',
formatter: () => {
if (this.poolsWindowPreference === '24h') {
return `<u><b>${pool.name} (${pool.share}%)</b></u><br>` +
return `<b style="color: white">${pool.name} (${pool.share}%)</b><br>` +
pool.lastEstimatedHashrate.toString() + ' PH/s' +
`<br>` + pool.blockCount.toString() + ` blocks`;
} else {
return `<u><b>${pool.name} (${pool.share}%)</b></u><br>` +
return `<b style="color: white">${pool.name} (${pool.share}%)</b><br>` +
pool.blockCount.toString() + ` blocks`;
}
}

View File

@ -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}` : '')
);
}
}

View File

@ -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