Merge branch 'master' into add-faq

This commit is contained in:
hunicus 2022-04-12 16:03:28 -04:00 committed by GitHub
commit 1348e953a6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
77 changed files with 2909 additions and 1401 deletions

View File

@ -601,9 +601,9 @@
} }
}, },
"node_modules/follow-redirects": { "node_modules/follow-redirects": {
"version": "1.14.7", "version": "1.14.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.7.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz",
"integrity": "sha512-+hbxoLbFMbRKDwohX8GkTataGqO6Jb7jGwpAlwgy2bIz25XtRm7KEzJM76R1WiNT5SwZkX4Y75SwBolkpmE7iQ==", "integrity": "sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w==",
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
@ -902,9 +902,9 @@
} }
}, },
"node_modules/minimist": { "node_modules/minimist": {
"version": "1.2.5", "version": "1.2.6",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==",
"dev": true "dev": true
}, },
"node_modules/mkdirp": { "node_modules/mkdirp": {
@ -1980,9 +1980,9 @@
} }
}, },
"follow-redirects": { "follow-redirects": {
"version": "1.14.7", "version": "1.14.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.7.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz",
"integrity": "sha512-+hbxoLbFMbRKDwohX8GkTataGqO6Jb7jGwpAlwgy2bIz25XtRm7KEzJM76R1WiNT5SwZkX4Y75SwBolkpmE7iQ==" "integrity": "sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w=="
}, },
"forwarded": { "forwarded": {
"version": "0.1.2", "version": "0.1.2",
@ -2206,9 +2206,9 @@
} }
}, },
"minimist": { "minimist": {
"version": "1.2.5", "version": "1.2.6",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==",
"dev": true "dev": true
}, },
"mkdirp": { "mkdirp": {

View File

@ -25,7 +25,7 @@ class BitcoinApi implements AbstractBitcoinApi {
.then((transaction: IBitcoinApi.Transaction) => { .then((transaction: IBitcoinApi.Transaction) => {
if (skipConversion) { if (skipConversion) {
transaction.vout.forEach((vout) => { transaction.vout.forEach((vout) => {
vout.value = vout.value * 100000000; vout.value = Math.round(vout.value * 100000000);
}); });
return transaction; return transaction;
} }
@ -143,11 +143,11 @@ class BitcoinApi implements AbstractBitcoinApi {
esploraTransaction.vout = transaction.vout.map((vout) => { esploraTransaction.vout = transaction.vout.map((vout) => {
return { return {
value: vout.value * 100000000, value: Math.round(vout.value * 100000000),
scriptpubkey: vout.scriptPubKey.hex, scriptpubkey: vout.scriptPubKey.hex,
scriptpubkey_address: vout.scriptPubKey && vout.scriptPubKey.address ? vout.scriptPubKey.address scriptpubkey_address: vout.scriptPubKey && vout.scriptPubKey.address ? vout.scriptPubKey.address
: vout.scriptPubKey.addresses ? vout.scriptPubKey.addresses[0] : '', : vout.scriptPubKey.addresses ? vout.scriptPubKey.addresses[0] : '',
scriptpubkey_asm: vout.scriptPubKey.asm ? this.convertScriptSigAsm(vout.scriptPubKey.asm) : '', scriptpubkey_asm: vout.scriptPubKey.asm ? this.convertScriptSigAsm(vout.scriptPubKey.hex) : '',
scriptpubkey_type: this.translateScriptPubKeyType(vout.scriptPubKey.type), scriptpubkey_type: this.translateScriptPubKeyType(vout.scriptPubKey.type),
}; };
}); });
@ -157,7 +157,7 @@ class BitcoinApi implements AbstractBitcoinApi {
is_coinbase: !!vin.coinbase, is_coinbase: !!vin.coinbase,
prevout: null, prevout: null,
scriptsig: vin.scriptSig && vin.scriptSig.hex || vin.coinbase || '', scriptsig: vin.scriptSig && vin.scriptSig.hex || vin.coinbase || '',
scriptsig_asm: vin.scriptSig && this.convertScriptSigAsm(vin.scriptSig.asm) || '', scriptsig_asm: vin.scriptSig && this.convertScriptSigAsm(vin.scriptSig.hex) || '',
sequence: vin.sequence, sequence: vin.sequence,
txid: vin.txid || '', txid: vin.txid || '',
vout: vin.vout || 0, vout: vin.vout || 0,
@ -212,6 +212,7 @@ class BitcoinApi implements AbstractBitcoinApi {
'witness_v0_scripthash': 'v0_p2wsh', 'witness_v0_scripthash': 'v0_p2wsh',
'witness_v1_taproot': 'v1_p2tr', 'witness_v1_taproot': 'v1_p2tr',
'nonstandard': 'nonstandard', 'nonstandard': 'nonstandard',
'multisig': 'multisig',
'nulldata': 'op_return' 'nulldata': 'op_return'
}; };
@ -235,7 +236,7 @@ class BitcoinApi implements AbstractBitcoinApi {
} else { } else {
mempoolEntry = await this.$getMempoolEntry(transaction.txid); mempoolEntry = await this.$getMempoolEntry(transaction.txid);
} }
transaction.fee = mempoolEntry.fees.base * 100000000; transaction.fee = Math.round(mempoolEntry.fees.base * 100000000);
return transaction; return transaction;
} }
@ -289,23 +290,68 @@ class BitcoinApi implements AbstractBitcoinApi {
return transaction; return transaction;
} }
private convertScriptSigAsm(str: string): string { private convertScriptSigAsm(hex: string): string {
const a = str.split(' '); const buf = Buffer.from(hex, 'hex');
const b: string[] = []; const b: string[] = [];
a.forEach((chunk) => {
if (chunk.substr(0, 3) === 'OP_') { let i = 0;
chunk = chunk.replace(/^OP_(\d+)/, 'OP_PUSHNUM_$1'); while (i < buf.length) {
chunk = chunk.replace('OP_CHECKSEQUENCEVERIFY', 'OP_CSV'); const op = buf[i];
b.push(chunk); if (op >= 0x01 && op <= 0x4e) {
} else { i++;
chunk = chunk.replace('[ALL]', '01'); let push: number;
if (chunk === '0') { if (op === 0x4c) {
b.push('OP_0'); push = buf.readUInt8(i);
b.push('OP_PUSHDATA1');
i += 1;
} else if (op === 0x4d) {
push = buf.readUInt16LE(i);
b.push('OP_PUSHDATA2');
i += 2;
} else if (op === 0x4e) {
push = buf.readUInt32LE(i);
b.push('OP_PUSHDATA4');
i += 4;
} else { } else {
b.push('OP_PUSHBYTES_' + Math.round(chunk.length / 2) + ' ' + chunk); push = op;
b.push('OP_PUSHBYTES_' + push);
} }
const data = buf.slice(i, i + push);
if (data.length !== push) {
break;
}
b.push(data.toString('hex'));
i += data.length;
} else {
if (op === 0x00) {
b.push('OP_0');
} else if (op === 0x4f) {
b.push('OP_PUSHNUM_NEG1');
} else if (op === 0xb1) {
b.push('OP_CLTV');
} else if (op === 0xb2) {
b.push('OP_CSV');
} else if (op === 0xba) {
b.push('OP_CHECKSIGADD');
} else {
const opcode = bitcoinjs.script.toASM([ op ]);
if (opcode && op < 0xfd) {
if (/^OP_(\d+)$/.test(opcode)) {
b.push(opcode.replace(/^OP_(\d+)$/, 'OP_PUSHNUM_$1'));
} else {
b.push(opcode);
}
} else {
b.push('OP_RETURN_' + op);
}
}
i += 1;
} }
}); }
return b.join(' '); return b.join(' ');
} }
@ -316,21 +362,21 @@ class BitcoinApi implements AbstractBitcoinApi {
if (vin.prevout.scriptpubkey_type === 'p2sh') { if (vin.prevout.scriptpubkey_type === 'p2sh') {
const redeemScript = vin.scriptsig_asm.split(' ').reverse()[0]; const redeemScript = vin.scriptsig_asm.split(' ').reverse()[0];
vin.inner_redeemscript_asm = this.convertScriptSigAsm(bitcoinjs.script.toASM(Buffer.from(redeemScript, 'hex'))); vin.inner_redeemscript_asm = this.convertScriptSigAsm(redeemScript);
if (vin.witness && vin.witness.length > 2) { if (vin.witness && vin.witness.length > 2) {
const witnessScript = vin.witness[vin.witness.length - 1]; const witnessScript = vin.witness[vin.witness.length - 1];
vin.inner_witnessscript_asm = this.convertScriptSigAsm(bitcoinjs.script.toASM(Buffer.from(witnessScript, 'hex'))); vin.inner_witnessscript_asm = this.convertScriptSigAsm(witnessScript);
} }
} }
if (vin.prevout.scriptpubkey_type === 'v0_p2wsh' && vin.witness) { if (vin.prevout.scriptpubkey_type === 'v0_p2wsh' && vin.witness) {
const witnessScript = vin.witness[vin.witness.length - 1]; const witnessScript = vin.witness[vin.witness.length - 1];
vin.inner_witnessscript_asm = this.convertScriptSigAsm(bitcoinjs.script.toASM(Buffer.from(witnessScript, 'hex'))); vin.inner_witnessscript_asm = this.convertScriptSigAsm(witnessScript);
} }
if (vin.prevout.scriptpubkey_type === 'v1_p2tr' && vin.witness && vin.witness.length > 1) { if (vin.prevout.scriptpubkey_type === 'v1_p2tr' && vin.witness && vin.witness.length > 1) {
const witnessScript = vin.witness[vin.witness.length - 2]; const witnessScript = vin.witness[vin.witness.length - 2];
vin.inner_witnessscript_asm = this.convertScriptSigAsm(bitcoinjs.script.toASM(Buffer.from(witnessScript, 'hex'))); vin.inner_witnessscript_asm = this.convertScriptSigAsm(witnessScript);
} }
} }

View File

@ -23,6 +23,7 @@ class Blocks {
private newBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => void)[] = []; private newBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => void)[] = [];
private blockIndexingStarted = false; private blockIndexingStarted = false;
public blockIndexingCompleted = false; public blockIndexingCompleted = false;
public reindexFlag = true; // Always re-index the latest indexed data in case the node went offline with an invalid block tip (reorg)
constructor() { } constructor() { }
@ -135,9 +136,16 @@ class Blocks {
} else { } else {
pool = await poolsRepository.$getUnknownPool(); pool = await poolsRepository.$getUnknownPool();
} }
if (!pool) { // Something is wrong with the pools table, ignore pool indexing
logger.err('Unable to find pool, nor getting the unknown pool. Is the "pools" table empty?');
return blockExtended;
}
blockExtended.extras.pool = { blockExtended.extras.pool = {
id: pool.id, id: pool.id,
name: pool.name name: pool.name,
slug: pool.slug,
}; };
} }
@ -182,16 +190,19 @@ class Blocks {
* [INDEXING] Index all blocks metadata for the mining dashboard * [INDEXING] Index all blocks metadata for the mining dashboard
*/ */
public async $generateBlockDatabase() { public async $generateBlockDatabase() {
if (this.blockIndexingStarted) { if (this.blockIndexingStarted && !this.reindexFlag) {
return; return;
} }
this.reindexFlag = false;
const blockchainInfo = await bitcoinClient.getBlockchainInfo(); const blockchainInfo = await bitcoinClient.getBlockchainInfo();
if (blockchainInfo.blocks !== blockchainInfo.headers) { // Wait for node to sync if (blockchainInfo.blocks !== blockchainInfo.headers) { // Wait for node to sync
return; return;
} }
this.blockIndexingStarted = true; this.blockIndexingStarted = true;
this.blockIndexingCompleted = false;
try { try {
let currentBlockHeight = blockchainInfo.blocks; let currentBlockHeight = blockchainInfo.blocks;
@ -309,6 +320,12 @@ class Blocks {
if (Common.indexingEnabled()) { if (Common.indexingEnabled()) {
await blocksRepository.$saveBlockInDatabase(blockExtended); await blocksRepository.$saveBlockInDatabase(blockExtended);
// If the last 10 blocks chain is not valid, re-index them (reorg)
const chainValid = await blocksRepository.$validateRecentBlocks();
if (!chainValid) {
this.reindexFlag = true;
}
} }
if (block.height % 2016 === 0) { if (block.height % 2016 === 0) {

View File

@ -6,7 +6,7 @@ import logger from '../logger';
const sleep = (ms: number) => new Promise(res => setTimeout(res, ms)); const sleep = (ms: number) => new Promise(res => setTimeout(res, ms));
class DatabaseMigration { class DatabaseMigration {
private static currentVersion = 16; private static currentVersion = 17;
private queryTimeout = 120000; private queryTimeout = 120000;
private statisticsAddedIndexed = false; private statisticsAddedIndexed = false;
@ -180,6 +180,10 @@ class DatabaseMigration {
await this.$executeQuery(connection, 'TRUNCATE hashrates;'); // Need to re-index because we changed timestamps await this.$executeQuery(connection, 'TRUNCATE hashrates;'); // Need to re-index because we changed timestamps
} }
if (databaseSchemaVersion < 17 && isBitcoin === true) {
await this.$executeQuery(connection, 'ALTER TABLE `pools` ADD `slug` CHAR(50) NULL');
}
connection.release(); connection.release();
} catch (e) { } catch (e) {
connection.release(); connection.release();

View File

@ -5,6 +5,7 @@ import HashratesRepository from '../repositories/HashratesRepository';
import bitcoinClient from './bitcoin/bitcoin-client'; import bitcoinClient from './bitcoin/bitcoin-client';
import logger from '../logger'; import logger from '../logger';
import blocks from './blocks'; import blocks from './blocks';
import { Common } from './common';
class Mining { class Mining {
hashrateIndexingStarted = false; hashrateIndexingStarted = false;
@ -13,6 +14,26 @@ class Mining {
constructor() { constructor() {
} }
/**
* Get historical block reward and total fee
*/
public async $getHistoricalBlockFees(interval: string | null = null): Promise<any> {
return await BlocksRepository.$getHistoricalBlockFees(
this.getTimeRange(interval),
Common.getSqlInterval(interval)
);
}
/**
* Get historical block rewards
*/
public async $getHistoricalBlockRewards(interval: string | null = null): Promise<any> {
return await BlocksRepository.$getHistoricalBlockRewards(
this.getTimeRange(interval),
Common.getSqlInterval(interval)
);
}
/** /**
* Generate high level overview of the pool ranks and general stats * Generate high level overview of the pool ranks and general stats
*/ */
@ -33,7 +54,8 @@ class Mining {
link: poolInfo.link, link: poolInfo.link,
blockCount: poolInfo.blockCount, blockCount: poolInfo.blockCount,
rank: rank++, rank: rank++,
emptyBlocks: emptyBlocksCount.length > 0 ? emptyBlocksCount[0]['count'] : 0 emptyBlocks: emptyBlocksCount.length > 0 ? emptyBlocksCount[0]['count'] : 0,
slug: poolInfo.slug,
}; };
poolsStats.push(poolStat); poolsStats.push(poolStat);
}); });
@ -44,8 +66,8 @@ class Mining {
const blockCount: number = await BlocksRepository.$blockCount(null, interval); const blockCount: number = await BlocksRepository.$blockCount(null, interval);
poolsStatistics['blockCount'] = blockCount; poolsStatistics['blockCount'] = blockCount;
const blockHeightTip = await bitcoinClient.getBlockCount(); const totalBlock24h: number = await BlocksRepository.$blockCount(null, '24h');
const lastBlockHashrate = await bitcoinClient.getNetworkHashPs(144, blockHeightTip); const lastBlockHashrate = await bitcoinClient.getNetworkHashPs(totalBlock24h);
poolsStatistics['lastEstimatedHashrate'] = lastBlockHashrate; poolsStatistics['lastEstimatedHashrate'] = lastBlockHashrate;
return poolsStatistics; return poolsStatistics;
@ -54,19 +76,37 @@ class Mining {
/** /**
* Get all mining pool stats for a pool * Get all mining pool stats for a pool
*/ */
public async $getPoolStat(poolId: number): Promise<object> { public async $getPoolStat(slug: string): Promise<object> {
const pool = await PoolsRepository.$getPool(poolId); const pool = await PoolsRepository.$getPool(slug);
if (!pool) { if (!pool) {
throw new Error(`This mining pool does not exist`); throw new Error(`This mining pool does not exist`);
} }
const blockCount: number = await BlocksRepository.$blockCount(poolId); const blockCount: number = await BlocksRepository.$blockCount(pool.id);
const emptyBlocksCount = await BlocksRepository.$countEmptyBlocks(poolId); const totalBlock: number = await BlocksRepository.$blockCount(null, null);
const blockCount24h: number = await BlocksRepository.$blockCount(pool.id, '24h');
const totalBlock24h: number = await BlocksRepository.$blockCount(null, '24h');
const blockCount1w: number = await BlocksRepository.$blockCount(pool.id, '1w');
const totalBlock1w: number = await BlocksRepository.$blockCount(null, '1w');
const currentEstimatedkHashrate = await bitcoinClient.getNetworkHashPs(totalBlock24h);
return { return {
pool: pool, pool: pool,
blockCount: blockCount, blockCount: {
emptyBlocks: emptyBlocksCount.length > 0 ? emptyBlocksCount[0]['count'] : 0, 'all': blockCount,
'24h': blockCount24h,
'1w': blockCount1w,
},
blockShare: {
'all': blockCount / totalBlock,
'24h': blockCount24h / totalBlock24h,
'1w': blockCount1w / totalBlock1w,
},
estimatedHashrate: currentEstimatedkHashrate * (blockCount24h / totalBlock24h),
reportedHashrate: null,
}; };
} }
@ -85,16 +125,23 @@ class Mining {
return; return;
} }
// We only run this once a week
const latestTimestamp = await HashratesRepository.$getLatestRunTimestamp('last_weekly_hashrates_indexing') * 1000;
const now = new Date(); const now = new Date();
if (now.getTime() - latestTimestamp < 604800000) {
return;
}
try { try {
this.weeklyHashrateIndexingStarted = true; this.weeklyHashrateIndexingStarted = true;
// We only run this once a week
const latestTimestamp = await HashratesRepository.$getLatestRunTimestamp('last_weekly_hashrates_indexing') * 1000;
if (now.getTime() - latestTimestamp < 604800000) {
this.weeklyHashrateIndexingStarted = false;
return;
}
} catch (e) {
this.weeklyHashrateIndexingStarted = false;
throw e;
}
try {
logger.info(`Indexing mining pools weekly hashrates`); logger.info(`Indexing mining pools weekly hashrates`);
const indexedTimestamp = await HashratesRepository.$getWeeklyHashrateTimestamps(); const indexedTimestamp = await HashratesRepository.$getWeeklyHashrateTimestamps();
@ -185,16 +232,23 @@ class Mining {
return; return;
} }
// We only run this once a day
const latestTimestamp = await HashratesRepository.$getLatestRunTimestamp('last_hashrates_indexing') * 1000;
const now = new Date().getTime(); const now = new Date().getTime();
if (now - latestTimestamp < 86400000) {
return;
}
try { try {
this.hashrateIndexingStarted = true; this.hashrateIndexingStarted = true;
// We only run this once a day
const latestTimestamp = await HashratesRepository.$getLatestRunTimestamp('last_hashrates_indexing') * 1000;
if (now - latestTimestamp < 86400000) {
this.hashrateIndexingStarted = false;
return;
}
} catch (e) {
this.hashrateIndexingStarted = false;
throw e;
}
try {
logger.info(`Indexing network daily hashrate`); logger.info(`Indexing network daily hashrate`);
const indexedTimestamp = (await HashratesRepository.$getNetworkDailyHashrate(null)).map(hashrate => hashrate.timestamp); const indexedTimestamp = (await HashratesRepository.$getNetworkDailyHashrate(null)).map(hashrate => hashrate.timestamp);
@ -288,6 +342,21 @@ class Mining {
return date; return date;
} }
private getTimeRange(interval: string | null): number {
switch (interval) {
case '3y': return 43200; // 12h
case '2y': return 28800; // 8h
case '1y': return 28800; // 8h
case '6m': return 10800; // 3h
case '3m': return 7200; // 2h
case '1m': return 1800; // 30min
case '1w': return 300; // 5min
case '3d': return 1;
case '24h': return 1;
default: return 86400; // 24h
}
}
} }
export default new Mining(); export default new Mining();

View File

@ -8,29 +8,20 @@ interface Pool {
link: string; link: string;
regexes: string[]; regexes: string[];
addresses: string[]; addresses: string[];
slug: string;
} }
class PoolsParser { class PoolsParser {
slugWarnFlag = false;
/** /**
* Parse the pools.json file, consolidate the data and dump it into the database * Parse the pools.json file, consolidate the data and dump it into the database
*/ */
public async migratePoolsJson() { public async migratePoolsJson(poolsJson: object) {
if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) === false) { if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) === false) {
return; return;
} }
logger.debug('Importing pools.json to the database, open ./pools.json');
let poolsJson: object = {};
try {
const fileContent: string = readFileSync('./pools.json', 'utf8');
poolsJson = JSON.parse(fileContent);
} catch (e) {
logger.err('Unable to open ./pools.json, does the file exist?');
await this.insertUnknownPool();
return;
}
// First we save every entries without paying attention to pool duplication // First we save every entries without paying attention to pool duplication
const poolsDuplicated: Pool[] = []; const poolsDuplicated: Pool[] = [];
@ -42,6 +33,7 @@ class PoolsParser {
'link': (<Pool>coinbaseTags[i][1]).link, 'link': (<Pool>coinbaseTags[i][1]).link,
'regexes': [coinbaseTags[i][0]], 'regexes': [coinbaseTags[i][0]],
'addresses': [], 'addresses': [],
'slug': ''
}); });
} }
logger.debug('Parse payout_addresses'); logger.debug('Parse payout_addresses');
@ -52,6 +44,7 @@ class PoolsParser {
'link': (<Pool>addressesTags[i][1]).link, 'link': (<Pool>addressesTags[i][1]).link,
'regexes': [], 'regexes': [],
'addresses': [addressesTags[i][0]], 'addresses': [addressesTags[i][0]],
'slug': ''
}); });
} }
@ -91,13 +84,29 @@ class PoolsParser {
const finalPoolName = poolNames[i].replace(`'`, `''`); // To support single quote in names when doing db queries const finalPoolName = poolNames[i].replace(`'`, `''`); // To support single quote in names when doing db queries
let slug: string | undefined;
try {
slug = poolsJson['slugs'][poolNames[i]];
} catch (e) {
if (this.slugWarnFlag === false) {
logger.warn(`pools.json does not seem to contain the 'slugs' object`);
this.slugWarnFlag = true;
}
}
if (slug === undefined) {
// Only keep alphanumerical
slug = poolNames[i].replace(/[^a-z0-9]/gi, '').toLowerCase();
logger.debug(`No slug found for '${poolNames[i]}', generating it => '${slug}'`);
}
if (existingPools.find((pool) => pool.name === poolNames[i]) !== undefined) { if (existingPools.find((pool) => pool.name === poolNames[i]) !== undefined) {
logger.debug(`Update '${finalPoolName}' mining pool`);
finalPoolDataUpdate.push({ finalPoolDataUpdate.push({
'name': finalPoolName, 'name': finalPoolName,
'link': match[0].link, 'link': match[0].link,
'regexes': allRegexes, 'regexes': allRegexes,
'addresses': allAddresses, 'addresses': allAddresses,
'slug': slug
}); });
} else { } else {
logger.debug(`Add '${finalPoolName}' mining pool`); logger.debug(`Add '${finalPoolName}' mining pool`);
@ -106,6 +115,7 @@ class PoolsParser {
'link': match[0].link, 'link': match[0].link,
'regexes': allRegexes, 'regexes': allRegexes,
'addresses': allAddresses, 'addresses': allAddresses,
'slug': slug
}); });
} }
} }
@ -113,10 +123,11 @@ class PoolsParser {
logger.debug(`Update pools table now`); logger.debug(`Update pools table now`);
// Add new mining pools into the database // Add new mining pools into the database
let queryAdd: string = 'INSERT INTO pools(name, link, regexes, addresses) VALUES '; let queryAdd: string = 'INSERT INTO pools(name, link, regexes, addresses, slug) VALUES ';
for (let i = 0; i < finalPoolDataAdd.length; ++i) { for (let i = 0; i < finalPoolDataAdd.length; ++i) {
queryAdd += `('${finalPoolDataAdd[i].name}', '${finalPoolDataAdd[i].link}', queryAdd += `('${finalPoolDataAdd[i].name}', '${finalPoolDataAdd[i].link}',
'${JSON.stringify(finalPoolDataAdd[i].regexes)}', '${JSON.stringify(finalPoolDataAdd[i].addresses)}'),`; '${JSON.stringify(finalPoolDataAdd[i].regexes)}', '${JSON.stringify(finalPoolDataAdd[i].addresses)}',
${JSON.stringify(finalPoolDataAdd[i].slug)}),`;
} }
queryAdd = queryAdd.slice(0, -1) + ';'; queryAdd = queryAdd.slice(0, -1) + ';';
@ -126,7 +137,8 @@ class PoolsParser {
updateQueries.push(` updateQueries.push(`
UPDATE pools UPDATE pools
SET name='${finalPoolDataUpdate[i].name}', link='${finalPoolDataUpdate[i].link}', SET name='${finalPoolDataUpdate[i].name}', link='${finalPoolDataUpdate[i].link}',
regexes='${JSON.stringify(finalPoolDataUpdate[i].regexes)}', addresses='${JSON.stringify(finalPoolDataUpdate[i].addresses)}' regexes='${JSON.stringify(finalPoolDataUpdate[i].regexes)}', addresses='${JSON.stringify(finalPoolDataUpdate[i].addresses)}',
slug='${finalPoolDataUpdate[i].slug}'
WHERE name='${finalPoolDataUpdate[i].name}' WHERE name='${finalPoolDataUpdate[i].name}'
;`); ;`);
} }
@ -156,11 +168,17 @@ class PoolsParser {
try { try {
const [rows]: any[] = await connection.query({ sql: 'SELECT name from pools where name="Unknown"', timeout: 120000 }); const [rows]: any[] = await connection.query({ sql: 'SELECT name from pools where name="Unknown"', timeout: 120000 });
if (rows.length === 0) { if (rows.length === 0) {
logger.debug('Manually inserting "Unknown" mining pool into the databse');
await connection.query({ await connection.query({
sql: `INSERT INTO pools(name, link, regexes, addresses) sql: `INSERT INTO pools(name, link, regexes, addresses, slug)
VALUES("Unknown", "https://learnmeabitcoin.com/technical/coinbase-transaction", "[]", "[]"); VALUES("Unknown", "https://learnmeabitcoin.com/technical/coinbase-transaction", "[]", "[]", "unknown");
`}); `});
} else {
await connection.query(`UPDATE pools
SET name='Unknown', link='https://learnmeabitcoin.com/technical/coinbase-transaction',
regexes='[]', addresses='[]',
slug='unknown'
WHERE name='Unknown'
`);
} }
} catch (e) { } catch (e) {
logger.err('Unable to insert "Unknown" mining pool'); logger.err('Unable to insert "Unknown" mining pool');

View File

@ -22,12 +22,13 @@ import loadingIndicators from './api/loading-indicators';
import mempool from './api/mempool'; import mempool from './api/mempool';
import elementsParser from './api/liquid/elements-parser'; import elementsParser from './api/liquid/elements-parser';
import databaseMigration from './api/database-migration'; import databaseMigration from './api/database-migration';
import poolsParser from './api/pools-parser';
import syncAssets from './sync-assets'; import syncAssets from './sync-assets';
import icons from './api/liquid/icons'; import icons from './api/liquid/icons';
import { Common } from './api/common'; import { Common } from './api/common';
import mining from './api/mining'; import mining from './api/mining';
import HashratesRepository from './repositories/HashratesRepository'; import HashratesRepository from './repositories/HashratesRepository';
import BlocksRepository from './repositories/BlocksRepository';
import poolsUpdater from './tasks/pools-updater';
class Server { class Server {
private wss: WebSocket.Server | undefined; private wss: WebSocket.Server | undefined;
@ -99,7 +100,6 @@ class Server {
await databaseMigration.$initializeOrMigrateDatabase(); await databaseMigration.$initializeOrMigrateDatabase();
if (Common.indexingEnabled()) { if (Common.indexingEnabled()) {
await this.$resetHashratesIndexingState(); await this.$resetHashratesIndexingState();
await poolsParser.migratePoolsJson();
} }
} catch (e) { } catch (e) {
throw new Error(e instanceof Error ? e.message : 'Error'); throw new Error(e instanceof Error ? e.message : 'Error');
@ -179,6 +179,11 @@ class Server {
} }
try { try {
await poolsUpdater.updatePoolsJson();
if (blocks.reindexFlag) {
await BlocksRepository.$deleteBlocks(10);
await HashratesRepository.$deleteLastEntries();
}
blocks.$generateBlockDatabase(); blocks.$generateBlockDatabase();
await mining.$generateNetworkHashrateHistory(); await mining.$generateNetworkHashrateHistory();
await mining.$generatePoolHashrateHistory(); await mining.$generatePoolHashrateHistory();
@ -301,18 +306,18 @@ class Server {
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/2y', routes.$getPools.bind(routes, '2y')) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/2y', routes.$getPools.bind(routes, '2y'))
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/3y', routes.$getPools.bind(routes, '3y')) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/3y', routes.$getPools.bind(routes, '3y'))
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/all', routes.$getPools.bind(routes, 'all')) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/all', routes.$getPools.bind(routes, 'all'))
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:poolId/hashrate', routes.$getPoolHistoricalHashrate) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:slug/hashrate', routes.$getPoolHistoricalHashrate)
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:poolId/blocks', routes.$getPoolBlocks) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:slug/blocks', routes.$getPoolBlocks)
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:poolId/blocks/:height', routes.$getPoolBlocks) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:slug/blocks/:height', routes.$getPoolBlocks)
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:poolId', routes.$getPool) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:slug', routes.$getPool)
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:poolId/:interval', routes.$getPool) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:slug/: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/hashrate/pools', routes.$getPoolsHistoricalHashrate) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/hashrate/pools', routes.$getPoolsHistoricalHashrate)
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/hashrate/pools/:interval', routes.$getPoolsHistoricalHashrate) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/hashrate/pools/:interval', routes.$getPoolsHistoricalHashrate)
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/hashrate', routes.$getHistoricalHashrate) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/hashrate', routes.$getHistoricalHashrate)
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/hashrate/:interval', routes.$getHistoricalHashrate) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/hashrate/:interval', routes.$getHistoricalHashrate)
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/reward-stats/:blockCount', routes.$getRewardStats) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/reward-stats/:blockCount', routes.$getRewardStats)
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/fees/:interval', routes.$getHistoricalBlockFees)
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/rewards/:interval', routes.$getHistoricalBlockRewards)
; ;
} }

View File

@ -6,6 +6,7 @@ export interface PoolTag {
link: string; link: string;
regexes: string; // JSON array regexes: string; // JSON array
addresses: string; // JSON array addresses: string; // JSON array
slug: string;
} }
export interface PoolInfo { export interface PoolInfo {
@ -13,6 +14,7 @@ export interface PoolInfo {
name: string; name: string;
link: string; link: string;
blockCount: number; blockCount: number;
slug: string;
} }
export interface PoolStats extends PoolInfo { export interface PoolStats extends PoolInfo {
@ -87,6 +89,7 @@ export interface BlockExtension {
pool?: { pool?: {
id: number; id: number;
name: string; name: string;
slug: string;
}; };
avgFee?: number; avgFee?: number;
avgFeeRate?: number; avgFeeRate?: number;

View File

@ -3,15 +3,18 @@ import { DB } from '../database';
import logger from '../logger'; import logger from '../logger';
import { Common } from '../api/common'; import { Common } from '../api/common';
import { prepareBlock } from '../utils/blocks-utils'; import { prepareBlock } from '../utils/blocks-utils';
import PoolsRepository from './PoolsRepository';
class BlocksRepository { class BlocksRepository {
/** /**
* Save indexed block data in the database * Save indexed block data in the database
*/ */
public async $saveBlockInDatabase(block: BlockExtended) { public async $saveBlockInDatabase(block: BlockExtended) {
const connection = await DB.getConnection(); let connection;
try { try {
connection = await DB.getConnection();
const query = `INSERT INTO blocks( const query = `INSERT INTO blocks(
height, hash, blockTimestamp, size, height, hash, blockTimestamp, size,
weight, tx_count, coinbase_raw, difficulty, weight, tx_count, coinbase_raw, difficulty,
@ -71,8 +74,9 @@ class BlocksRepository {
return []; return [];
} }
const connection = await DB.getConnection(); let connection;
try { try {
connection = await DB.getConnection();
const [rows]: any[] = await connection.query(` const [rows]: any[] = await connection.query(`
SELECT height SELECT height
FROM blocks FROM blocks
@ -117,8 +121,9 @@ class BlocksRepository {
query += ` GROUP by pools.id`; query += ` GROUP by pools.id`;
const connection = await DB.getConnection(); let connection;
try { try {
connection = await DB.getConnection();
const [rows] = await connection.query(query, params); const [rows] = await connection.query(query, params);
connection.release(); connection.release();
@ -154,8 +159,9 @@ class BlocksRepository {
query += ` blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`; query += ` blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`;
} }
const connection = await DB.getConnection(); let connection;
try { try {
connection = await DB.getConnection();
const [rows] = await connection.query(query, params); const [rows] = await connection.query(query, params);
connection.release(); connection.release();
@ -193,8 +199,9 @@ class BlocksRepository {
} }
query += ` blockTimestamp BETWEEN FROM_UNIXTIME('${from}') AND FROM_UNIXTIME('${to}')`; query += ` blockTimestamp BETWEEN FROM_UNIXTIME('${from}') AND FROM_UNIXTIME('${to}')`;
const connection = await DB.getConnection(); let connection;
try { try {
connection = await DB.getConnection();
const [rows] = await connection.query(query, params); const [rows] = await connection.query(query, params);
connection.release(); connection.release();
@ -215,8 +222,9 @@ class BlocksRepository {
ORDER BY height ORDER BY height
LIMIT 1;`; LIMIT 1;`;
const connection = await DB.getConnection(); let connection;
try { try {
connection = await DB.getConnection();
const [rows]: any[] = await connection.query(query); const [rows]: any[] = await connection.query(query);
connection.release(); connection.release();
@ -235,13 +243,18 @@ class BlocksRepository {
/** /**
* Get blocks mined by a specific mining pool * Get blocks mined by a specific mining pool
*/ */
public async $getBlocksByPool(poolId: number, startHeight: number | undefined = undefined): Promise<object[]> { public async $getBlocksByPool(slug: string, startHeight: number | undefined = undefined): Promise<object[]> {
const pool = await PoolsRepository.$getPool(slug);
if (!pool) {
throw new Error(`This mining pool does not exist`);
}
const params: any[] = []; const params: any[] = [];
let query = ` SELECT *, UNIX_TIMESTAMP(blocks.blockTimestamp) as blockTimestamp, let query = ` SELECT *, UNIX_TIMESTAMP(blocks.blockTimestamp) as blockTimestamp,
previous_block_hash as previousblockhash previous_block_hash as previousblockhash
FROM blocks FROM blocks
WHERE pool_id = ?`; WHERE pool_id = ?`;
params.push(poolId); params.push(pool.id);
if (startHeight !== undefined) { if (startHeight !== undefined) {
query += ` AND height < ?`; query += ` AND height < ?`;
@ -251,8 +264,9 @@ class BlocksRepository {
query += ` ORDER BY height DESC query += ` ORDER BY height DESC
LIMIT 10`; LIMIT 10`;
const connection = await DB.getConnection(); let connection;
try { try {
connection = await DB.getConnection();
const [rows] = await connection.query(query, params); const [rows] = await connection.query(query, params);
connection.release(); connection.release();
@ -273,11 +287,12 @@ class BlocksRepository {
* Get one block by height * Get one block by height
*/ */
public async $getBlockByHeight(height: number): Promise<object | null> { public async $getBlockByHeight(height: number): Promise<object | null> {
const connection = await DB.getConnection(); let connection;
try { try {
connection = await DB.getConnection();
const [rows]: any[] = await connection.query(` const [rows]: any[] = await connection.query(`
SELECT *, UNIX_TIMESTAMP(blocks.blockTimestamp) as blockTimestamp, SELECT *, UNIX_TIMESTAMP(blocks.blockTimestamp) as blockTimestamp,
pools.id as pool_id, pools.name as pool_name, pools.link as pool_link, pools.id as pool_id, pools.name as pool_name, pools.link as pool_link, pools.slug as pool_slug,
pools.addresses as pool_addresses, pools.regexes as pool_regexes, pools.addresses as pool_addresses, pools.regexes as pool_regexes,
previous_block_hash as previousblockhash previous_block_hash as previousblockhash
FROM blocks FROM blocks
@ -304,8 +319,6 @@ class BlocksRepository {
public async $getBlocksDifficulty(interval: string | null): Promise<object[]> { public async $getBlocksDifficulty(interval: string | null): Promise<object[]> {
interval = Common.getSqlInterval(interval); interval = Common.getSqlInterval(interval);
const connection = await DB.getConnection();
// :D ... Yeah don't ask me about this one https://stackoverflow.com/a/40303162 // :D ... Yeah don't ask me about this one https://stackoverflow.com/a/40303162
// Basically, using temporary user defined fields, we are able to extract all // Basically, using temporary user defined fields, we are able to extract all
// difficulty adjustments from the blocks tables. // difficulty adjustments from the blocks tables.
@ -338,14 +351,17 @@ class BlocksRepository {
ORDER BY t.height ORDER BY t.height
`; `;
let connection;
try { try {
connection = await DB.getConnection();
const [rows]: any[] = await connection.query(query); const [rows]: any[] = await connection.query(query);
connection.release(); connection.release();
for (let row of rows) { for (const row of rows) {
delete row['rn']; delete row['rn'];
} }
connection.release();
return rows; return rows;
} catch (e) { } catch (e) {
connection.release(); connection.release();
@ -354,23 +370,6 @@ class BlocksRepository {
} }
} }
/**
* Return oldest blocks height
*/
public async $getOldestIndexedBlockHeight(): Promise<number> {
const connection = await DB.getConnection();
try {
const [rows]: any[] = await connection.query(`SELECT MIN(height) as minHeight FROM blocks`);
connection.release();
return rows[0].minHeight;
} catch (e) {
connection.release();
logger.err('$getOldestIndexedBlockHeight() error' + (e instanceof Error ? e.message : e));
throw e;
}
}
/** /**
* Get general block stats * Get general block stats
*/ */
@ -380,10 +379,14 @@ class BlocksRepository {
connection = await DB.getConnection(); connection = await DB.getConnection();
// We need to use a subquery // We need to use a subquery
const query = `SELECT SUM(reward) as totalReward, SUM(fees) as totalFee, SUM(tx_count) as totalTx const query = `
FROM (SELECT reward, fees, tx_count FROM blocks ORDER by height DESC LIMIT ${blockCount}) as sub`; SELECT MIN(height) as startBlock, MAX(height) as endBlock, SUM(reward) as totalReward, SUM(fees) as totalFee, SUM(tx_count) as totalTx
FROM
(SELECT height, reward, fees, tx_count FROM blocks
ORDER by height DESC
LIMIT ?) as sub`;
const [rows]: any = await connection.query(query); const [rows]: any = await connection.query(query, [blockCount]);
connection.release(); connection.release();
return rows[0]; return rows[0];
@ -393,6 +396,107 @@ class BlocksRepository {
throw e; throw e;
} }
} }
/*
* Check if the last 10 blocks chain is valid
*/
public async $validateRecentBlocks(): Promise<boolean> {
let connection;
try {
connection = await DB.getConnection();
const [lastBlocks] = await connection.query(`SELECT height, hash, previous_block_hash FROM blocks ORDER BY height DESC LIMIT 10`);
connection.release();
for (let i = 0; i < lastBlocks.length - 1; ++i) {
if (lastBlocks[i].previous_block_hash !== lastBlocks[i + 1].hash) {
logger.notice(`Chain divergence detected at block ${lastBlocks[i].height}, re-indexing most recent data`);
return false;
}
}
return true;
} catch (e) {
connection.release();
return true; // Don't do anything if there is a db error
}
}
/**
* Delete $count blocks from the database
*/
public async $deleteBlocks(count: number) {
let connection;
try {
connection = await DB.getConnection();
logger.debug(`Delete ${count} most recent indexed blocks from the database`);
await connection.query(`DELETE FROM blocks ORDER BY height DESC LIMIT ${count};`);
} catch (e) {
logger.err('$deleteBlocks() error' + (e instanceof Error ? e.message : e));
}
connection.release();
}
/**
* Get the historical averaged block reward and total fees
*/
public async $getHistoricalBlockFees(div: number, interval: string | null): Promise<any> {
let connection;
try {
connection = await DB.getConnection();
let query = `SELECT CAST(AVG(UNIX_TIMESTAMP(blockTimestamp)) as INT) as timestamp,
CAST(AVG(fees) as INT) as avg_fees
FROM blocks`;
if (interval !== null) {
query += ` WHERE blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`;
}
query += ` GROUP BY UNIX_TIMESTAMP(blockTimestamp) DIV ${div}`;
const [rows]: any = await connection.query(query);
connection.release();
return rows;
} catch (e) {
connection.release();
logger.err('$getHistoricalBlockFees() error: ' + (e instanceof Error ? e.message : e));
throw e;
}
}
/**
* Get the historical averaged block rewards
*/
public async $getHistoricalBlockRewards(div: number, interval: string | null): Promise<any> {
let connection;
try {
connection = await DB.getConnection();
let query = `SELECT CAST(AVG(UNIX_TIMESTAMP(blockTimestamp)) as INT) as timestamp,
CAST(AVG(reward) as INT) as avg_rewards
FROM blocks`;
if (interval !== null) {
query += ` WHERE blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`;
}
query += ` GROUP BY UNIX_TIMESTAMP(blockTimestamp) DIV ${div}`;
const [rows]: any = await connection.query(query);
connection.release();
return rows;
} catch (e) {
connection.release();
logger.err('$getHistoricalBlockRewards() error: ' + (e instanceof Error ? e.message : e));
throw e;
}
}
} }
export default new BlocksRepository(); export default new BlocksRepository();

View File

@ -120,8 +120,11 @@ class HashratesRepository {
/** /**
* Returns a pool hashrate history * Returns a pool hashrate history
*/ */
public async $getPoolWeeklyHashrate(poolId: number): Promise<any[]> { public async $getPoolWeeklyHashrate(slug: string): Promise<any[]> {
const connection = await DB.getConnection(); const pool = await PoolsRepository.$getPool(slug);
if (!pool) {
throw new Error(`This mining pool does not exist`);
}
// Find hashrate boundaries // Find hashrate boundaries
let query = `SELECT MIN(hashrate_timestamp) as firstTimestamp, MAX(hashrate_timestamp) as lastTimestamp let query = `SELECT MIN(hashrate_timestamp) as firstTimestamp, MAX(hashrate_timestamp) as lastTimestamp
@ -134,8 +137,11 @@ class HashratesRepository {
firstTimestamp: '1970-01-01', firstTimestamp: '1970-01-01',
lastTimestamp: '9999-01-01' lastTimestamp: '9999-01-01'
}; };
let connection;
try { try {
const [rows]: any[] = await connection.query(query, [poolId]); connection = await DB.getConnection();
const [rows]: any[] = await connection.query(query, [pool.id]);
boundaries = rows[0]; boundaries = rows[0];
connection.release(); connection.release();
} catch (e) { } catch (e) {
@ -152,7 +158,7 @@ class HashratesRepository {
ORDER by hashrate_timestamp`; ORDER by hashrate_timestamp`;
try { try {
const [rows]: any[] = await connection.query(query, [boundaries.firstTimestamp, boundaries.lastTimestamp, poolId]); const [rows]: any[] = await connection.query(query, [boundaries.firstTimestamp, boundaries.lastTimestamp, pool.id]);
connection.release(); connection.release();
return rows; return rows;
@ -163,6 +169,9 @@ class HashratesRepository {
} }
} }
/**
* Set latest run timestamp
*/
public async $setLatestRunTimestamp(key: string, val: any = null) { public async $setLatestRunTimestamp(key: string, val: any = null) {
const connection = await DB.getConnection(); const connection = await DB.getConnection();
const query = `UPDATE state SET number = ? WHERE name = ?`; const query = `UPDATE state SET number = ? WHERE name = ?`;
@ -175,6 +184,9 @@ class HashratesRepository {
} }
} }
/**
* Get latest run timestamp
*/
public async $getLatestRunTimestamp(key: string): Promise<number> { public async $getLatestRunTimestamp(key: string): Promise<number> {
const connection = await DB.getConnection(); const connection = await DB.getConnection();
const query = `SELECT number FROM state WHERE name = ?`; const query = `SELECT number FROM state WHERE name = ?`;
@ -193,6 +205,29 @@ class HashratesRepository {
throw e; throw e;
} }
} }
/**
* Delete most recent data points for re-indexing
*/
public async $deleteLastEntries() {
logger.debug(`Delete latest hashrates data points from the database`);
let connection;
try {
connection = await DB.getConnection();
const [rows] = await connection.query(`SELECT MAX(hashrate_timestamp) as timestamp FROM hashrates GROUP BY type`);
for (const row of rows) {
await connection.query(`DELETE FROM hashrates WHERE hashrate_timestamp = ?`, [row.timestamp]);
}
// Re-run the hashrate indexing to fill up missing data
await this.$setLatestRunTimestamp('last_hashrates_indexing', 0);
await this.$setLatestRunTimestamp('last_weekly_hashrates_indexing', 0);
} catch (e) {
logger.err('$deleteLastEntries() error' + (e instanceof Error ? e.message : e));
}
connection.release();
}
} }
export default new HashratesRepository(); export default new HashratesRepository();

View File

@ -1,4 +1,5 @@
import { Common } from '../api/common'; import { Common } from '../api/common';
import config from '../config';
import { DB } from '../database'; import { DB } from '../database';
import logger from '../logger'; import logger from '../logger';
import { PoolInfo, PoolTag } from '../mempool.interfaces'; import { PoolInfo, PoolTag } from '../mempool.interfaces';
@ -9,7 +10,7 @@ class PoolsRepository {
*/ */
public async $getPools(): Promise<PoolTag[]> { public async $getPools(): Promise<PoolTag[]> {
const connection = await DB.getConnection(); const connection = await DB.getConnection();
const [rows] = await connection.query('SELECT id, name, addresses, regexes FROM pools;'); const [rows] = await connection.query('SELECT id, name, addresses, regexes, slug FROM pools;');
connection.release(); connection.release();
return <PoolTag[]>rows; return <PoolTag[]>rows;
} }
@ -19,7 +20,7 @@ class PoolsRepository {
*/ */
public async $getUnknownPool(): Promise<PoolTag> { public async $getUnknownPool(): Promise<PoolTag> {
const connection = await DB.getConnection(); const connection = await DB.getConnection();
const [rows] = await connection.query('SELECT id, name FROM pools where name = "Unknown"'); const [rows] = await connection.query('SELECT id, name, slug FROM pools where name = "Unknown"');
connection.release(); connection.release();
return <PoolTag>rows[0]; return <PoolTag>rows[0];
} }
@ -30,7 +31,7 @@ class PoolsRepository {
public async $getPoolsInfo(interval: string | null = null): Promise<PoolInfo[]> { public async $getPoolsInfo(interval: string | null = null): Promise<PoolInfo[]> {
interval = Common.getSqlInterval(interval); interval = Common.getSqlInterval(interval);
let query = `SELECT COUNT(height) as blockCount, pool_id as poolId, pools.name as name, pools.link as link let query = `SELECT COUNT(height) as blockCount, pool_id as poolId, pools.name as name, pools.link as link, slug
FROM blocks FROM blocks
JOIN pools on pools.id = pool_id`; JOIN pools on pools.id = pool_id`;
@ -80,20 +81,30 @@ class PoolsRepository {
/** /**
* Get mining pool statistics for one pool * Get mining pool statistics for one pool
*/ */
public async $getPool(poolId: any): Promise<object> { public async $getPool(slug: string): Promise<PoolTag | null> {
const query = ` const query = `
SELECT * SELECT *
FROM pools FROM pools
WHERE pools.id = ?`; WHERE pools.slug = ?`;
// logger.debug(query); let connection;
const connection = await DB.getConnection();
try { try {
const [rows] = await connection.query(query, [poolId]); connection = await DB.getConnection();
const [rows] = await connection.query(query, [slug]);
connection.release(); connection.release();
if (rows.length < 1) {
logger.debug(`$getPool(): slug does not match any known pool`);
return null;
}
rows[0].regexes = JSON.parse(rows[0].regexes); rows[0].regexes = JSON.parse(rows[0].regexes);
rows[0].addresses = JSON.parse(rows[0].addresses); if (['testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) {
rows[0].addresses = []; // pools.json only contains mainnet addresses
} else {
rows[0].addresses = JSON.parse(rows[0].addresses);
}
return rows[0]; return rows[0];
} catch (e) { } catch (e) {

View File

@ -539,20 +539,24 @@ class Routes {
public async $getPool(req: Request, res: Response) { public async $getPool(req: Request, res: Response) {
try { try {
const stats = await mining.$getPoolStat(parseInt(req.params.poolId, 10)); const stats = await mining.$getPoolStat(req.params.slug);
res.header('Pragma', 'public'); res.header('Pragma', 'public');
res.header('Cache-control', 'public'); res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(stats); res.json(stats);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); if (e instanceof Error && e.message.indexOf('This mining pool does not exist') > -1) {
res.status(404).send(e.message);
} else {
res.status(500).send(e instanceof Error ? e.message : e);
}
} }
} }
public async $getPoolBlocks(req: Request, res: Response) { public async $getPoolBlocks(req: Request, res: Response) {
try { try {
const poolBlocks = await BlocksRepository.$getBlocksByPool( const poolBlocks = await BlocksRepository.$getBlocksByPool(
parseInt(req.params.poolId, 10), req.params.slug,
req.params.height === undefined ? undefined : parseInt(req.params.height, 10), req.params.height === undefined ? undefined : parseInt(req.params.height, 10),
); );
res.header('Pragma', 'public'); res.header('Pragma', 'public');
@ -560,7 +564,11 @@ class Routes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(poolBlocks); res.json(poolBlocks);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); if (e instanceof Error && e.message.indexOf('This mining pool does not exist') > -1) {
res.status(404).send(e.message);
} else {
res.status(500).send(e instanceof Error ? e.message : e);
}
} }
} }
@ -576,18 +584,6 @@ class Routes {
} }
} }
public async $getHistoricalDifficulty(req: Request, res: Response) {
try {
const stats = await BlocksRepository.$getBlocksDifficulty(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 $getPoolsHistoricalHashrate(req: Request, res: Response) { public async $getPoolsHistoricalHashrate(req: Request, res: Response) {
try { try {
const hashrates = await HashratesRepository.$getPoolsWeeklyHashrate(req.params.interval ?? null); const hashrates = await HashratesRepository.$getPoolsWeeklyHashrate(req.params.interval ?? null);
@ -606,7 +602,7 @@ class Routes {
public async $getPoolHistoricalHashrate(req: Request, res: Response) { public async $getPoolHistoricalHashrate(req: Request, res: Response) {
try { try {
const hashrates = await HashratesRepository.$getPoolWeeklyHashrate(parseInt(req.params.poolId, 10)); const hashrates = await HashratesRepository.$getPoolWeeklyHashrate(req.params.slug);
const oldestIndexedBlockTimestamp = await BlocksRepository.$oldestBlockTimestamp(); const oldestIndexedBlockTimestamp = await BlocksRepository.$oldestBlockTimestamp();
res.header('Pragma', 'public'); res.header('Pragma', 'public');
res.header('Cache-control', 'public'); res.header('Cache-control', 'public');
@ -616,7 +612,11 @@ class Routes {
hashrates: hashrates, hashrates: hashrates,
}); });
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); if (e instanceof Error && e.message.indexOf('This mining pool does not exist') > -1) {
res.status(404).send(e.message);
} else {
res.status(500).send(e instanceof Error ? e.message : e);
}
} }
} }
@ -638,6 +638,38 @@ class Routes {
} }
} }
public async $getHistoricalBlockFees(req: Request, res: Response) {
try {
const blockFees = await mining.$getHistoricalBlockFees(req.params.interval ?? null);
const oldestIndexedBlockTimestamp = await BlocksRepository.$oldestBlockTimestamp();
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString());
res.json({
oldestIndexedBlockTimestamp: oldestIndexedBlockTimestamp,
blockFees: blockFees,
});
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
public async $getHistoricalBlockRewards(req: Request, res: Response) {
try {
const blockRewards = await mining.$getHistoricalBlockRewards(req.params.interval ?? null);
const oldestIndexedBlockTimestamp = await BlocksRepository.$oldestBlockTimestamp();
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString());
res.json({
oldestIndexedBlockTimestamp: oldestIndexedBlockTimestamp,
blockRewards: blockRewards,
});
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
public async getBlock(req: Request, res: Response) { public async getBlock(req: Request, res: Response) {
try { try {
const result = await bitcoinApi.$getBlock(req.params.hash); const result = await bitcoinApi.$getBlock(req.params.hash);

View File

@ -0,0 +1,148 @@
const https = require('https');
import poolsParser from "../api/pools-parser";
import config from "../config";
import { DB } from "../database";
import logger from "../logger";
/**
* Maintain the most recent version of pools.json
*/
class PoolsUpdater {
lastRun: number = 0;
constructor() {
}
public async updatePoolsJson() {
if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) === false) {
return;
}
const oneWeek = 604800;
const oneDay = 86400;
const now = new Date().getTime() / 1000;
if (now - this.lastRun < oneWeek) { // Execute the PoolsUpdate only once a week, or upon restart
return;
}
this.lastRun = now;
try {
const dbSha = await this.getShaFromDb();
const githubSha = await this.fetchPoolsSha(); // Fetch pools.json sha from github
if (githubSha === undefined) {
return;
}
logger.debug(`Pools.json sha | Current: ${dbSha} | Github: ${githubSha}`);
if (dbSha !== undefined && dbSha === githubSha) {
return;
}
logger.warn('Pools.json is outdated, fetch latest from github');
const poolsJson = await this.fetchPools();
await poolsParser.migratePoolsJson(poolsJson);
await this.updateDBSha(githubSha);
logger.notice('PoolsUpdater completed');
} catch (e) {
this.lastRun = now - (oneWeek - oneDay); // Try again in 24h instead of waiting next week
logger.err('PoolsUpdater failed. Will try again in 24h. Error: ' + e);
}
}
/**
* Fetch pools.json from github repo
*/
private async fetchPools(): Promise<object> {
const response = await this.query('/repos/mempool/mining-pools/contents/pools.json');
return JSON.parse(Buffer.from(response['content'], 'base64').toString('utf8'));
}
/**
* Fetch our latest pools.json sha from the db
*/
private async updateDBSha(githubSha: string) {
let connection;
try {
connection = await DB.getConnection();
await connection.query('DELETE FROM state where name="pools_json_sha"');
await connection.query(`INSERT INTO state VALUES('pools_json_sha', NULL, '${githubSha}')`);
connection.release();
} catch (e) {
logger.err('Unable save github pools.json sha into the DB, error: ' + e);
connection.release();
return undefined;
}
}
/**
* Fetch our latest pools.json sha from the db
*/
private async getShaFromDb(): Promise<string | undefined> {
let connection;
try {
connection = await DB.getConnection();
const [rows] = await connection.query('SELECT string FROM state WHERE name="pools_json_sha"');
connection.release();
return (rows.length > 0 ? rows[0].string : undefined);
} catch (e) {
logger.err('Unable fetch pools.json sha from DB, error: ' + e);
connection.release();
return undefined;
}
}
/**
* Fetch our latest pools.json sha from github
*/
private async fetchPoolsSha(): Promise<string | undefined> {
const response = await this.query('/repos/mempool/mining-pools/git/trees/master');
for (const file of response['tree']) {
if (file['path'] === 'pools.json') {
return file['sha'];
}
}
logger.err('Unable to find latest pools.json sha from github');
return undefined;
}
/**
* Http request wrapper
*/
private query(path): Promise<string> {
return new Promise((resolve, reject) => {
const options = {
host: 'api.github.com',
path: path,
method: 'GET',
headers: { 'user-agent': 'node.js' }
};
logger.debug('Querying: api.github.com' + path);
const request = https.get(options, (response) => {
const chunks_of_data: any[] = [];
response.on('data', (fragments) => {
chunks_of_data.push(fragments);
});
response.on('end', () => {
resolve(JSON.parse(Buffer.concat(chunks_of_data).toString()));
});
response.on('error', (error) => {
reject(error);
});
});
request.on('error', (error) => {
logger.err('Query failed with error: ' + error);
reject(error);
})
});
}
}
export default new PoolsUpdater();

View File

@ -23,6 +23,7 @@ export function prepareBlock(block: any): BlockExtended {
pool: block?.extras?.pool ?? (block?.pool_id ? { pool: block?.extras?.pool ?? (block?.pool_id ? {
id: block.pool_id, id: block.pool_id,
name: block.pool_name, name: block.pool_name,
slug: block.pool_slug,
} : undefined), } : undefined),
} }
}; };

3
contributors/TechMiX.txt Normal file
View File

@ -0,0 +1,3 @@
I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of January 25, 2022.
Signed: TechMiX

File diff suppressed because it is too large Load Diff

View File

@ -33,6 +33,8 @@ import { HashrateChartPoolsComponent } from './components/hashrates-chart-pools/
import { MiningStartComponent } from './components/mining-start/mining-start.component'; import { MiningStartComponent } from './components/mining-start/mining-start.component';
import { GraphsComponent } from './components/graphs/graphs.component'; import { GraphsComponent } from './components/graphs/graphs.component';
import { BlocksList } from './components/blocks-list/blocks-list.component'; import { BlocksList } from './components/blocks-list/blocks-list.component';
import { BlockFeesGraphComponent } from './components/block-fees-graph/block-fees-graph.component';
import { BlockRewardsGraphComponent } from './components/block-rewards-graph/block-rewards-graph.component';
let routes: Routes = [ let routes: Routes = [
{ {
@ -85,7 +87,7 @@ let routes: Routes = [
path: 'pool', path: 'pool',
children: [ children: [
{ {
path: ':poolId', path: ':slug',
component: PoolComponent, component: PoolComponent,
}, },
] ]
@ -117,6 +119,14 @@ let routes: Routes = [
path: 'mining/pools', path: 'mining/pools',
component: PoolRankingComponent, component: PoolRankingComponent,
}, },
{
path: 'mining/block-fees',
component: BlockFeesGraphComponent,
},
{
path: 'mining/block-rewards',
component: BlockRewardsGraphComponent,
}
], ],
}, },
{ {
@ -215,23 +225,11 @@ let routes: Routes = [
path: 'blocks', path: 'blocks',
component: BlocksList, component: BlocksList,
}, },
{
path: 'hashrate',
component: HashrateChartComponent,
},
{
path: 'hashrate/pools',
component: HashrateChartPoolsComponent,
},
{
path: 'pools',
component: PoolRankingComponent,
},
{ {
path: 'pool', path: 'pool',
children: [ children: [
{ {
path: ':poolId', path: ':slug',
component: PoolComponent, component: PoolComponent,
}, },
] ]
@ -263,6 +261,14 @@ let routes: Routes = [
path: 'mining/pools', path: 'mining/pools',
component: PoolRankingComponent, component: PoolRankingComponent,
}, },
{
path: 'mining/block-fees',
component: BlockFeesGraphComponent,
},
{
path: 'mining/block-rewards',
component: BlockRewardsGraphComponent,
}
] ]
}, },
{ {
@ -355,23 +361,11 @@ let routes: Routes = [
path: 'blocks', path: 'blocks',
component: BlocksList, component: BlocksList,
}, },
{
path: 'hashrate',
component: HashrateChartComponent,
},
{
path: 'hashrate/pools',
component: HashrateChartPoolsComponent,
},
{
path: 'pools',
component: PoolRankingComponent,
},
{ {
path: 'pool', path: 'pool',
children: [ children: [
{ {
path: ':poolId', path: ':slug',
component: PoolComponent, component: PoolComponent,
}, },
] ]
@ -403,6 +397,14 @@ let routes: Routes = [
path: 'mining/pools', path: 'mining/pools',
component: PoolRankingComponent, component: PoolRankingComponent,
}, },
{
path: 'mining/block-fees',
component: BlockFeesGraphComponent,
},
{
path: 'mining/block-rewards',
component: BlockRewardsGraphComponent,
}
] ]
}, },
{ {
@ -519,19 +521,7 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') {
{ {
path: 'mempool', path: 'mempool',
component: StatisticsComponent, component: StatisticsComponent,
}, }
{
path: 'mining/hashrate-difficulty',
component: HashrateChartComponent,
},
{
path: 'mining/pools-dominance',
component: HashrateChartPoolsComponent,
},
{
path: 'mining/pools',
component: PoolRankingComponent,
},
] ]
}, },
{ {
@ -651,19 +641,7 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') {
{ {
path: 'mempool', path: 'mempool',
component: StatisticsComponent, component: StatisticsComponent,
}, }
{
path: 'mining/hashrate-difficulty',
component: HashrateChartComponent,
},
{
path: 'mining/pools-dominance',
component: HashrateChartPoolsComponent,
},
{
path: 'mining/pools',
component: PoolRankingComponent,
},
] ]
}, },
{ {

View File

@ -81,6 +81,8 @@ import { DifficultyAdjustmentsTable } from './components/difficulty-adjustments-
import { BlocksList } from './components/blocks-list/blocks-list.component'; import { BlocksList } from './components/blocks-list/blocks-list.component';
import { RewardStatsComponent } from './components/reward-stats/reward-stats.component'; import { RewardStatsComponent } from './components/reward-stats/reward-stats.component';
import { DataCyDirective } from './data-cy.directive'; import { DataCyDirective } from './data-cy.directive';
import { BlockFeesGraphComponent } from './components/block-fees-graph/block-fees-graph.component';
import { BlockRewardsGraphComponent } from './components/block-rewards-graph/block-rewards-graph.component';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -143,6 +145,8 @@ import { DataCyDirective } from './data-cy.directive';
BlocksList, BlocksList,
DataCyDirective, DataCyDirective,
RewardStatsComponent, RewardStatsComponent,
BlockFeesGraphComponent,
BlockRewardsGraphComponent,
], ],
imports: [ imports: [
BrowserModule.withServerTransition({ appId: 'serverApp' }), BrowserModule.withServerTransition({ appId: 'serverApp' }),

View File

@ -169,7 +169,7 @@
</div> </div>
</div> </div>
<div class="community-integrations-sponsor"> <div class="selfhosted-integrations-sponsor">
<h3 i18n="about.self-hosted-integrations">Self-Hosted Integrations</h3> <h3 i18n="about.self-hosted-integrations">Self-Hosted Integrations</h3>
<div class="wrapper"> <div class="wrapper">
<a href="https://github.com/getumbrel/umbrel" target="_blank" title="Umbrel"> <a href="https://github.com/getumbrel/umbrel" target="_blank" title="Umbrel">

View File

@ -43,6 +43,7 @@
.alliances, .alliances,
.enterprise-sponsor, .enterprise-sponsor,
.community-integrations-sponsor, .community-integrations-sponsor,
.selfhosted-integrations-sponsor,
.maintainers { .maintainers {
margin-top: 68px; margin-top: 68px;
margin-bottom: 68px; margin-bottom: 68px;
@ -108,6 +109,7 @@
.contributors, .contributors,
.community-sponsor, .community-sponsor,
.community-integrations-sponsor, .community-integrations-sponsor,
.selfhosted-integrations-sponsor,
.maintainers { .maintainers {
.wrapper { .wrapper {
display: inline-block; display: inline-block;
@ -181,3 +183,8 @@
.no-about-margin { .no-about-margin {
height: 10px; height: 10px;
} }
.community-integrations-sponsor {
max-width: 750px;
margin: auto;
}

View File

@ -1,17 +1,5 @@
<span <span
*ngIf="multisig" *ngIf="label"
class="badge badge-pill badge-warning" class="badge badge-pill badge-warning"
i18n="address-labels.multisig" i18n="address-labels.multisig"
>multisig {{ multisigM }} of {{ multisigN }}</span> >{{ label }}</span>
<span
*ngIf="lightning"
class="badge badge-pill badge-warning"
i18n="address-labels.upper-layer-peg-out"
>Lightning {{ lightning }}</span>
<span
*ngIf="liquid"
class="badge badge-pill badge-warning"
i18n="address-labels.upper-layer-peg-out"
>Liquid {{ liquid }}</span>

View File

@ -14,12 +14,7 @@ export class AddressLabelsComponent implements OnInit {
@Input() vin: Vin; @Input() vin: Vin;
@Input() vout: Vout; @Input() vout: Vout;
multisig = false; label?: string;
multisigM: number;
multisigN: number;
lightning = null;
liquid = null;
constructor( constructor(
stateService: StateService, stateService: StateService,
@ -39,30 +34,46 @@ export class AddressLabelsComponent implements OnInit {
if (this.vin.inner_witnessscript_asm) { if (this.vin.inner_witnessscript_asm) {
if (this.vin.inner_witnessscript_asm.indexOf('OP_DEPTH OP_PUSHNUM_12 OP_EQUAL OP_IF OP_PUSHNUM_11') === 0) { if (this.vin.inner_witnessscript_asm.indexOf('OP_DEPTH OP_PUSHNUM_12 OP_EQUAL OP_IF OP_PUSHNUM_11') === 0) {
if (this.vin.witness.length > 11) { if (this.vin.witness.length > 11) {
this.liquid = 'Peg Out'; this.label = 'Liquid Peg Out';
} else { } else {
this.liquid = 'Emergency Peg Out'; this.label = 'Emergency Liquid Peg Out';
} }
return; return;
} }
// https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction-outputs const topElement = this.vin.witness[this.vin.witness.length - 2];
if (/^OP_IF OP_PUSHBYTES_33 \w{66} OP_ELSE OP_PUSHBYTES_(1 \w{2}|2 \w{4}) OP_CSV OP_DROP OP_PUSHBYTES_33 \w{66} OP_ENDIF OP_CHECKSIG$/.test(this.vin.inner_witnessscript_asm)) { if (/^OP_IF OP_PUSHBYTES_33 \w{66} OP_ELSE OP_PUSHBYTES_(1 \w{2}|2 \w{4}) OP_CSV OP_DROP OP_PUSHBYTES_33 \w{66} OP_ENDIF OP_CHECKSIG$/.test(this.vin.inner_witnessscript_asm)) {
if (this.vin.witness[this.vin.witness.length - 2] == '01') { // https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction-outputs
this.lightning = 'Revoked Force Close'; if (topElement === '01') {
} else { // top element is '01' to get in the revocation path
this.lightning = 'Force Close'; this.label = 'Revoked Lightning Force Close';
}
// https://github.com/lightning/bolts/blob/master/03-transactions.md#offered-htlc-outputs
} else if (/^OP_DUP OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUAL OP_IF OP_CHECKSIG OP_ELSE OP_PUSHBYTES_33 \w{66} OP_SWAP OP_SIZE OP_PUSHBYTES_1 20 OP_EQUAL OP_NOTIF OP_DROP OP_PUSHNUM_2 OP_SWAP OP_PUSHBYTES_33 \w{66} OP_PUSHNUM_2 OP_CHECKMULTISIG OP_ELSE OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUALVERIFY OP_CHECKSIG OP_ENDIF (OP_PUSHNUM_1 OP_CHECKSEQUENCEVERIFY OP_DROP |)OP_ENDIF$/.test(this.vin.inner_witnessscript_asm)) {
if (this.vin.witness[this.vin.witness.length - 2].length == 66) {
this.lightning = 'Revoked HTLC';
} else { } else {
this.lightning = 'HTLC'; // top element is '', this is a delayed to_local output
this.label = 'Lightning Force Close';
}
return;
} else if (/^OP_DUP OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUAL OP_IF OP_CHECKSIG OP_ELSE OP_PUSHBYTES_33 \w{66} OP_SWAP OP_SIZE OP_PUSHBYTES_1 20 OP_EQUAL OP_NOTIF OP_DROP OP_PUSHNUM_2 OP_SWAP OP_PUSHBYTES_33 \w{66} OP_PUSHNUM_2 OP_CHECKMULTISIG OP_ELSE OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUALVERIFY OP_CHECKSIG OP_ENDIF (OP_PUSHNUM_1 OP_CSV OP_DROP |)OP_ENDIF$/.test(this.vin.inner_witnessscript_asm)) {
// https://github.com/lightning/bolts/blob/master/03-transactions.md#offered-htlc-outputs
if (topElement.length === 66) {
// top element is a public key
this.label = 'Revoked Lightning HTLC';
} else if (topElement) {
// top element is a preimage
this.label = 'Lightning HTLC';
} else {
// top element is '' to get in the multisig path of the script
this.label = 'Expired Lightning HTLC';
}
return;
} else if (/^OP_PUSHBYTES_33 \w{66} OP_CHECKSIG OP_IFDUP OP_NOTIF OP_PUSHNUM_16 OP_CSV OP_ENDIF$/.test(this.vin.inner_witnessscript_asm)) {
// https://github.com/lightning/bolts/blob/master/03-transactions.md#to_local_anchor-and-to_remote_anchor-output-option_anchors
if (topElement) {
// top element is a signature
this.label = 'Lightning Anchor';
} else {
// top element is '', it has been swept after 16 blocks
this.label = 'Swept Lightning Anchor';
} }
}
if (this.lightning) {
return; return;
} }
@ -77,19 +88,19 @@ export class AddressLabelsComponent implements OnInit {
return; return;
} }
const ops = script.split(' '); const ops = script.split(' ');
if (ops.length < 3 || ops.pop() != 'OP_CHECKMULTISIG') { if (ops.length < 3 || ops.pop() !== 'OP_CHECKMULTISIG') {
return; return;
} }
const opN = ops.pop(); const opN = ops.pop();
if (!opN.startsWith('OP_PUSHNUM_')) { if (!opN.startsWith('OP_PUSHNUM_')) {
return; return;
} }
const n = parseInt(opN.match(/[0-9]+/)[0]); const n = parseInt(opN.match(/[0-9]+/)[0], 10);
if (ops.length < n * 2 + 1) { if (ops.length < n * 2 + 1) {
return; return;
} }
// pop n public keys // pop n public keys
for (var i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
if (!/^0((2|3)\w{64}|4\w{128})$/.test(ops.pop())) { if (!/^0((2|3)\w{64}|4\w{128})$/.test(ops.pop())) {
return; return;
} }
@ -101,13 +112,12 @@ export class AddressLabelsComponent implements OnInit {
if (!opM.startsWith('OP_PUSHNUM_')) { if (!opM.startsWith('OP_PUSHNUM_')) {
return; return;
} }
const m = parseInt(opM.match(/[0-9]+/)[0]); const m = parseInt(opM.match(/[0-9]+/)[0], 10);
this.multisig = true; this.label = `multisig ${m} of ${n}`;
this.multisigM = m;
this.multisigN = n;
} }
handleVout() { handleVout() {
this.detectMultisig(this.vout.scriptpubkey_asm);
} }
} }

View File

@ -55,7 +55,7 @@
<br> <br>
<div class="title-tx"> <div class="title-tx">
<h2> <h2 class="text-left">
<ng-template [ngIf]="!transactions?.length">&nbsp;</ng-template> <ng-template [ngIf]="!transactions?.length">&nbsp;</ng-template>
<ng-template i18n="X of X Address Transaction" [ngIf]="transactions?.length === 1">{{ (transactions?.length | number) || '?' }} of {{ txCount | number }} transaction</ng-template> <ng-template i18n="X of X Address Transaction" [ngIf]="transactions?.length === 1">{{ (transactions?.length | number) || '?' }} of {{ txCount | number }} transaction</ng-template>
<ng-template i18n="X of X Address Transactions (Plural)" [ngIf]="transactions?.length > 1">{{ (transactions?.length | number) || '?' }} of {{ txCount | number }} transactions</ng-template> <ng-template i18n="X of X Address Transactions (Plural)" [ngIf]="transactions?.length > 1">{{ (transactions?.length | number) || '?' }} of {{ txCount | number }} transactions</ng-template>

View File

@ -1,28 +1,33 @@
import { Location } from '@angular/common'; import { Location } from '@angular/common';
import { Component, HostListener, OnInit, Inject, LOCALE_ID, HostBinding } from '@angular/core'; import { Component, HostListener, OnInit, Inject, LOCALE_ID, HostBinding } from '@angular/core';
import { Router, NavigationEnd } from '@angular/router'; import { Router, NavigationEnd } from '@angular/router';
import { WebsocketService } from '../../services/websocket.service';
import { StateService } from 'src/app/services/state.service'; import { StateService } from 'src/app/services/state.service';
import { NgbTooltipConfig } from '@ng-bootstrap/ng-bootstrap';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'] styleUrls: ['./app.component.scss'],
providers: [NgbTooltipConfig]
}) })
export class AppComponent implements OnInit { export class AppComponent implements OnInit {
link: HTMLElement = document.getElementById('canonical'); link: HTMLElement = document.getElementById('canonical');
constructor( constructor(
public router: Router, public router: Router,
private websocketService: WebsocketService,
private stateService: StateService, private stateService: StateService,
private location: Location, private location: Location,
tooltipConfig: NgbTooltipConfig,
@Inject(LOCALE_ID) private locale: string, @Inject(LOCALE_ID) private locale: string,
) { ) {
if (this.locale.startsWith('ar') || this.locale.startsWith('fa') || this.locale.startsWith('he')) { if (this.locale.startsWith('ar') || this.locale.startsWith('fa') || this.locale.startsWith('he')) {
this.dir = 'rtl'; this.dir = 'rtl';
this.class = 'rtl-layout'; this.class = 'rtl-layout';
} }
tooltipConfig.animation = false;
tooltipConfig.container = 'body';
tooltipConfig.triggers = 'hover';
} }
@HostBinding('attr.dir') dir = 'ltr'; @HostBinding('attr.dir') dir = 'ltr';

View File

@ -0,0 +1,63 @@
<div class="full-container">
<div class="card-header mb-0 mb-md-4">
<span i18n="mining.block-fees">Block fees</span>
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats">
<div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.availableTimespanDay >= 1">
<input ngbButton type="radio" [value]="'24h'" fragment="24h"> 24h
</label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.availableTimespanDay >= 3">
<input ngbButton type="radio" [value]="'3d'" fragment="3d"> 3D
</label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.availableTimespanDay >= 7">
<input ngbButton type="radio" [value]="'1w'" fragment="1w"> 1W
</label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.availableTimespanDay >= 30">
<input ngbButton type="radio" [value]="'1m'" fragment="1m"> 1M
</label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.availableTimespanDay >= 90">
<input ngbButton type="radio" [value]="'3m'" fragment="3m"> 3M
</label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.availableTimespanDay >= 180">
<input ngbButton type="radio" [value]="'6m'" fragment="6m"> 6M
</label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.availableTimespanDay >= 365">
<input ngbButton type="radio" [value]="'1y'" fragment="1y"> 1Y
</label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.availableTimespanDay >= 730">
<input ngbButton type="radio" [value]="'2y'" fragment="2y"> 2Y
</label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.availableTimespanDay >= 1095">
<input ngbButton type="radio" [value]="'3y'" fragment="3y"> 3Y
</label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.availableTimespanDay > 1095">
<input ngbButton type="radio" [value]="'all'" fragment="all"> ALL
</label>
</div>
</form>
</div>
<div class="chart" echarts [initOpts]="chartInitOptions" [options]="chartOptions">
</div>
<div class="text-center loadingGraphs" *ngIf="isLoading">
<div class="spinner-border text-light"></div>
</div>
</div>
<ng-template #loadingStats>
<div class="pool-distribution">
<div class="item">
<h5 class="card-title" i18n="mining.miners-luck">Hashrate</h5>
<p class="card-text">
<span class="skeleton-loader skeleton-loader-big"></span>
</p>
</div>
<div class="item">
<h5 class="card-title" i18n="master-page.blocks">Difficulty</h5>
<p class="card-text">
<span class="skeleton-loader skeleton-loader-big"></span>
</p>
</div>
</div>
</ng-template>

View File

@ -0,0 +1,135 @@
.card-header {
border-bottom: 0;
font-size: 18px;
@media (min-width: 465px) {
font-size: 20px;
}
}
.main-title {
position: relative;
color: #ffffff91;
margin-top: -13px;
font-size: 10px;
text-transform: uppercase;
font-weight: 500;
text-align: center;
padding-bottom: 3px;
}
.full-container {
padding: 0px 15px;
width: 100%;
min-height: 500px;
height: calc(100% - 150px);
@media (max-width: 992px) {
height: 100%;
padding-bottom: 100px;
};
}
.chart {
width: 100%;
height: 100%;
padding-bottom: 20px;
padding-right: 10px;
@media (max-width: 992px) {
padding-bottom: 25px;
}
@media (max-width: 829px) {
padding-bottom: 50px;
}
@media (max-width: 767px) {
padding-bottom: 25px;
}
@media (max-width: 629px) {
padding-bottom: 55px;
}
@media (max-width: 567px) {
padding-bottom: 55px;
}
}
.chart-widget {
width: 100%;
height: 100%;
max-height: 270px;
}
.formRadioGroup {
margin-top: 6px;
display: flex;
flex-direction: column;
@media (min-width: 1130px) {
position: relative;
top: -65px;
}
@media (min-width: 830px) and (max-width: 1130px) {
position: relative;
top: 0px;
}
@media (min-width: 830px) {
flex-direction: row;
float: right;
margin-top: 0px;
}
.btn-sm {
font-size: 9px;
@media (min-width: 830px) {
font-size: 14px;
}
}
}
.pool-distribution {
min-height: 56px;
display: block;
@media (min-width: 485px) {
display: flex;
flex-direction: row;
}
h5 {
margin-bottom: 10px;
}
.item {
width: 50%;
display: inline-block;
margin: 0px auto 20px;
&:nth-child(2) {
order: 2;
@media (min-width: 485px) {
order: 3;
}
}
&:nth-child(3) {
order: 3;
@media (min-width: 485px) {
order: 2;
display: block;
}
@media (min-width: 768px) {
display: none;
}
@media (min-width: 992px) {
display: block;
}
}
.card-title {
font-size: 1rem;
color: #4a68b9;
}
.card-text {
font-size: 18px;
span {
color: #ffffff66;
font-size: 12px;
}
}
}
}
.skeleton-loader {
width: 100%;
display: block;
max-width: 80px;
margin: 15px auto 3px;
}

View File

@ -0,0 +1,201 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnInit } from '@angular/core';
import { EChartsOption, graphic } from 'echarts';
import { Observable } from 'rxjs';
import { map, share, startWith, switchMap, tap } from 'rxjs/operators';
import { ApiService } from 'src/app/services/api.service';
import { SeoService } from 'src/app/services/seo.service';
import { formatNumber } from '@angular/common';
import { FormBuilder, FormGroup } from '@angular/forms';
import { formatterXAxisLabel } from 'src/app/shared/graphs.utils';
import { StorageService } from 'src/app/services/storage.service';
import { MiningService } from 'src/app/services/mining.service';
@Component({
selector: 'app-block-fees-graph',
templateUrl: './block-fees-graph.component.html',
styleUrls: ['./block-fees-graph.component.scss'],
styles: [`
.loadingGraphs {
position: absolute;
top: 50%;
left: calc(50% - 15px);
z-index: 100;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BlockFeesGraphComponent implements OnInit {
@Input() tableOnly = false;
@Input() right: number | string = 45;
@Input() left: number | string = 75;
miningWindowPreference: string;
radioGroupForm: FormGroup;
chartOptions: EChartsOption = {};
chartInitOptions = {
renderer: 'svg',
};
statsObservable$: Observable<any>;
isLoading = true;
formatNumber = formatNumber;
timespan = '';
constructor(
@Inject(LOCALE_ID) public locale: string,
private seoService: SeoService,
private apiService: ApiService,
private formBuilder: FormBuilder,
private storageService: StorageService,
private miningService: MiningService
) {
this.radioGroupForm = this.formBuilder.group({ dateSpan: '1y' });
this.radioGroupForm.controls.dateSpan.setValue('1y');
}
ngOnInit(): void {
this.seoService.setTitle($localize`:@@mining.block-fees:Block Fees`);
this.miningWindowPreference = this.miningService.getDefaultTimespan('24h');
this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference });
this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference);
this.statsObservable$ = this.radioGroupForm.get('dateSpan').valueChanges
.pipe(
startWith(this.miningWindowPreference),
switchMap((timespan) => {
this.storageService.setValue('miningWindowPreference', timespan);
this.timespan = timespan;
this.isLoading = true;
return this.apiService.getHistoricalBlockFees$(timespan)
.pipe(
tap((data: any) => {
this.prepareChartOptions({
blockFees: data.blockFees.map(val => [val.timestamp * 1000, val.avg_fees / 100000000]),
});
this.isLoading = false;
}),
map((data: any) => {
const availableTimespanDay = (
(new Date().getTime() / 1000) - (data.oldestIndexedBlockTimestamp)
) / 3600 / 24;
return {
availableTimespanDay: availableTimespanDay,
};
}),
);
}),
share()
);
}
prepareChartOptions(data) {
this.chartOptions = {
animation: false,
color: [
new graphic.LinearGradient(0, 0, 0, 0.65, [
{ offset: 0, color: '#F4511E' },
{ offset: 0.25, color: '#FB8C00' },
{ offset: 0.5, color: '#FFB300' },
{ offset: 0.75, color: '#FDD835' },
{ offset: 1, color: '#7CB342' }
]),
],
grid: {
top: 30,
bottom: 80,
right: this.right,
left: this.left,
},
tooltip: {
show: !this.isMobile(),
trigger: 'axis',
axisPointer: {
type: 'line'
},
backgroundColor: 'rgba(17, 19, 31, 1)',
borderRadius: 4,
shadowColor: 'rgba(0, 0, 0, 0.5)',
textStyle: {
color: '#b1b1b1',
align: 'left',
},
borderColor: '#000',
formatter: (ticks) => {
const tick = ticks[0];
const feesString = `${tick.marker} ${tick.seriesName}: ${formatNumber(tick.data[1], this.locale, '1.3-3')} BTC`;
return `
<b style="color: white; margin-left: 18px">${tick.axisValueLabel}</b><br>
<span>${feesString}</span>
`;
}
},
xAxis: {
name: formatterXAxisLabel(this.locale, this.timespan),
nameLocation: 'middle',
nameTextStyle: {
padding: [10, 0, 0, 0],
},
type: 'time',
splitNumber: this.isMobile() ? 5 : 10,
},
yAxis: [
{
type: 'value',
axisLabel: {
color: 'rgb(110, 112, 121)',
formatter: (val) => {
return `${val} BTC`;
}
},
splitLine: {
show: false,
}
},
],
series: [
{
zlevel: 0,
name: 'Fees',
showSymbol: false,
symbol: 'none',
data: data.blockFees,
type: 'line',
lineStyle: {
width: 2,
},
},
],
dataZoom: [{
type: 'inside',
realtime: true,
zoomLock: true,
maxSpan: 100,
minSpan: 10,
moveOnMouseMove: false,
}, {
showDetail: false,
show: true,
type: 'slider',
brushSelect: false,
realtime: true,
left: 20,
right: 15,
selectedDataBackground: {
lineStyle: {
color: '#fff',
opacity: 0.45,
},
areaStyle: {
opacity: 0,
}
},
}],
};
}
isMobile() {
return (window.innerWidth <= 767.98);
}
}

View File

@ -0,0 +1,64 @@
<div class="full-container">
<div class="card-header mb-0 mb-md-4">
<span i18n="mining.block-rewards">Block rewards</span>
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats">
<div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.availableTimespanDay >= 1">
<input ngbButton type="radio" [value]="'24h'" fragment="24h"> 24h
</label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.availableTimespanDay >= 3">
<input ngbButton type="radio" [value]="'3d'" fragment="3d"> 3D
</label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.availableTimespanDay >= 7">
<input ngbButton type="radio" [value]="'1w'" fragment="1w"> 1W
</label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.availableTimespanDay >= 30">
<input ngbButton type="radio" [value]="'1m'" fragment="1m"> 1M
</label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.availableTimespanDay >= 90">
<input ngbButton type="radio" [value]="'3m'" fragment="3m"> 3M
</label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.availableTimespanDay >= 180">
<input ngbButton type="radio" [value]="'6m'" fragment="6m"> 6M
</label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.availableTimespanDay >= 365">
<input ngbButton type="radio" [value]="'1y'" fragment="1y"> 1Y
</label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.availableTimespanDay >= 730">
<input ngbButton type="radio" [value]="'2y'" fragment="2y"> 2Y
</label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.availableTimespanDay >= 1095">
<input ngbButton type="radio" [value]="'3y'" fragment="3y"> 3Y
</label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.availableTimespanDay > 1095">
<input ngbButton type="radio" [value]="'all'" fragment="all"> ALL
</label>
</div>
</form>
</div>
<div class="chart" echarts [initOpts]="chartInitOptions" [options]="chartOptions">
</div>
<div class="text-center loadingGraphs" *ngIf="isLoading">
<div class="spinner-border text-light"></div>
</div>
</div>
<ng-template #loadingStats>
<div class="pool-distribution">
<div class="item">
<h5 class="card-title" i18n="mining.miners-luck">Hashrate</h5>
<p class="card-text">
<span class="skeleton-loader skeleton-loader-big"></span>
</p>
</div>
<div class="item">
<h5 class="card-title" i18n="master-page.blocks">Difficulty</h5>
<p class="card-text">
<span class="skeleton-loader skeleton-loader-big"></span>
</p>
</div>
</div>
</ng-template>

View File

@ -0,0 +1,135 @@
.card-header {
border-bottom: 0;
font-size: 18px;
@media (min-width: 465px) {
font-size: 20px;
}
}
.main-title {
position: relative;
color: #ffffff91;
margin-top: -13px;
font-size: 10px;
text-transform: uppercase;
font-weight: 500;
text-align: center;
padding-bottom: 3px;
}
.full-container {
padding: 0px 15px;
width: 100%;
min-height: 500px;
height: calc(100% - 150px);
@media (max-width: 992px) {
height: 100%;
padding-bottom: 100px;
};
}
.chart {
width: 100%;
height: 100%;
padding-bottom: 20px;
padding-right: 10px;
@media (max-width: 992px) {
padding-bottom: 25px;
}
@media (max-width: 829px) {
padding-bottom: 50px;
}
@media (max-width: 767px) {
padding-bottom: 25px;
}
@media (max-width: 629px) {
padding-bottom: 55px;
}
@media (max-width: 567px) {
padding-bottom: 55px;
}
}
.chart-widget {
width: 100%;
height: 100%;
max-height: 270px;
}
.formRadioGroup {
margin-top: 6px;
display: flex;
flex-direction: column;
@media (min-width: 1130px) {
position: relative;
top: -65px;
}
@media (min-width: 830px) and (max-width: 1130px) {
position: relative;
top: 0px;
}
@media (min-width: 830px) {
flex-direction: row;
float: right;
margin-top: 0px;
}
.btn-sm {
font-size: 9px;
@media (min-width: 830px) {
font-size: 14px;
}
}
}
.pool-distribution {
min-height: 56px;
display: block;
@media (min-width: 485px) {
display: flex;
flex-direction: row;
}
h5 {
margin-bottom: 10px;
}
.item {
width: 50%;
display: inline-block;
margin: 0px auto 20px;
&:nth-child(2) {
order: 2;
@media (min-width: 485px) {
order: 3;
}
}
&:nth-child(3) {
order: 3;
@media (min-width: 485px) {
order: 2;
display: block;
}
@media (min-width: 768px) {
display: none;
}
@media (min-width: 992px) {
display: block;
}
}
.card-title {
font-size: 1rem;
color: #4a68b9;
}
.card-text {
font-size: 18px;
span {
color: #ffffff66;
font-size: 12px;
}
}
}
}
.skeleton-loader {
width: 100%;
display: block;
max-width: 80px;
margin: 15px auto 3px;
}

View File

@ -0,0 +1,200 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnInit } from '@angular/core';
import { EChartsOption, graphic } from 'echarts';
import { Observable } from 'rxjs';
import { map, share, startWith, switchMap, tap } from 'rxjs/operators';
import { ApiService } from 'src/app/services/api.service';
import { SeoService } from 'src/app/services/seo.service';
import { formatNumber } from '@angular/common';
import { FormBuilder, FormGroup } from '@angular/forms';
import { formatterXAxisLabel } from 'src/app/shared/graphs.utils';
import { MiningService } from 'src/app/services/mining.service';
import { StorageService } from 'src/app/services/storage.service';
@Component({
selector: 'app-block-rewards-graph',
templateUrl: './block-rewards-graph.component.html',
styleUrls: ['./block-rewards-graph.component.scss'],
styles: [`
.loadingGraphs {
position: absolute;
top: 50%;
left: calc(50% - 15px);
z-index: 100;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BlockRewardsGraphComponent implements OnInit {
@Input() right: number | string = 45;
@Input() left: number | string = 75;
miningWindowPreference: string;
radioGroupForm: FormGroup;
chartOptions: EChartsOption = {};
chartInitOptions = {
renderer: 'svg',
};
statsObservable$: Observable<any>;
isLoading = true;
formatNumber = formatNumber;
timespan = '';
constructor(
@Inject(LOCALE_ID) public locale: string,
private seoService: SeoService,
private apiService: ApiService,
private formBuilder: FormBuilder,
private miningService: MiningService,
private storageService: StorageService
) {
}
ngOnInit(): void {
this.seoService.setTitle($localize`:@@mining.block-reward:Block Reward`);
this.miningWindowPreference = this.miningService.getDefaultTimespan('24h');
this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference });
this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference);
this.statsObservable$ = this.radioGroupForm.get('dateSpan').valueChanges
.pipe(
startWith(this.miningWindowPreference),
switchMap((timespan) => {
this.storageService.setValue('miningWindowPreference', timespan);
this.timespan = timespan;
this.isLoading = true;
return this.apiService.getHistoricalBlockRewards$(timespan)
.pipe(
tap((data: any) => {
this.prepareChartOptions({
blockRewards: data.blockRewards.map(val => [val.timestamp * 1000, val.avg_rewards / 100000000]),
});
this.isLoading = false;
}),
map((data: any) => {
const availableTimespanDay = (
(new Date().getTime() / 1000) - (data.oldestIndexedBlockTimestamp)
) / 3600 / 24;
return {
availableTimespanDay: availableTimespanDay,
};
}),
);
}),
share()
);
}
prepareChartOptions(data) {
this.chartOptions = {
animation: false,
color: [
new graphic.LinearGradient(0, 0, 0, 0.65, [
{ offset: 0, color: '#F4511E' },
{ offset: 0.25, color: '#FB8C00' },
{ offset: 0.5, color: '#FFB300' },
{ offset: 0.75, color: '#FDD835' },
{ offset: 1, color: '#7CB342' }
]),
],
grid: {
top: 20,
bottom: 80,
right: this.right,
left: this.left,
},
tooltip: {
show: !this.isMobile(),
trigger: 'axis',
axisPointer: {
type: 'line'
},
backgroundColor: 'rgba(17, 19, 31, 1)',
borderRadius: 4,
shadowColor: 'rgba(0, 0, 0, 0.5)',
textStyle: {
color: '#b1b1b1',
align: 'left',
},
borderColor: '#000',
formatter: (ticks) => {
const tick = ticks[0];
const rewardsString = `${tick.marker} ${tick.seriesName}: ${formatNumber(tick.data[1], this.locale, '1.3-3')} BTC`;
return `
<b style="color: white; margin-left: 18px">${tick.axisValueLabel}</b><br>
<span>${rewardsString}</span>
`;
}
},
xAxis: {
name: formatterXAxisLabel(this.locale, this.timespan),
nameLocation: 'middle',
nameTextStyle: {
padding: [10, 0, 0, 0],
},
type: 'time',
splitNumber: this.isMobile() ? 5 : 10,
},
yAxis: [
{
min: value => Math.round(10 * value.min * 0.99) / 10,
max: value => Math.round(10 * value.max * 1.01) / 10,
type: 'value',
axisLabel: {
color: 'rgb(110, 112, 121)',
formatter: (val) => {
return `${val} BTC`;
}
},
splitLine: {
show: false,
}
},
],
series: [
{
zlevel: 0,
name: 'Reward',
showSymbol: false,
symbol: 'none',
data: data.blockRewards,
type: 'line',
lineStyle: {
width: 2,
},
},
],
dataZoom: [{
type: 'inside',
realtime: true,
zoomLock: true,
maxSpan: 100,
minSpan: 10,
moveOnMouseMove: false,
}, {
showDetail: false,
show: true,
type: 'slider',
brushSelect: false,
realtime: true,
left: 20,
right: 15,
selectedDataBackground: {
lineStyle: {
color: '#fff',
opacity: 0.45,
},
areaStyle: {
opacity: 0,
}
},
}],
};
}
isMobile() {
return (window.innerWidth <= 767.98);
}
}

View File

@ -163,7 +163,7 @@
</div> </div>
<div #blockTxTitle id="block-tx-title" class="block-tx-title"> <div #blockTxTitle id="block-tx-title" class="block-tx-title">
<h2> <h2 class="text-left">
<ng-container *ngTemplateOutlet="block.tx_count === 1 ? transactionsSingular : transactionsPlural; context: {$implicit: block.tx_count | number}"></ng-container> <ng-container *ngTemplateOutlet="block.tx_count === 1 ? transactionsSingular : transactionsPlural; context: {$implicit: block.tx_count | number}"></ng-container>
<ng-template #transactionsSingular let-i i18n="shared.transaction-count.singular">{{ i }} transaction</ng-template> <ng-template #transactionsSingular let-i i18n="shared.transaction-count.singular">{{ i }} transaction</ng-template>
<ng-template #transactionsPlural let-i i18n="shared.transaction-count.plural">{{ i }} transactions</ng-template> <ng-template #transactionsPlural let-i i18n="shared.transaction-count.plural">{{ i }} transactions</ng-template>

View File

@ -25,7 +25,7 @@
<div class="time-difference"><app-time-since [time]="block.timestamp" [fastRender]="true"></app-time-since></div> <div class="time-difference"><app-time-since [time]="block.timestamp" [fastRender]="true"></app-time-since></div>
</div> </div>
<div class="animated" [class]="showMiningInfo ? 'show' : 'hide'" *ngIf="block.extras?.pool != undefined"> <div class="animated" [class]="showMiningInfo ? 'show' : 'hide'" *ngIf="block.extras?.pool != undefined">
<a class="badge badge-primary" [routerLink]="[('/mining/pool/' + block.extras.pool.id) | relativeUrl]"> <a class="badge badge-primary" [routerLink]="[('/mining/pool/' + block.extras.pool.slug) | relativeUrl]">
{{ block.extras.pool.name}}</a> {{ block.extras.pool.name}}</a>
</div> </div>
</div> </div>

View File

@ -51,7 +51,7 @@ export class BlockchainBlocksComponent implements OnInit, OnDestroy {
} }
enabledMiningInfoIfNeeded(url) { enabledMiningInfoIfNeeded(url) {
this.showMiningInfo = url === '/mining'; this.showMiningInfo = url.indexOf('/mining') !== -1;
this.cd.markForCheck(); // Need to update the view asap this.cd.markForCheck(); // Need to update the view asap
} }

View File

@ -1,4 +1,4 @@
<div class="container-xl" [class]="widget ? 'widget' : ''"> <div class="container-xl" [class]="widget ? 'widget' : 'full-height'">
<h1 *ngIf="!widget" class="float-left" i18n="latest-blocks.blocks">Blocks</h1> <h1 *ngIf="!widget" class="float-left" i18n="latest-blocks.blocks">Blocks</h1>
<div class="clearfix"></div> <div class="clearfix"></div>
@ -25,8 +25,8 @@
</td> </td>
<td class="pool text-left" [class]="widget ? 'widget' : ''"> <td class="pool text-left" [class]="widget ? 'widget' : ''">
<div class="tooltip-custom"> <div class="tooltip-custom">
<a class="clear-link" [routerLink]="[('/mining/pool/' + block.extras.pool.id) | relativeUrl]"> <a class="clear-link" [routerLink]="['/mining/pool' | relativeUrl, block.extras.pool.slug]">
<img width="25" height="25" src="{{ block.extras.pool['logo'] }}" <img width="23" height="23" src="{{ block.extras.pool['logo'] }}"
onError="this.src = './resources/mining-pools/default.svg'"> onError="this.src = './resources/mining-pools/default.svg'">
<span class="pool-name">{{ block.extras.pool.name }}</span> <span class="pool-name">{{ block.extras.pool.name }}</span>
</a> </a>
@ -64,7 +64,7 @@
<span class="skeleton-loader"></span> <span class="skeleton-loader"></span>
</td> </td>
<td class="pool text-left" [class]="widget ? 'widget' : ''"> <td class="pool text-left" [class]="widget ? 'widget' : ''">
<img width="0" height="25" style="opacity: 0"> <img width="25" height="25" style="opacity: 0">
<span class="skeleton-loader"></span> <span class="skeleton-loader"></span>
</td> </td>
<td class="timestamp" *ngIf="!widget"> <td class="timestamp" *ngIf="!widget">
@ -91,9 +91,9 @@
</table> </table>
<ngb-pagination *ngIf="!widget" class="pagination-container float-right mt-2" [class]="isLoading ? 'disabled' : ''" <ngb-pagination *ngIf="!widget" class="pagination-container float-right mt-2" [class]="isLoading ? 'disabled' : ''"
[collectionSize]="blocksCount" [rotate]="true" [maxSize]="5" [pageSize]="15" [(page)]="page" [collectionSize]="blocksCount" [rotate]="true" [maxSize]="maxSize" [pageSize]="15" [(page)]="page"
(pageChange)="pageChange(page)" [boundaryLinks]="true" [ellipses]="false"> (pageChange)="pageChange(page)" [boundaryLinks]="true" [ellipses]="false">
</ngb-pagination> </ngb-pagination>
</div> </div>
</div> </div>

View File

@ -14,6 +14,10 @@
td { td {
padding-top: 0.7rem !important; padding-top: 0.7rem !important;
padding-bottom: 0.7rem !important; padding-bottom: 0.7rem !important;
@media (max-width: 376px) {
padding-top: 0.73rem !important;
padding-bottom: 0.73rem !important;
}
} }
.clear-link { .clear-link {
@ -35,8 +39,7 @@ td {
.pool.widget { .pool.widget {
width: 40%; width: 40%;
padding-left: 30px; padding-left: 30px;
@media (max-width: 576px) { @media (max-width: 376px) {
padding-left: 40px;
width: 60%; width: 60%;
} }
} }

View File

@ -22,6 +22,7 @@ export class BlocksList implements OnInit {
paginationMaxSize: number; paginationMaxSize: number;
page = 1; page = 1;
lastPage = 1; lastPage = 1;
maxSize = window.innerWidth <= 767.98 ? 3 : 5;
blocksCount: number; blocksCount: number;
fromHeightSubject: BehaviorSubject<number> = new BehaviorSubject(this.fromBlockHeight); fromHeightSubject: BehaviorSubject<number> = new BehaviorSubject(this.fromBlockHeight);
skeletonLines: number[] = []; skeletonLines: number[] = [];

View File

@ -1,5 +1,5 @@
<div style="min-height: 295px"> <div style="min-height: 295px">
<table class="table latest-transactions"> <table class="table latest-adjustments">
<thead> <thead>
<tr> <tr>
<th class="d-none d-md-block" i18n="block.height">Height</th> <th class="d-none d-md-block" i18n="block.height">Height</th>

View File

@ -1,4 +1,4 @@
.latest-transactions { .latest-adjustments {
width: 100%; width: 100%;
text-align: left; text-align: left;
table-layout:fixed; table-layout:fixed;
@ -7,34 +7,8 @@
} }
td { td {
width: 25%; width: 25%;
} @media (max-width: 376px) {
.table-cell-satoshis { padding: 0.85rem;
display: none;
text-align: right;
@media (min-width: 576px) {
display: table-cell;
} }
@media (min-width: 768px) {
display: none;
}
@media (min-width: 1100px) {
display: table-cell;
}
}
.table-cell-fiat {
display: none;
text-align: right;
@media (min-width: 485px) {
display: table-cell;
}
@media (min-width: 768px) {
display: none;
}
@media (min-width: 992px) {
display: table-cell;
}
}
.table-cell-fees {
text-align: right;
} }
} }

View File

@ -1,4 +1,4 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit, HostBinding } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { Env, StateService } from 'src/app/services/state.service'; import { Env, StateService } from 'src/app/services/state.service';
@ -14,6 +14,8 @@ export class DocsComponent implements OnInit {
showWebSocketTab = true; showWebSocketTab = true;
showFaqTab = true; showFaqTab = true;
@HostBinding('attr.dir') dir = 'ltr';
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
private stateService: StateService, private stateService: StateService,
@ -32,6 +34,7 @@ export class DocsComponent implements OnInit {
this.env = this.stateService.env; this.env = this.stateService.env;
this.showWebSocketTab = ( ! ( ( this.stateService.network === "bisq" ) || ( this.stateService.network === "liquidtestnet" ) ) ); this.showWebSocketTab = ( ! ( ( this.stateService.network === "bisq" ) || ( this.stateService.network === "liquidtestnet" ) ) );
this.showFaqTab = ( this.env.BASE_MODULE === 'mempool' ) ? true : false; this.showFaqTab = ( this.env.BASE_MODULE === 'mempool' ) ? true : false;
document.querySelector<HTMLElement>( "html" ).style.scrollBehavior = "smooth"; document.querySelector<HTMLElement>( "html" ).style.scrollBehavior = "smooth";
} }

View File

@ -1,25 +1,31 @@
<ul ngbNav #nav="ngbNav" class="nav-pills mb-3" style="padding: 0px 35px" *ngIf="stateService.env.MINING_DASHBOARD"> <div class="mb-3 d-flex menu" style="padding: 0px 35px;">
<div class="d-inline-flex flex-wrap menu"> <a routerLinkActive="active" class="btn btn-primary w-50 mr-1"
<li ngbNavItem class="menu-li"> [routerLink]="['/graphs/mempool' | relativeUrl]">Mempool</a>
<a routerLinkActive="active" [routerLink]="['/graphs/mempool' | relativeUrl]" ngbNavLink>Mempool</a> <div ngbDropdown *ngIf="stateService.env.MINING_DASHBOARD" class="w-50">
</li> <button class="btn btn-primary w-100" id="dropdownBasic1" ngbDropdownToggle i18n="mining">Mining</button>
<li ngbNavItem class="menu-li"> <div ngbDropdownMenu aria-labelledby="dropdownBasic1">
<a routerLinkActive="active" [routerLink]="['/graphs/mining/pools' | relativeUrl]" ngbNavLink i18n="mining.pools"> <a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/mining/pools' | relativeUrl]"
i18n="mining.pools">
Pools ranking Pools ranking
</a> </a>
</li> <a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/mining/pools-dominance' | relativeUrl]"
<li ngbNavItem class="menu-li"> i18n="mining.pools-dominance">
<a routerLinkActive="active" [routerLink]="['/graphs/mining/pools-dominance' | relativeUrl]" ngbNavLink i18n="mining.pools-dominance">
Pools dominance Pools dominance
</a> </a>
</li> <a class="dropdown-item" routerLinkActive="active"
<li ngbNavItem class="menu-li"> [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" i18n="mining.hashrate-difficulty">
<a routerLinkActive="active" [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" ngbNavLink
i18n="mining.hashrate-difficulty">
Hashrate & Difficulty Hashrate & Difficulty
</a> </a>
</li> <a class="dropdown-item" routerLinkActive="active"
[routerLink]="['/graphs/mining/block-fees' | relativeUrl]" i18n="mining.block-fees">
Block Fees
</a>
<a class="dropdown-item" routerLinkActive="active"
[routerLink]="['/graphs/mining/block-rewards' | relativeUrl]" i18n="mining.block-rewards">
Block Rewards
</a>
</div>
</div> </div>
</ul> </div>
<router-outlet></router-outlet> <router-outlet></router-outlet>

View File

@ -1,9 +1,6 @@
.menu { .menu {
flex-grow: 1; flex-grow: 1;
max-width: 600px; @media (min-width: 576px) {
} max-width: 400px;
}
.menu-li {
flex-grow: 1;
text-align: center;
} }

View File

@ -1,5 +1,6 @@
import { Component, OnInit } from "@angular/core"; import { Component, OnInit } from "@angular/core";
import { StateService } from "src/app/services/state.service"; import { StateService } from "src/app/services/state.service";
import { WebsocketService } from "src/app/services/websocket.service";
@Component({ @Component({
selector: 'app-graphs', selector: 'app-graphs',
@ -7,9 +8,12 @@ import { StateService } from "src/app/services/state.service";
styleUrls: ['./graphs.component.scss'], styleUrls: ['./graphs.component.scss'],
}) })
export class GraphsComponent implements OnInit { export class GraphsComponent implements OnInit {
constructor(public stateService: StateService) { } constructor(
public stateService: StateService,
private websocketService: WebsocketService
) { }
ngOnInit(): void { ngOnInit(): void {
this.websocketService.want(['blocks']);
} }
} }

View File

@ -19,37 +19,40 @@
</div> </div>
<div class="card-header mb-0 mb-md-4" [style]="widget ? 'display:none' : ''"> <div class="card-header mb-0 mb-md-4" [style]="widget ? 'display:none' : ''">
<span i18n="mining.mining-pool-share">Hashrate & Difficulty</span> <span i18n="mining.hashrate-difficulty">Hashrate & Difficulty</span>
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(hashrateObservable$ | async) as hashrates"> <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(hashrateObservable$ | async) as stats">
<div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan"> <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="hashrates.availableTimespanDay >= 90"> <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.availableTimespanDay >= 30">
<input ngbButton type="radio" [value]="'3m'"> 3M <input ngbButton type="radio" [value]="'1m'" fragment="1m"> 1M
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="hashrates.availableTimespanDay >= 180"> <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.availableTimespanDay >= 90">
<input ngbButton type="radio" [value]="'6m'"> 6M <input ngbButton type="radio" [value]="'3m'" fragment="3m"> 3M
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="hashrates.availableTimespanDay >= 365"> <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.availableTimespanDay >= 180">
<input ngbButton type="radio" [value]="'1y'"> 1Y <input ngbButton type="radio" [value]="'6m'" fragment="6m"> 6M
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="hashrates.availableTimespanDay >= 730"> <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.availableTimespanDay >= 365">
<input ngbButton type="radio" [value]="'2y'"> 2Y <input ngbButton type="radio" [value]="'1y'" fragment="1y"> 1Y
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="hashrates.availableTimespanDay >= 1095"> <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.availableTimespanDay >= 730">
<input ngbButton type="radio" [value]="'3y'"> 3Y <input ngbButton type="radio" [value]="'2y'" fragment="2y"> 2Y
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm"> <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.availableTimespanDay >= 1095">
<input ngbButton type="radio" [value]="'all'"> ALL <input ngbButton type="radio" [value]="'3y'" fragment="3y"> 3Y
</label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.availableTimespanDay > 1095">
<input ngbButton type="radio" [value]="'all'" fragment="all"> ALL
</label> </label>
</div> </div>
</form> </form>
</div> </div>
<div [class]="!widget ? 'chart' : 'chart-widget'" <div [class]="!widget ? 'chart' : 'chart-widget'" echarts [initOpts]="chartInitOptions" [options]="chartOptions">
echarts [initOpts]="chartInitOptions" [options]="chartOptions"></div> </div>
<div class="text-center loadingGraphs" *ngIf="isLoading"> <div class="text-center loadingGraphs" *ngIf="isLoading">
<div class="spinner-border text-light"></div> <div class="spinner-border text-light"></div>
</div> </div>
</div> </div>
<ng-template #loadingStats> <ng-template #loadingStats>
@ -67,4 +70,4 @@
</p> </p>
</div> </div>
</div> </div>
</ng-template> </ng-template>

View File

@ -20,12 +20,11 @@
.full-container { .full-container {
padding: 0px 15px; padding: 0px 15px;
width: 100%; width: 100%;
height: calc(100% - 170px); min-height: 500px;
height: calc(100% - 150px);
@media (max-width: 992px) { @media (max-width: 992px) {
height: calc(100% - 220px); height: 100%;
}; padding-bottom: 100px;
@media (max-width: 575px) {
height: calc(100% - 260px);
}; };
} }
@ -93,17 +92,8 @@
} }
.item { .item {
width: 50%; width: 50%;
margin: 0px auto 10px;
display: inline-block; display: inline-block;
@media (min-width: 485px) { margin: 0px auto 20px;
margin: 0px auto 10px;
}
@media (min-width: 785px) {
margin: 0px auto 0px;
}
&:last-child {
margin: 0px auto 0px;
}
&:nth-child(2) { &:nth-child(2) {
order: 2; order: 2;
@media (min-width: 485px) { @media (min-width: 485px) {
@ -142,4 +132,4 @@
display: block; display: block;
max-width: 80px; max-width: 80px;
margin: 15px auto 3px; margin: 15px auto 3px;
} }

View File

@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnInit, HostBinding } from '@angular/core';
import { EChartsOption, graphic } from 'echarts'; import { EChartsOption, graphic } from 'echarts';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { delay, map, retryWhen, share, startWith, switchMap, tap } from 'rxjs/operators'; import { delay, map, retryWhen, share, startWith, switchMap, tap } from 'rxjs/operators';
@ -7,6 +7,8 @@ import { SeoService } from 'src/app/services/seo.service';
import { formatNumber } from '@angular/common'; import { formatNumber } from '@angular/common';
import { FormBuilder, FormGroup } from '@angular/forms'; import { FormBuilder, FormGroup } from '@angular/forms';
import { selectPowerOfTen } from 'src/app/bitcoin.utils'; import { selectPowerOfTen } from 'src/app/bitcoin.utils';
import { StorageService } from 'src/app/services/storage.service';
import { MiningService } from 'src/app/services/mining.service';
@Component({ @Component({
selector: 'app-hashrate-chart', selector: 'app-hashrate-chart',
@ -28,15 +30,16 @@ export class HashrateChartComponent implements OnInit {
@Input() right: number | string = 45; @Input() right: number | string = 45;
@Input() left: number | string = 75; @Input() left: number | string = 75;
miningWindowPreference: string;
radioGroupForm: FormGroup; radioGroupForm: FormGroup;
chartOptions: EChartsOption = {}; chartOptions: EChartsOption = {};
chartInitOptions = { chartInitOptions = {
renderer: 'svg', renderer: 'svg',
width: 'auto',
height: 'auto',
}; };
@HostBinding('attr.dir') dir = 'ltr';
hashrateObservable$: Observable<any>; hashrateObservable$: Observable<any>;
isLoading = true; isLoading = true;
formatNumber = formatNumber; formatNumber = formatNumber;
@ -47,20 +50,32 @@ export class HashrateChartComponent implements OnInit {
private apiService: ApiService, private apiService: ApiService,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private cd: ChangeDetectorRef, private cd: ChangeDetectorRef,
private storageService: StorageService,
private miningService: MiningService
) { ) {
this.radioGroupForm = this.formBuilder.group({ dateSpan: '1y' });
this.radioGroupForm.controls.dateSpan.setValue('1y');
} }
ngOnInit(): void { ngOnInit(): void {
if (!this.widget) { let firstRun = true;
if (this.widget) {
this.miningWindowPreference = '1y';
} else {
this.seoService.setTitle($localize`:@@mining.hashrate-difficulty:Hashrate and Difficulty`); this.seoService.setTitle($localize`:@@mining.hashrate-difficulty:Hashrate and Difficulty`);
this.miningWindowPreference = this.miningService.getDefaultTimespan('1m');
} }
this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference });
this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference);
this.hashrateObservable$ = this.radioGroupForm.get('dateSpan').valueChanges this.hashrateObservable$ = this.radioGroupForm.get('dateSpan').valueChanges
.pipe( .pipe(
startWith('1y'), startWith(this.miningWindowPreference),
switchMap((timespan) => { switchMap((timespan) => {
if (!this.widget && !firstRun) {
this.storageService.setValue('miningWindowPreference', timespan);
}
firstRun = false;
this.miningWindowPreference = timespan;
this.isLoading = true; this.isLoading = true;
return this.apiService.getHistoricalHashrate$(timespan) return this.apiService.getHistoricalHashrate$(timespan)
.pipe( .pipe(
@ -157,10 +172,10 @@ export class HashrateChartComponent implements OnInit {
'#D81B60', '#D81B60',
], ],
grid: { grid: {
top: 30, top: 20,
bottom: this.widget ? 30 : 70,
right: this.right, right: this.right,
left: this.left, left: this.left,
bottom: this.widget ? 30 : this.isMobile() ? 90 : 60,
}, },
tooltip: { tooltip: {
show: !this.isMobile() || !this.widget, show: !this.isMobile() || !this.widget,
@ -176,7 +191,7 @@ export class HashrateChartComponent implements OnInit {
align: 'left', align: 'left',
}, },
borderColor: '#000', borderColor: '#000',
formatter: function (ticks) { formatter: (ticks) => {
let hashrateString = ''; let hashrateString = '';
let difficultyString = ''; let difficultyString = '';
let hashratePowerOfTen: any = selectPowerOfTen(1); let hashratePowerOfTen: any = selectPowerOfTen(1);
@ -207,11 +222,14 @@ export class HashrateChartComponent implements OnInit {
<span>${hashrateString}</span><br> <span>${hashrateString}</span><br>
<span>${difficultyString}</span> <span>${difficultyString}</span>
`; `;
}.bind(this) }
}, },
xAxis: data.hashrates.length === 0 ? undefined : { xAxis: data.hashrates.length === 0 ? undefined : {
type: 'time', type: 'time',
splitNumber: (this.isMobile() || this.widget) ? 5 : 10, splitNumber: (this.isMobile() || this.widget) ? 5 : 10,
axisLabel: {
hideOverlap: true,
}
}, },
legend: (this.widget || data.hashrates.length === 0) ? undefined : { legend: (this.widget || data.hashrates.length === 0) ? undefined : {
data: [ data: [
@ -241,7 +259,7 @@ export class HashrateChartComponent implements OnInit {
}, },
yAxis: data.hashrates.length === 0 ? undefined : [ yAxis: data.hashrates.length === 0 ? undefined : [
{ {
min: function (value) { min: (value) => {
return value.min * 0.9; return value.min * 0.9;
}, },
type: 'value', type: 'value',
@ -250,7 +268,7 @@ export class HashrateChartComponent implements OnInit {
formatter: (val) => { formatter: (val) => {
const selectedPowerOfTen: any = selectPowerOfTen(val); const selectedPowerOfTen: any = selectPowerOfTen(val);
const newVal = Math.round(val / selectedPowerOfTen.divider); const newVal = Math.round(val / selectedPowerOfTen.divider);
return `${newVal} ${selectedPowerOfTen.unit}H/s` return `${newVal} ${selectedPowerOfTen.unit}H/s`;
} }
}, },
splitLine: { splitLine: {
@ -258,7 +276,7 @@ export class HashrateChartComponent implements OnInit {
} }
}, },
{ {
min: function (value) { min: (value) => {
return value.min * 0.9; return value.min * 0.9;
}, },
type: 'value', type: 'value',
@ -268,7 +286,7 @@ export class HashrateChartComponent implements OnInit {
formatter: (val) => { formatter: (val) => {
const selectedPowerOfTen: any = selectPowerOfTen(val); const selectedPowerOfTen: any = selectPowerOfTen(val);
const newVal = Math.round(val / selectedPowerOfTen.divider); const newVal = Math.round(val / selectedPowerOfTen.divider);
return `${newVal} ${selectedPowerOfTen.unit}` return `${newVal} ${selectedPowerOfTen.unit}`;
} }
}, },
splitLine: { splitLine: {
@ -278,6 +296,7 @@ export class HashrateChartComponent implements OnInit {
], ],
series: data.hashrates.length === 0 ? [] : [ series: data.hashrates.length === 0 ? [] : [
{ {
zlevel: 0,
name: 'Hashrate', name: 'Hashrate',
showSymbol: false, showSymbol: false,
symbol: 'none', symbol: 'none',
@ -288,6 +307,7 @@ export class HashrateChartComponent implements OnInit {
}, },
}, },
{ {
zlevel: 1,
yAxisIndex: 1, yAxisIndex: 1,
name: 'Difficulty', name: 'Difficulty',
showSymbol: false, showSymbol: false,
@ -312,7 +332,6 @@ export class HashrateChartComponent implements OnInit {
type: 'slider', type: 'slider',
brushSelect: false, brushSelect: false,
realtime: true, realtime: true,
bottom: this.isMobile() ? 30 : 0,
left: 20, left: 20,
right: 15, right: 15,
selectedDataBackground: { selectedDataBackground: {

View File

@ -1,32 +1,35 @@
<div [class]="widget === false ? 'full-container' : ''"> <div class="full-container">
<div class="card-header" [style]="widget ? 'display:none' : ''"> <div class="card-header mb-0 mb-md-4">
<span *ngIf="!widget" i18n="mining.pools-dominance">Mining pools dominance</span> <span i18n="mining.pools-dominance">Mining pools dominance</span>
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(hashrateObservable$ | async) as hashrates"> <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(hashrateObservable$ | async) as stats">
<div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan"> <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="hashrates.availableTimespanDay >= 90"> <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.availableTimespanDay >= 30">
<input ngbButton type="radio" [value]="'3m'"> 3M <input ngbButton type="radio" [value]="'1m'" fragment="1m"> 1M
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="hashrates.availableTimespanDay >= 180"> <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.availableTimespanDay >= 90">
<input ngbButton type="radio" [value]="'6m'"> 6M <input ngbButton type="radio" [value]="'3m'" fragment="3m"> 3M
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="hashrates.availableTimespanDay >= 365"> <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.availableTimespanDay >= 180">
<input ngbButton type="radio" [value]="'1y'"> 1Y <input ngbButton type="radio" [value]="'6m'" fragment="6m"> 6M
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="hashrates.availableTimespanDay >= 730"> <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.availableTimespanDay >= 365">
<input ngbButton type="radio" [value]="'2y'"> 2Y <input ngbButton type="radio" [value]="'1y'" fragment="1y"> 1Y
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="hashrates.availableTimespanDay >= 1095"> <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.availableTimespanDay >= 730">
<input ngbButton type="radio" [value]="'3y'"> 3Y <input ngbButton type="radio" [value]="'2y'" fragment="2y"> 2Y
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm"> <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.availableTimespanDay >= 1095">
<input ngbButton type="radio" [value]="'all'"> ALL <input ngbButton type="radio" [value]="'3y'" fragment="3y"> 3Y
</label>
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.availableTimespanDay > 1095">
<input ngbButton type="radio" [value]="'all'" fragment="all"> ALL
</label> </label>
</div> </div>
</form> </form>
</div> </div>
<div [class]="!widget ? 'chart' : 'chart-widget'" <div class="chart"
echarts [initOpts]="chartInitOptions" [options]="chartOptions"></div> echarts [initOpts]="chartInitOptions" [options]="chartOptions"></div>
<div class="text-center loadingGraphs" *ngIf="isLoading"> <div class="text-center loadingGraphs" *ngIf="isLoading">
<div class="spinner-border text-light"></div> <div class="spinner-border text-light"></div>

View File

@ -20,19 +20,18 @@
.full-container { .full-container {
padding: 0px 15px; padding: 0px 15px;
width: 100%; width: 100%;
height: calc(100% - 140px); min-height: 500px;
@media (max-width: 991px) { height: calc(100% - 150px);
height: calc(100% - 190px); @media (max-width: 992px) {
}; height: 100%;
@media (max-width: 575px) { padding-bottom: 100px;
height: calc(100% - 235px);
}; };
} }
.chart { .chart {
width: 100%; width: 100%;
height: 100%; height: 100%;
padding-bottom: 25px; padding-bottom: 20px;
padding-right: 10px; padding-right: 10px;
@media (max-width: 992px) { @media (max-width: 992px) {
padding-bottom: 25px; padding-bottom: 25px;
@ -43,12 +42,6 @@
@media (max-width: 767px) { @media (max-width: 767px) {
padding-bottom: 50px; padding-bottom: 50px;
} }
@media (max-width: 629px) {
padding-bottom: 85px;
}
@media (max-width: 567px) {
padding-bottom: 85px;
}
} }
.chart-widget { .chart-widget {
width: 100%; width: 100%;

View File

@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnInit, HostBinding } from '@angular/core';
import { EChartsOption } from 'echarts'; import { EChartsOption } from 'echarts';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { delay, map, retryWhen, share, startWith, switchMap, tap } from 'rxjs/operators'; import { delay, map, retryWhen, share, startWith, switchMap, tap } from 'rxjs/operators';
@ -6,6 +6,8 @@ import { ApiService } from 'src/app/services/api.service';
import { SeoService } from 'src/app/services/seo.service'; import { SeoService } from 'src/app/services/seo.service';
import { FormBuilder, FormGroup } from '@angular/forms'; import { FormBuilder, FormGroup } from '@angular/forms';
import { poolsColor } from 'src/app/app.constants'; import { poolsColor } from 'src/app/app.constants';
import { StorageService } from 'src/app/services/storage.service';
import { MiningService } from 'src/app/services/mining.service';
@Component({ @Component({
selector: 'app-hashrate-chart-pools', selector: 'app-hashrate-chart-pools',
@ -22,19 +24,19 @@ import { poolsColor } from 'src/app/app.constants';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class HashrateChartPoolsComponent implements OnInit { export class HashrateChartPoolsComponent implements OnInit {
@Input() widget = false;
@Input() right: number | string = 45; @Input() right: number | string = 45;
@Input() left: number | string = 25; @Input() left: number | string = 25;
miningWindowPreference: string;
radioGroupForm: FormGroup; radioGroupForm: FormGroup;
chartOptions: EChartsOption = {}; chartOptions: EChartsOption = {};
chartInitOptions = { chartInitOptions = {
renderer: 'svg', renderer: 'svg',
width: 'auto',
height: 'auto',
}; };
@HostBinding('attr.dir') dir = 'ltr';
hashrateObservable$: Observable<any>; hashrateObservable$: Observable<any>;
isLoading = true; isLoading = true;
@ -44,20 +46,29 @@ export class HashrateChartPoolsComponent implements OnInit {
private apiService: ApiService, private apiService: ApiService,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private cd: ChangeDetectorRef, private cd: ChangeDetectorRef,
private storageService: StorageService,
private miningService: MiningService
) { ) {
this.radioGroupForm = this.formBuilder.group({ dateSpan: '1y' }); this.radioGroupForm = this.formBuilder.group({ dateSpan: '1y' });
this.radioGroupForm.controls.dateSpan.setValue('1y'); this.radioGroupForm.controls.dateSpan.setValue('1y');
} }
ngOnInit(): void { ngOnInit(): void {
if (!this.widget) { let firstRun = true;
this.seoService.setTitle($localize`:@@mining.pools-historical-dominance:Pools Historical Dominance`);
} this.seoService.setTitle($localize`:@@mining.pools-historical-dominance:Pools Historical Dominance`);
this.miningWindowPreference = this.miningService.getDefaultTimespan('1m');
this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference });
this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference);
this.hashrateObservable$ = this.radioGroupForm.get('dateSpan').valueChanges this.hashrateObservable$ = this.radioGroupForm.get('dateSpan').valueChanges
.pipe( .pipe(
startWith('1y'), startWith(this.miningWindowPreference),
switchMap((timespan) => { switchMap((timespan) => {
if (!firstRun) {
this.storageService.setValue('miningWindowPreference', timespan);
}
firstRun = false;
this.isLoading = true; this.isLoading = true;
return this.apiService.getHistoricalPoolsHashrate$(timespan) return this.apiService.getHistoricalPoolsHashrate$(timespan)
.pipe( .pipe(
@ -75,6 +86,7 @@ export class HashrateChartPoolsComponent implements OnInit {
const legends = []; const legends = [];
for (const name in grouped) { for (const name in grouped) {
series.push({ series.push({
zlevel: 0,
stack: 'Total', stack: 'Total',
name: name, name: name,
showSymbol: false, showSymbol: false,
@ -84,7 +96,7 @@ export class HashrateChartPoolsComponent implements OnInit {
lineStyle: { width: 0 }, lineStyle: { width: 0 },
areaStyle: { opacity: 1 }, areaStyle: { opacity: 1 },
smooth: true, smooth: true,
color: poolsColor[name.replace(/[^a-zA-Z0-9]/g, "").toLowerCase()], color: poolsColor[name.replace(/[^a-zA-Z0-9]/g, '').toLowerCase()],
emphasis: { emphasis: {
disabled: true, disabled: true,
scale: false, scale: false,
@ -157,11 +169,11 @@ export class HashrateChartPoolsComponent implements OnInit {
grid: { grid: {
right: this.right, right: this.right,
left: this.left, left: this.left,
bottom: this.widget ? 30 : 60, bottom: 70,
top: this.widget || this.isMobile() ? 10 : 50, top: this.isMobile() ? 10 : 50,
}, },
tooltip: { tooltip: {
show: !this.isMobile() || !this.widget, show: !this.isMobile(),
trigger: 'axis', trigger: 'axis',
axisPointer: { axisPointer: {
type: 'line' type: 'line'
@ -188,9 +200,12 @@ export class HashrateChartPoolsComponent implements OnInit {
}, },
xAxis: data.series.length === 0 ? undefined : { xAxis: data.series.length === 0 ? undefined : {
type: 'time', type: 'time',
splitNumber: (this.isMobile() || this.widget) ? 5 : 10, splitNumber: this.isMobile() ? 5 : 10,
axisLabel: {
hideOverlap: true,
}
}, },
legend: (this.isMobile() || this.widget || data.series.length === 0) ? undefined : { legend: (this.isMobile() || data.series.length === 0) ? undefined : {
data: data.legends data: data.legends
}, },
yAxis: data.series.length === 0 ? undefined : { yAxis: data.series.length === 0 ? undefined : {
@ -207,7 +222,7 @@ export class HashrateChartPoolsComponent implements OnInit {
min: 0, min: 0,
}, },
series: data.series, series: data.series,
dataZoom: this.widget ? null : [{ dataZoom: [{
type: 'inside', type: 'inside',
realtime: true, realtime: true,
zoomLock: true, zoomLock: true,
@ -220,7 +235,6 @@ export class HashrateChartPoolsComponent implements OnInit {
type: 'slider', type: 'slider',
brushSelect: false, brushSelect: false,
realtime: true, realtime: true,
bottom: 0,
left: 20, left: 20,
right: 15, right: 15,
selectedDataBackground: { selectedDataBackground: {

View File

@ -157,6 +157,7 @@ export class IncomingTransactionsGraphComponent implements OnInit, OnChanges {
}, },
series: [ series: [
{ {
zlevel: 0,
data: this.data.series[0], data: this.data.series[0],
type: 'line', type: 'line',
smooth: false, smooth: false,

View File

@ -62,7 +62,7 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy {
) { } ) { }
enabledMiningInfoIfNeeded(url) { enabledMiningInfoIfNeeded(url) {
this.showMiningInfo = url === '/mining'; this.showMiningInfo = url.indexOf('/mining') !== -1;
this.cd.markForCheck(); // Need to update the view asap this.cd.markForCheck(); // Need to update the view asap
} }

View File

@ -122,6 +122,7 @@ export class MempoolGraphComponent implements OnInit, OnChanges {
if (index >= this.feeLimitIndex) { if (index >= this.feeLimitIndex) {
newColors.push(this.chartColorsOrdered[index]); newColors.push(this.chartColorsOrdered[index]);
seriesGraph.push({ seriesGraph.push({
zlevel: 0,
name: this.feeLevelsOrdered[index], name: this.feeLevelsOrdered[index],
type: 'line', type: 'line',
stack: 'fees', stack: 'fees',

View File

@ -4,7 +4,7 @@
<ng-template #done> <ng-template #done>
<ng-template [ngIf]="miner" [ngIfElse]="unknownMiner"> <ng-template [ngIf]="miner" [ngIfElse]="unknownMiner">
<a placement="bottom" [ngbTooltip]="title" [href]="url" target="_blank" class="badge badge-primary">{{ miner }}</a> <a placement="bottom" [ngbTooltip]="title" [href]="url" [target]="target" class="badge badge-primary">{{ miner }}</a>
</ng-template> </ng-template>
<ng-template #unknownMiner> <ng-template #unknownMiner>
<span class="badge badge-secondary" i18n="miner.tag.unknown-miner">Unknown</span> <span class="badge badge-secondary" i18n="miner.tag.unknown-miner">Unknown</span>

View File

@ -1,6 +1,8 @@
import { Component, Input, OnChanges, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core'; import { Component, Input, OnChanges, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { AssetsService } from 'src/app/services/assets.service'; import { AssetsService } from 'src/app/services/assets.service';
import { Transaction } from 'src/app/interfaces/electrs.interface'; import { Transaction } from 'src/app/interfaces/electrs.interface';
import { StateService } from 'src/app/services/state.service';
import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe';
@Component({ @Component({
selector: 'app-miner', selector: 'app-miner',
@ -13,15 +15,23 @@ export class MinerComponent implements OnChanges {
miner = ''; miner = '';
title = ''; title = '';
url = ''; url = '';
target = '_blank';
loading = true; loading = true;
constructor( constructor(
private assetsService: AssetsService, private assetsService: AssetsService,
private cd: ChangeDetectorRef, private cd: ChangeDetectorRef,
public stateService: StateService,
private relativeUrlPipe: RelativeUrlPipe,
) { } ) { }
ngOnChanges() { ngOnChanges() {
this.miner = ''; this.miner = '';
if (this.stateService.env.MINING_DASHBOARD) {
this.miner = 'Unknown';
this.url = this.relativeUrlPipe.transform(`/mining/pool/unknown`);
this.target = '';
}
this.loading = true; this.loading = true;
this.findMinerFromCoinbase(); this.findMinerFromCoinbase();
} }
@ -40,7 +50,13 @@ export class MinerComponent implements OnChanges {
if (pools.payout_addresses[vout.scriptpubkey_address]) { if (pools.payout_addresses[vout.scriptpubkey_address]) {
this.miner = pools.payout_addresses[vout.scriptpubkey_address].name; this.miner = pools.payout_addresses[vout.scriptpubkey_address].name;
this.title = $localize`:@@miner-identified-by-payout:Identified by payout address: '${vout.scriptpubkey_address}:PAYOUT_ADDRESS:'`; this.title = $localize`:@@miner-identified-by-payout:Identified by payout address: '${vout.scriptpubkey_address}:PAYOUT_ADDRESS:'`;
this.url = pools.payout_addresses[vout.scriptpubkey_address].link; const pool = pools.payout_addresses[vout.scriptpubkey_address];
if (this.stateService.env.MINING_DASHBOARD && pools.slugs && pools.slugs[pool.name] !== undefined) {
this.url = this.relativeUrlPipe.transform(`/mining/pool/${pools.slugs[pool.name]}`);
this.target = '';
} else {
this.url = pool.link;
}
break; break;
} }
@ -48,9 +64,15 @@ export class MinerComponent implements OnChanges {
if (pools.coinbase_tags.hasOwnProperty(tag)) { if (pools.coinbase_tags.hasOwnProperty(tag)) {
const coinbaseAscii = this.hex2ascii(this.coinbaseTransaction.vin[0].scriptsig); const coinbaseAscii = this.hex2ascii(this.coinbaseTransaction.vin[0].scriptsig);
if (coinbaseAscii.indexOf(tag) > -1) { if (coinbaseAscii.indexOf(tag) > -1) {
this.miner = pools.coinbase_tags[tag].name; const pool = pools.coinbase_tags[tag];
this.miner = pool.name;
this.title = $localize`:@@miner-identified-by-coinbase:Identified by coinbase tag: '${tag}:TAG:'`; this.title = $localize`:@@miner-identified-by-coinbase:Identified by coinbase tag: '${tag}:TAG:'`;
this.url = pools.coinbase_tags[tag].link; if (this.stateService.env.MINING_DASHBOARD && pools.slugs && pools.slugs[pool.name] !== undefined) {
this.url = this.relativeUrlPipe.transform(`/mining/pool/${pools.slugs[pool.name]}`);
this.target = '';
} else {
this.url = pool.link;
}
break; break;
} }
} }

View File

@ -27,7 +27,7 @@
<!-- pool distribution --> <!-- pool distribution -->
<div class="col"> <div class="col">
<div class="card" style="height: 385px"> <div class="card">
<div class="card-body"> <div class="card-body">
<app-pool-ranking [widget]=true></app-pool-ranking> <app-pool-ranking [widget]=true></app-pool-ranking>
<div class="mt-1"><a [routerLink]="['/graphs/mining/pools' | relativeUrl]" i18n="dashboard.view-more">View more <div class="mt-1"><a [routerLink]="['/graphs/mining/pools' | relativeUrl]" i18n="dashboard.view-more">View more
@ -38,7 +38,7 @@
<!-- hashrate --> <!-- hashrate -->
<div class="col"> <div class="col">
<div class="card" style="height: 385px"> <div class="card">
<div class="card-body"> <div class="card-body">
<app-hashrate-chart [widget]=true></app-hashrate-chart> <app-hashrate-chart [widget]=true></app-hashrate-chart>
<div class="mt-1"><a [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" i18n="dashboard.view-more">View more <div class="mt-1"><a [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" i18n="dashboard.view-more">View more
@ -49,7 +49,7 @@
<!-- Latest blocks --> <!-- Latest blocks -->
<div class="col"> <div class="col">
<div class="card" style="height: 385px"> <div class="card">
<div class="card-body"> <div class="card-body">
<h5 class="card-title"> <h5 class="card-title">
Latest blocks Latest blocks
@ -63,13 +63,13 @@
<!-- Difficult adjustments --> <!-- Difficult adjustments -->
<div class="col"> <div class="col">
<div class="card" style="height: 385px"> <div class="card">
<div class="card-body"> <div class="card-body">
<h5 class="card-title"> <h5 class="card-title">
Adjustments Adjustments
</h5> </h5>
<app-difficulty-adjustments-table></app-difficulty-adjustments-table> <app-difficulty-adjustments-table></app-difficulty-adjustments-table>
<div class="mt-1"><a [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" i18n="dashboard.view-more">View more <div><a [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" i18n="dashboard.view-more">View more
&raquo;</a></div> &raquo;</a></div>
</div> </div>
</div> </div>

View File

@ -55,7 +55,7 @@
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="miningStats.availableTimespanDay >= 1095"> <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="miningStats.availableTimespanDay >= 1095">
<input ngbButton type="radio" [value]="'3y'" fragment="3y"> 3Y <input ngbButton type="radio" [value]="'3y'" fragment="3y"> 3Y
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm"> <label ngbButtonLabel class="btn-primary btn-sm" *ngIf="miningStats.availableTimespanDay > 1095">
<input ngbButton type="radio" [value]="'all'" fragment="all"> ALL <input ngbButton type="radio" [value]="'all'" fragment="all"> ALL
</label> </label>
</div> </div>
@ -63,8 +63,12 @@
</div> </div>
<div [class]="!widget ? 'bottom-padding' : 'pb-0'" class="container pb-lg-0"> <div [class]="!widget ? 'bottom-padding' : 'pb-0'" class="container pb-lg-0">
<div [class]="widget ? 'chart-widget' : 'chart'" echarts [initOpts]="chartInitOptions" [options]="chartOptions" <div>
(chartInit)="onChartInit($event)"></div> <div [class]="widget ? 'chart-widget' : 'chart'" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
(chartInit)="onChartInit($event)">
</div>
</div>
<div class="text-center loadingGraphs" *ngIf="isLoading"> <div class="text-center loadingGraphs" *ngIf="isLoading">
<div class="spinner-border text-light"></div> <div class="spinner-border text-light"></div>
</div> </div>
@ -75,7 +79,7 @@
<th class="d-none d-md-block" i18n="mining.rank">Rank</th> <th class="d-none d-md-block" i18n="mining.rank">Rank</th>
<th class=""></th> <th class=""></th>
<th class="" i18n="mining.pool-name">Pool</th> <th class="" i18n="mining.pool-name">Pool</th>
<th class="" *ngIf="this.poolsWindowPreference === '24h'" i18n="mining.hashrate">Hashrate</th> <th class="" *ngIf="this.miningWindowPreference === '24h'" i18n="mining.hashrate">Hashrate</th>
<th class="" i18n="master-page.blocks">Blocks</th> <th class="" i18n="master-page.blocks">Blocks</th>
<th class="d-none d-md-block" i18n="mining.empty-blocks">Empty Blocks</th> <th class="d-none d-md-block" i18n="mining.empty-blocks">Empty Blocks</th>
</tr> </tr>
@ -85,8 +89,8 @@
<td class="d-none d-md-block">{{ pool.rank }}</td> <td class="d-none d-md-block">{{ pool.rank }}</td>
<td class="text-right"><img width="25" height="25" src="{{ pool.logo }}" <td class="text-right"><img width="25" height="25" src="{{ pool.logo }}"
onError="this.src = './resources/mining-pools/default.svg'"></td> onError="this.src = './resources/mining-pools/default.svg'"></td>
<td class=""><a [routerLink]="[('/mining/pool/' + pool.poolId) | relativeUrl]">{{ pool.name }}</a></td> <td class=""><a [routerLink]="[('/mining/pool/' + pool.slug) | relativeUrl]">{{ pool.name }}</a></td>
<td class="" *ngIf="this.poolsWindowPreference === '24h' && !isLoading">{{ pool.lastEstimatedHashrate }} {{ <td class="" *ngIf="this.miningWindowPreference === '24h' && !isLoading">{{ pool.lastEstimatedHashrate }} {{
miningStats.miningUnits.hashrateUnit }}</td> miningStats.miningUnits.hashrateUnit }}</td>
<td class="">{{ pool['blockText'] }}</td> <td class="">{{ pool['blockText'] }}</td>
<td class="d-none d-md-block">{{ pool.emptyBlocks }} ({{ pool.emptyBlockRatio }}%)</td> <td class="d-none d-md-block">{{ pool.emptyBlocks }} ({{ pool.emptyBlockRatio }}%)</td>
@ -95,7 +99,7 @@
<td class="d-none d-md-block"></td> <td class="d-none d-md-block"></td>
<td class="text-right"></td> <td class="text-right"></td>
<td class="" i18n="mining.all-miners"><b>All miners</b></td> <td class="" i18n="mining.all-miners"><b>All miners</b></td>
<td class="" *ngIf="this.poolsWindowPreference === '24h'"><b>{{ miningStats.lastEstimatedHashrate}} {{ <td class="" *ngIf="this.miningWindowPreference === '24h'"><b>{{ miningStats.lastEstimatedHashrate}} {{
miningStats.miningUnits.hashrateUnit }}</b></td> miningStats.miningUnits.hashrateUnit }}</b></td>
<td class=""><b>{{ miningStats.blockCount }}</b></td> <td class=""><b>{{ miningStats.blockCount }}</b></td>
<td class="d-none d-md-block"><b>{{ miningStats.totalEmptyBlock }} ({{ miningStats.totalEmptyBlockRatio <td class="d-none d-md-block"><b>{{ miningStats.totalEmptyBlock }} ({{ miningStats.totalEmptyBlockRatio
@ -129,4 +133,4 @@
</p> </p>
</div> </div>
</div> </div>
</ng-template> </ng-template>

View File

@ -28,7 +28,7 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
max-height: 270px; max-height: 270px;
@media (max-width: 767.98px) { @media (max-width: 485px) {
max-height: 200px; max-height: 200px;
} }
} }
@ -93,17 +93,8 @@
} }
.item { .item {
width: 50%; width: 50%;
margin: 0px auto 10px;
display: inline-block; display: inline-block;
@media (min-width: 485px) { margin: 0px auto 20px;
margin: 0px auto 10px;
}
@media (min-width: 785px) {
margin: 0px auto 0px;
}
&:last-child {
margin: 0px auto 0px;
}
&:nth-child(2) { &:nth-child(2) {
order: 2; order: 2;
@media (min-width: 485px) { @media (min-width: 485px) {

View File

@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, Input, NgZone, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input, NgZone, OnInit, HostBinding } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms'; import { FormBuilder, FormGroup } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { EChartsOption, PieSeriesOption } from 'echarts'; import { EChartsOption, PieSeriesOption } from 'echarts';
@ -10,6 +10,7 @@ import { StorageService } from '../..//services/storage.service';
import { MiningService, MiningStats } from '../../services/mining.service'; import { MiningService, MiningStats } from '../../services/mining.service';
import { StateService } from '../../services/state.service'; import { StateService } from '../../services/state.service';
import { chartColors, poolsColor } from 'src/app/app.constants'; import { chartColors, poolsColor } from 'src/app/app.constants';
import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe';
@Component({ @Component({
selector: 'app-pool-ranking', selector: 'app-pool-ranking',
@ -18,20 +19,20 @@ import { chartColors, poolsColor } from 'src/app/app.constants';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class PoolRankingComponent implements OnInit { export class PoolRankingComponent implements OnInit {
@Input() widget: boolean = false; @Input() widget = false;
poolsWindowPreference: string; miningWindowPreference: string;
radioGroupForm: FormGroup; radioGroupForm: FormGroup;
isLoading = true; isLoading = true;
chartOptions: EChartsOption = {}; chartOptions: EChartsOption = {};
chartInitOptions = { chartInitOptions = {
renderer: 'svg', renderer: 'svg',
width: 'auto',
height: 'auto',
}; };
chartInstance: any = undefined; chartInstance: any = undefined;
@HostBinding('attr.dir') dir = 'ltr';
miningStatsObservable$: Observable<MiningStats>; miningStatsObservable$: Observable<MiningStats>;
constructor( constructor(
@ -47,13 +48,13 @@ export class PoolRankingComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
if (this.widget) { if (this.widget) {
this.poolsWindowPreference = '1w'; this.miningWindowPreference = '1w';
} else { } else {
this.seoService.setTitle($localize`:@@mining.mining-pools:Mining Pools`); this.seoService.setTitle($localize`:@@mining.mining-pools:Mining Pools`);
this.poolsWindowPreference = this.storageService.getValue('poolsWindowPreference') ? this.storageService.getValue('poolsWindowPreference') : '1w'; this.miningWindowPreference = this.miningService.getDefaultTimespan('24h');
} }
this.radioGroupForm = this.formBuilder.group({ dateSpan: this.poolsWindowPreference }); this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference });
this.radioGroupForm.controls.dateSpan.setValue(this.poolsWindowPreference); this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference);
// When... // When...
this.miningStatsObservable$ = combineLatest([ this.miningStatsObservable$ = combineLatest([
@ -66,12 +67,12 @@ export class PoolRankingComponent implements OnInit {
// ...or we change the timespan // ...or we change the timespan
this.radioGroupForm.get('dateSpan').valueChanges this.radioGroupForm.get('dateSpan').valueChanges
.pipe( .pipe(
startWith(this.poolsWindowPreference), // (trigger when the page loads) startWith(this.miningWindowPreference), // (trigger when the page loads)
tap((value) => { tap((value) => {
if (!this.widget) { if (!this.widget) {
this.storageService.setValue('poolsWindowPreference', value); this.storageService.setValue('miningWindowPreference', value);
} }
this.poolsWindowPreference = value; this.miningWindowPreference = value;
}) })
) )
]) ])
@ -79,7 +80,7 @@ export class PoolRankingComponent implements OnInit {
.pipe( .pipe(
switchMap(() => { switchMap(() => {
this.isLoading = true; this.isLoading = true;
return this.miningService.getMiningStats(this.poolsWindowPreference) return this.miningService.getMiningStats(this.miningWindowPreference)
.pipe( .pipe(
catchError((e) => of(this.getEmptyMiningStat())) catchError((e) => of(this.getEmptyMiningStat()))
); );
@ -149,7 +150,7 @@ export class PoolRankingComponent implements OnInit {
}, },
borderColor: '#000', borderColor: '#000',
formatter: () => { formatter: () => {
if (this.poolsWindowPreference === '24h') { if (this.miningWindowPreference === '24h') {
return `<b style="color: white">${pool.name} (${pool.share}%)</b><br>` + return `<b style="color: white">${pool.name} (${pool.share}%)</b><br>` +
pool.lastEstimatedHashrate.toString() + ' PH/s' + pool.lastEstimatedHashrate.toString() + ' PH/s' +
`<br>` + pool.blockCount.toString() + ` blocks`; `<br>` + pool.blockCount.toString() + ` blocks`;
@ -159,7 +160,7 @@ export class PoolRankingComponent implements OnInit {
} }
} }
}, },
data: pool.poolId, data: pool.slug,
} as PieSeriesOption); } as PieSeriesOption);
}); });
@ -185,7 +186,7 @@ export class PoolRankingComponent implements OnInit {
}, },
borderColor: '#000', borderColor: '#000',
formatter: () => { formatter: () => {
if (this.poolsWindowPreference === '24h') { if (this.miningWindowPreference === '24h') {
return `<b style="color: white">${'Other'} (${totalShareOther.toFixed(2)}%)</b><br>` + return `<b style="color: white">${'Other'} (${totalShareOther.toFixed(2)}%)</b><br>` +
totalEstimatedHashrateOther.toString() + ' PH/s' + totalEstimatedHashrateOther.toString() + ' PH/s' +
`<br>` + totalBlockOther.toString() + ` blocks`; `<br>` + totalBlockOther.toString() + ` blocks`;
@ -202,30 +203,6 @@ export class PoolRankingComponent implements OnInit {
} }
prepareChartOptions(miningStats) { prepareChartOptions(miningStats) {
let network = this.stateService.network;
if (network === '') {
network = 'bitcoin';
}
network = network.charAt(0).toUpperCase() + network.slice(1);
let radius: any[] = ['20%', '80%'];
let top: number = 0; let height = undefined;
if (this.isMobile() && this.widget) {
top = -30;
height = 270;
radius = ['10%', '50%'];
} else if (this.isMobile() && !this.widget) {
top = -40;
height = 300;
radius = ['10%', '50%'];
} else if (this.widget) {
radius = ['15%', '60%'];
top = -20;
height = 330;
} else {
top = 0;
}
this.chartOptions = { this.chartOptions = {
animation: false, animation: false,
color: chartColors, color: chartColors,
@ -237,12 +214,11 @@ export class PoolRankingComponent implements OnInit {
}, },
series: [ series: [
{ {
zlevel: 0,
minShowLabelAngle: 3.6, minShowLabelAngle: 3.6,
top: top,
height: height,
name: 'Mining pool', name: 'Mining pool',
type: 'pie', type: 'pie',
radius: radius, radius: ['20%', '80%'],
data: this.generatePoolsChartSerieData(miningStats), data: this.generatePoolsChartSerieData(miningStats),
labelLine: { labelLine: {
lineStyle: { lineStyle: {
@ -284,7 +260,8 @@ export class PoolRankingComponent implements OnInit {
return; return;
} }
this.zone.run(() => { this.zone.run(() => {
this.router.navigate(['/mining/pool/', e.data.data]); const url = new RelativeUrlPipe(this.stateService).transform(`/mining/pool/${e.data.data}`);
this.router.navigate([url]);
}); });
}); });
} }

View File

@ -1,5 +1,6 @@
<div class="container-xl"> <div class="container-xl">
<!-- Pool overview -->
<div *ngIf="poolStats$ | async as poolStats; else loadingMain"> <div *ngIf="poolStats$ | async as poolStats; else loadingMain">
<div style="display:flex" class="mb-3"> <div style="display:flex" class="mb-3">
<img width="50" height="50" src="{{ poolStats['logo'] }}" <img width="50" height="50" src="{{ poolStats['logo'] }}"
@ -9,107 +10,268 @@
<div class="box"> <div class="box">
<div class="row"> <div class="row">
<div class="col-lg-9">
<table class="table table-borderless table-striped" style="table-layout: fixed;"> <div class="col-lg-6">
<table class="table table-borderless table-striped taller" style="table-layout: fixed;">
<tbody> <tbody>
<tr>
<td class="label">Tags</td> <!-- Regexes desktop -->
<td class="text-truncate" *ngIf="poolStats.pool.regexes.length else nodata"> <tr *ngIf="!isMobile()" class="taller-row">
<div class="scrollable"> <td class="label" i18n="mining.tags">Tags</td>
<td *ngIf="poolStats.pool.regexes.length else nodata" style="vertical-align: middle">
<div class="scrollable">{{ poolStats.pool.regexes }}</div>
</td>
</tr>
<!-- Regexes mobile -->
<tr *ngIf="isMobile()">
<td colspan=2>
<span class="label" i18n="mining.tags">Tags</span>
<div *ngIf="poolStats.pool.regexes.length else nodatamobile" class="overflow-auto">
{{ poolStats.pool.regexes }} {{ poolStats.pool.regexes }}
</div> </div>
</td> </td>
</tr> </tr>
<tr>
<td class="label">Addresses</td> <!-- Addresses desktop -->
<td class="text-truncate" *ngIf="poolStats.pool.addresses.length else nodata"> <tr *ngIf="!isMobile()" class="taller-row">
<div class="scrollable"> <td class="label addresses" i18n="mining.addresses">Addresses</td>
<a *ngFor="let address of poolStats.pool.addresses" <td *ngIf="poolStats.pool.addresses.length else nodata" style="padding-top: 25px">
[routerLink]="['/address' | relativeUrl, address]">{{ <a [routerLink]="['/address' | relativeUrl, poolStats.pool.addresses[0]]" class="first-address">
address }}<br></a> {{ poolStats.pool.addresses[0] }}
</a>
<div>
<div #collapse="ngbCollapse" [(ngbCollapse)]="gfg">
<a *ngFor="let address of poolStats.pool.addresses | slice: 1"
[routerLink]="['/address' | relativeUrl, address]">{{
address }}<br></a>
</div>
<button *ngIf="poolStats.pool.addresses.length >= 2" type="button"
class="btn btn-sm btn-primary small-button" (click)="collapse.toggle()"
[attr.aria-expanded]="!gfg" aria-controls="collapseExample">
<div *ngIf="gfg"><span i18n="show-all">Show all</span> ({{ poolStats.pool.addresses.length }})
</div>
<span *ngIf="!gfg" i18n="hide">Hide</span>
</button>
</div>
</td>
</tr>
<!-- Addresses mobile -->
<tr *ngIf="isMobile()">
<td colspan=2>
<span class="label" i18n="mining.addresses">Addresses</span>
<div *ngIf="poolStats.pool.addresses.length else nodatamobile">
<button *ngIf="poolStats.pool.addresses.length >= 2" type="button"
class="btn btn-sm btn-primary float-right small-button mobile" (click)="collapse.toggle()"
[attr.aria-expanded]="!gfg" aria-controls="collapseExample">
<span i18n="show-all">Show all</span> ({{ poolStats.pool.addresses.length }})
</button>
<a [routerLink]="['/address' | relativeUrl, poolStats.pool.addresses[0]]">
{{ poolStats.pool.addresses[0] | shortenString: 40 }}
</a>
<div #collapse="ngbCollapse" [(ngbCollapse)]="gfg" style="width: 100%">
<a *ngFor="let address of poolStats.pool.addresses | slice: 1"
[routerLink]="['/address' | relativeUrl, address]">{{
address | shortenString: 40 }}<br></a>
</div>
</div> </div>
</td> </td>
<ng-template #nodata>
<td class="right-mobile">~</td>
</ng-template>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="col-lg-3">
<div class="col-lg-6">
<table class="table table-borderless table-striped"> <table class="table table-borderless table-striped">
<tbody> <tbody>
<tr>
<td class="label">Mined Blocks</td> <!-- Hashrate desktop -->
<td class="data">{{ formatNumber(poolStats.blockCount, this.locale, '1.0-0') }}</td> <tr *ngIf="!isMobile()" class="taller-row">
<td class="label" i18n="mining.hashrate-24h">Hashrate (24h)</td>
<td class="data">
<table class="table table-xs table-data">
<thead>
<tr>
<th scope="col" class="block-count-title" style="width: 37%" i18n="mining.estimated">Estimated
</th>
<th scope="col" class="block-count-title" style="width: 37%" i18n="mining.reported">Reported
</th>
<th scope="col" class="block-count-title" style="width: 26%" i18n="mining.luck">Luck</th>
</tr>
</thead>
<tbody>
<td>{{ poolStats.estimatedHashrate | amountShortener : 1 : 'H/s' }}</td>
<ng-template *ngIf="poolStats.luck; else noreported">
<td>{{ poolStats.reportedHashrate | amountShortener : 1 : 'H/s' }}</td>
<td>{{ formatNumber(poolStats.luck, this.locale, '1.2-2') }}%</td>
</ng-template>
</tbody>
</table>
</td>
</tr> </tr>
<tr> <!-- Hashrate mobile -->
<td class="label">Empty Blocks</td> <tr *ngIf="isMobile()">
<td class="data">{{ formatNumber(poolStats.emptyBlocks, this.locale, '1.0-0') }}</td> <td colspan="2">
<span class="label" i18n="mining.hashrate-24h">Hashrate (24h)</span>
<table class="table table-xs table-data">
<thead>
<tr>
<th scope="col" class="block-count-title" style="width: 33%" i18n="mining.estimated">Estimated
</th>
<th scope="col" class="block-count-title" style="width: 37%" i18n="mining.reported">Reported
</th>
<th scope="col" class="block-count-title" style="width: 30%" i18n="mining.luck">Luck</th>
</tr>
</thead>
<tbody>
<td>{{ poolStats.estimatedHashrate | amountShortener : 1 : 'H/s' }}</td>
<ng-template *ngIf="poolStats.luck; else noreported">
<td>{{ poolStats.reportedHashrate | amountShortener : 1 : 'H/s' }}</td>
<td>{{ formatNumber(poolStats.luck, this.locale, '1.2-2') }}%</td>
</ng-template>
</tbody>
</table>
</td>
</tr> </tr>
<ng-template #noreported>
<td>~</td>
<td>~</td>
</ng-template>
<!-- Mined blocks desktop -->
<tr *ngIf="!isMobile()" class="taller-row">
<td class="label" i18n="mining.mined-blocks">Mined Blocks</td>
<td class="data">
<table class="table table-xs table-data">
<thead>
<tr>
<th scope="col" class="block-count-title" style="width: 37%" i18n="24h">24h</th>
<th scope="col" class="block-count-title" style="width: 37%" i18n="1w">1w</th>
<th scope="col" class="block-count-title" style="width: 26%" i18n="all">All</th>
</tr>
</thead>
<tbody>
<td>{{ formatNumber(poolStats.blockCount['24h'], this.locale, '1.0-0') }} ({{ formatNumber(100 *
poolStats.blockShare['24h'], this.locale, '1.0-0') }}%)</td>
<td>{{ formatNumber(poolStats.blockCount['1w'], this.locale, '1.0-0') }} ({{ formatNumber(100 *
poolStats.blockShare['1w'], this.locale, '1.0-0') }}%)</td>
<td>{{ formatNumber(poolStats.blockCount['all'], this.locale, '1.0-0') }} ({{ formatNumber(100 *
poolStats.blockShare['all'], this.locale, '1.0-0') }}%)</td>
</tbody>
</table>
</td>
</tr>
<!-- Mined blocks mobile -->
<tr *ngIf="isMobile()">
<td colspan=2>
<span class="label" i18n="mining.mined-blocks">Mined Blocks</span>
<table class="table table-xs table-data">
<thead>
<tr>
<th scope="col" class="block-count-title" style="width: 33%" i18n="24h">24h</th>
<th scope="col" class="block-count-title" style="width: 37%" i18n="1w">1w</th>
<th scope="col" class="block-count-title" style="width: 30%" i18n="all">All</th>
</tr>
</thead>
<tbody>
<td>{{ formatNumber(poolStats.blockCount['24h'], this.locale, '1.0-0') }} ({{ formatNumber(100 *
poolStats.blockShare['24h'], this.locale, '1.0-0') }}%)</td>
<td>{{ formatNumber(poolStats.blockCount['1w'], this.locale, '1.0-0') }} ({{ formatNumber(100 *
poolStats.blockShare['1w'], this.locale, '1.0-0') }}%)</td>
<td>{{ formatNumber(poolStats.blockCount['all'], this.locale, '1.0-0') }} ({{ formatNumber(100 *
poolStats.blockShare['all'], this.locale, '1.0-0') }}%)</td>
</tbody>
</table>
</td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<ng-template #nodata>
<td class="taller-row" style="vertical-align: middle">~</td>
</ng-template>
<ng-template #nodatamobile>
<div>~</div>
</ng-template>
<!-- Hashrate chart -->
<div class="chart" echarts [initOpts]="chartInitOptions" [options]="chartOptions"></div> <div class="chart" echarts [initOpts]="chartInitOptions" [options]="chartOptions"></div>
<div class="text-center loadingGraphs" *ngIf="isLoading"> <div class="text-center loadingGraphs" *ngIf="isLoading">
<div class="spinner-border text-light"></div> <div class="spinner-border text-light"></div>
</div> </div>
<table class="table table-borderless" [alwaysCallback]="true" infiniteScroll <!-- Blocks list -->
[infiniteScrollDistance]="1.5" [infiniteScrollUpDistance]="1.5" [infiniteScrollThrottle]="50" <table class="table table-borderless" [alwaysCallback]="true" infiniteScroll [infiniteScrollDistance]="1.5"
(scrolled)="loadMore()"> [infiniteScrollUpDistance]="1.5" [infiniteScrollThrottle]="50" (scrolled)="loadMore()">
<thead> <ng-container *ngIf="blocks$ | async as blocks; else skeleton">
<th class="height" i18n="latest-blocks.height">Height</th> <thead *ngIf="blocks.length > 0">
<th class="timestamp" i18n="latest-blocks.timestamp">Timestamp</th> <th class="height" i18n="latest-blocks.height">Height</th>
<th class="mined" i18n="latest-blocks.mined">Mined</th> <th class="timestamp" i18n="latest-blocks.timestamp">Timestamp</th>
<th class="coinbase text-left" i18n="latest-blocks.coinbasetag"> <th class="mined" i18n="latest-blocks.mined">Mined</th>
Coinbase Tag</th> <th class="coinbase text-left" i18n="latest-blocks.coinbasetag">
<th class="reward text-right" i18n="latest-blocks.reward"> Coinbase Tag</th>
Reward</th> <th class="reward text-right" i18n="latest-blocks.reward">
<th class="fees text-right" i18n="latest-blocks.fees">Fees</th> Reward</th>
<th class="txs text-right" i18n="latest-blocks.transactions">Txs</th> <th class="fees text-right" i18n="latest-blocks.fees">Fees</th>
<th class="size" i18n="latest-blocks.size">Size</th> <th class="txs text-right" i18n="latest-blocks.transactions">Txs</th>
</thead> <th class="size" i18n="latest-blocks.size">Size</th>
<tbody *ngIf="blocks$ | async as blocks; else skeleton" [style]="isLoading ? 'opacity: 0.75' : ''"> </thead>
<tr *ngFor="let block of blocks; let i= index; trackBy: trackByBlock"> <tbody [style]="isLoading ? 'opacity: 0.75' : ''">
<td class="height"> <tr *ngFor="let block of blocks; let i= index; trackBy: trackByBlock">
<a [routerLink]="['/block' | relativeUrl, block.height]">{{ block.height <td class="height">
}}</a> <a [routerLink]="['/block' | relativeUrl, block.height]">{{ block.height
</td> }}</a>
<td class="timestamp"> </td>
&lrm;{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }} <td class="timestamp">
</td> &lrm;{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }}
<td class="mined"> </td>
<app-time-since [time]="block.timestamp" [fastRender]="true"></app-time-since> <td class="mined">
</td> <app-time-since [time]="block.timestamp" [fastRender]="true"></app-time-since>
<td class="coinbase"> </td>
<span class="badge badge-secondary scriptmessage longer"> <td class="coinbase">
{{ block.extras.coinbaseRaw | hex2ascii }} <span class="badge badge-secondary scriptmessage longer">
</span> {{ block.extras.coinbaseRaw | hex2ascii }}
</td> </span>
<td class="reward text-right"> </td>
<app-amount [satoshis]="block.extras.reward" digitsInfo="1.2-2"></app-amount> <td class="reward text-right">
</td> <app-amount [satoshis]="block.extras.reward" digitsInfo="1.2-2"></app-amount>
<td class="fees text-right"> </td>
<app-amount [satoshis]="block.extras.totalFees" digitsInfo="1.2-2"></app-amount> <td class="fees text-right">
</td> <app-amount [satoshis]="block.extras.totalFees" digitsInfo="1.2-2"></app-amount>
<td class="txs text-right"> </td>
{{ block.tx_count | number }} <td class="txs text-right">
</td> {{ block.tx_count | number }}
<td class="size"> </td>
<div class="progress"> <td class="size">
<div class="progress-bar progress-mempool" role="progressbar" <div class="progress">
[ngStyle]="{'width': (block.weight / stateService.env.BLOCK_WEIGHT_UNITS)*100 + '%' }"></div> <div class="progress-bar progress-mempool" role="progressbar"
<div class="progress-text" [innerHTML]="block.size | bytes: 2"></div> [ngStyle]="{'width': (block.weight / stateService.env.BLOCK_WEIGHT_UNITS)*100 + '%' }"></div>
</div> <div class="progress-text" [innerHTML]="block.size | bytes: 2"></div>
</td> </div>
</tr> </td>
</tbody> </tr>
</tbody>
</ng-container>
<ng-template #skeleton> <ng-template #skeleton>
<thead>
<th class="height" i18n="latest-blocks.height">Height</th>
<th class="timestamp" i18n="latest-blocks.timestamp">Timestamp</th>
<th class="mined" i18n="latest-blocks.mined">Mined</th>
<th class="coinbase text-left" i18n="latest-blocks.coinbasetag">
Coinbase Tag</th>
<th class="reward text-right" i18n="latest-blocks.reward">
Reward</th>
<th class="fees text-right" i18n="latest-blocks.fees">Fees</th>
<th class="txs text-right" i18n="latest-blocks.transactions">Txs</th>
<th class="size" i18n="latest-blocks.size">Size</th>
</thead>
<tbody> <tbody>
<tr *ngFor="let item of [1,2,3,4,5]"> <tr *ngFor="let item of [1,2,3,4,5]">
<td class="height"> <td class="height">
@ -143,56 +305,184 @@
</div> </div>
<!-- Main table skeleton -->
<ng-template #loadingMain> <ng-template #loadingMain>
<div> <div>
<div class="mb-3" style="display:flex; position: relative"> <div class="mb-3" style="display:flex; position: relative">
<div class="skeleton-loader mr-3" style="width: 50px; height: 50px"></div> <div class="skeleton-loader mr-3" style="width: 50px; height: 50px"></div>
<h1 class="m-0 pt-1 pt-md-0"><div class="skeleton-loader" style="position: absolute; top: 32%; width: 150px; height: 20px"></div></h1> <h1 class="m-0 pt-1 pt-md-0">
<div class="skeleton-loader" style="position: absolute; top: 32%; width: 150px; height: 20px"></div>
</h1>
</div> </div>
<div class="box"> <div class="box">
<div class="row"> <div class="row">
<div class="col-lg-9">
<table class="table table-borderless table-striped"> <div class="col-lg-6">
<table class="table table-borderless table-striped taller" style="table-layout: fixed;">
<tbody> <tbody>
<tr>
<td class="label">Tags</td> <!-- Regexes desktop -->
<td class="text-truncate"> <tr *ngIf="!isMobile()" class="taller-row">
<td class="label" i18n="mining.tags">Tags</td>
<td style="vertical-align: middle">
<div class="skeleton-loader"></div> <div class="skeleton-loader"></div>
</td> </td>
</tr> </tr>
<tr> <!-- Regexes mobile -->
<td class="label">Addresses</td> <tr *ngIf="isMobile()">
<td class="text-truncate"> <td colspan=2>
<div class="scrollable"> <span class="label" i18n="mining.tags">Tags</span>
<div class="overflow-auto">
<div class="skeleton-loader"></div> <div class="skeleton-loader"></div>
</div> </div>
</td> </td>
<ng-template #nodata> </tr>
<td>~</td>
</ng-template> <!-- Addresses desktop -->
<tr *ngIf="!isMobile()" class="taller-row">
<td class="label" i18n="mining.addresses">Addresses</td>
<td style="vertical-align: middle;">
<div class="skeleton-loader"></div>
<div #collapse="ngbCollapse" [(ngbCollapse)]="gfg">
<div class="skeleton-loader"></div>
</div>
</td>
</tr>
<!-- Addresses mobile -->
<tr *ngIf="isMobile()">
<td colspan=2>
<span class="label" i18n="mining.addresses">Addresses</span>
<div>
<div class="skeleton-loader"></div>
<div #collapse="ngbCollapse" [(ngbCollapse)]="gfg" style="width: 100%">
<div class="skeleton-loader"></div>
</div>
</div>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="col-lg-3">
<table class="table table-borderless table-striped" > <div class="col-lg-6">
<table class="table table-borderless table-striped">
<tbody> <tbody>
<tr>
<td class="label">Mined Blocks</td> <!-- Hashrate desktop -->
<td class="text-truncate"> <tr *ngIf="!isMobile()" class="taller-row">
<div class="skeleton-loader"></div> <td class="label" i18n="mining.hashrate-24h">Hashrate (24h)</td>
<td class="data">
<table class="table table-xs table-data text-center">
<thead>
<tr>
<th scope="col" class="block-count-title" style="width: 37%" i18n="mining.estimated">Estimated
</th>
<th scope="col" class="block-count-title" style="width: 37%" i18n="mining.reported">Reported
</th>
<th scope="col" class="block-count-title" style="width: 26%" i18n="mining.luck">Luck</th>
</tr>
</thead>
<tbody>
<td>
<div class="skeleton-loader data"></div>
</td>
<td>
<div class="skeleton-loader data"></div>
</td>
<td>
<div class="skeleton-loader data"></div>
</td>
</tbody>
</table>
</td> </td>
</tr> </tr>
<tr> <!-- Hashrate mobile -->
<td class="label">Empty Blocks</td> <tr *ngIf="isMobile()">
<td class="text-truncate"> <td colspan="2">
<div class="skeleton-loader"></div> <span class="label" i18n="mining.hashrate-24h">Hashrate (24h)</span>
<table class="table table-xs table-data text-center">
<thead>
<tr>
<th scope="col" class="block-count-title" style="width: 33%" i18n="mining.estimated">Estimated
</th>
<th scope="col" class="block-count-title" style="width: 37%" i18n="mining.reported">Reported
</th>
<th scope="col" class="block-count-title" style="width: 30%" i18n="mining.luck">Luck</th>
</tr>
</thead>
<tbody>
<td>
<div class="skeleton-loader data"></div>
</td>
<td>
<div class="skeleton-loader data"></div>
</td>
<td>
<div class="skeleton-loader data"></div>
</td>
</tbody>
</table>
</td> </td>
</tr> </tr>
<!-- Mined blocks desktop -->
<tr *ngIf="!isMobile()" class="taller-row">
<td class="label" i18n="mining.mined-blocks">Mined Blocks</td>
<td class="data">
<table class="table table-xs table-data text-center">
<thead>
<tr>
<th scope="col" class="block-count-title" style="width: 37%" i18n="24h">24h</th>
<th scope="col" class="block-count-title" style="width: 37%" i18n="1w">1w</th>
<th scope="col" class="block-count-title" style="width: 26%" i18n="all">All</th>
</tr>
</thead>
<tbody>
<td>
<div class="skeleton-loader data"></div>
</td>
<td>
<div class="skeleton-loader data"></div>
</td>
<td>
<div class="skeleton-loader data"></div>
</td>
</tbody>
</table>
</td>
</tr>
<!-- Mined blocks mobile -->
<tr *ngIf="isMobile()">
<td colspan=2>
<span class="label" i18n="mining.mined-blocks">Mined Blocks</span>
<table class="table table-xs table-data text-center">
<thead>
<tr>
<th scope="col" class="block-count-title" style="width: 33%" i18n="24h">24h</th>
<th scope="col" class="block-count-title" style="width: 37%" i18n="1w">1w</th>
<th scope="col" class="block-count-title" style="width: 30%" i18n="all">All</th>
</tr>
</thead>
<tbody>
<td>
<div class="skeleton-loader data"></div>
</td>
<td>
<div class="skeleton-loader data"></div>
</td>
<td>
<div class="skeleton-loader data"></div>
</td>
</tbody>
</table>
</td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -31,6 +31,14 @@
} }
} }
.chart {
margin-bottom: 20px;
@media (max-width: 768px) {
margin-bottom: 10px;
}
height: 400px;
}
div.scrollable { div.scrollable {
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -42,16 +50,30 @@ div.scrollable {
.box { .box {
padding-bottom: 5px; padding-bottom: 5px;
@media (min-width: 767.98px) {
min-height: 187px;
}
} }
.label { .label {
width: 35%; width: 25%;
@media (min-width: 767.98px) {
vertical-align: middle;
}
@media (max-width: 767.98px) {
font-weight: bold;
}
}
.label.addresses {
vertical-align: top;
padding-top: 25px;
} }
.data { .data {
text-align: left; text-align: right;
padding-left: 25%; padding-left: 5%;
@media (max-width: 991px) { @media (max-width: 992px) {
text-align: left;
padding-left: 12px; padding-left: 12px;
} }
@media (max-width: 450px) { @media (max-width: 450px) {
@ -103,10 +125,6 @@ div.scrollable {
} }
} }
.fees {
width: 0%;
}
.size { .size {
width: 12%; width: 12%;
@media (max-width: 1000px) { @media (max-width: 1000px) {
@ -132,15 +150,13 @@ div.scrollable {
text-align: left; text-align: left;
} }
.right-mobile {
@media (max-width: 450px) {
text-align: right;
}
}
.skeleton-loader { .skeleton-loader {
max-width: 200px; max-width: 200px;
} }
.skeleton-loader.data {
max-width: 70px;
}
.loadingGraphs { .loadingGraphs {
position: absolute; position: absolute;
@ -151,3 +167,41 @@ div.scrollable {
top: 600px; top: 600px;
} }
} }
.small-button {
height: 20px;
font-size: 10px;
padding-top: 0;
padding-bottom: 0;
outline: none;
box-shadow: none;
}
.small-button.mobile {
transform: translateY(-20px);
@media (min-width: 767.98px) {
transform: translateY(-17px);
}
}
.block-count-title {
color: #4a68b9;
font-size: 14px;
text-align: left;
@media (max-width: 767.98px) {
text-align: center;
}
}
.table-data tr {
background-color: transparent;
}
.table-data td {
text-align: left;
@media (max-width: 767.98px) {
text-align: center;
}
}
.taller-row {
height: 75px;
}

View File

@ -2,12 +2,13 @@ import { ChangeDetectionStrategy, Component, Inject, Input, LOCALE_ID, OnInit }
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { EChartsOption, graphic } from 'echarts'; import { EChartsOption, graphic } from 'echarts';
import { BehaviorSubject, Observable, timer } from 'rxjs'; import { BehaviorSubject, Observable, timer } from 'rxjs';
import { map, switchMap, tap } from 'rxjs/operators'; import { distinctUntilChanged, map, share, switchMap, tap } from 'rxjs/operators';
import { BlockExtended, PoolStat } from 'src/app/interfaces/node-api.interface'; import { BlockExtended, PoolStat } from 'src/app/interfaces/node-api.interface';
import { ApiService } from 'src/app/services/api.service'; import { ApiService } from 'src/app/services/api.service';
import { StateService } from 'src/app/services/state.service'; import { StateService } from 'src/app/services/state.service';
import { selectPowerOfTen } from 'src/app/bitcoin.utils'; import { selectPowerOfTen } from 'src/app/bitcoin.utils';
import { formatNumber } from '@angular/common'; import { formatNumber } from '@angular/common';
import { SeoService } from 'src/app/services/seo.service';
@Component({ @Component({
selector: 'app-pool', selector: 'app-pool',
@ -19,6 +20,8 @@ export class PoolComponent implements OnInit {
@Input() right: number | string = 45; @Input() right: number | string = 45;
@Input() left: number | string = 75; @Input() left: number | string = 75;
gfg = true;
formatNumber = formatNumber; formatNumber = formatNumber;
poolStats$: Observable<PoolStat>; poolStats$: Observable<PoolStat>;
blocks$: Observable<BlockExtended[]>; blocks$: Observable<BlockExtended[]>;
@ -27,43 +30,45 @@ export class PoolComponent implements OnInit {
chartOptions: EChartsOption = {}; chartOptions: EChartsOption = {};
chartInitOptions = { chartInitOptions = {
renderer: 'svg', renderer: 'svg',
width: 'auto',
height: 'auto',
}; };
blocks: BlockExtended[] = []; blocks: BlockExtended[] = [];
poolId: number = undefined; slug: string = undefined;
loadMoreSubject: BehaviorSubject<number> = new BehaviorSubject(this.poolId); loadMoreSubject: BehaviorSubject<number> = new BehaviorSubject(this.blocks[this.blocks.length - 1]?.height);
constructor( constructor(
@Inject(LOCALE_ID) public locale: string, @Inject(LOCALE_ID) public locale: string,
private apiService: ApiService, private apiService: ApiService,
private route: ActivatedRoute, private route: ActivatedRoute,
public stateService: StateService, public stateService: StateService,
private seoService: SeoService,
) { ) {
} }
ngOnInit(): void { ngOnInit(): void {
this.poolStats$ = this.route.params.pipe(map((params) => params.poolId)) this.poolStats$ = this.route.params.pipe(map((params) => params.slug))
.pipe( .pipe(
switchMap((poolId: any) => { switchMap((slug: any) => {
this.isLoading = true; this.isLoading = true;
this.poolId = poolId; this.slug = slug;
this.loadMoreSubject.next(this.poolId); return this.apiService.getPoolHashrate$(this.slug)
return this.apiService.getPoolHashrate$(this.poolId)
.pipe( .pipe(
switchMap((data) => { switchMap((data) => {
this.isLoading = false; this.isLoading = false;
this.prepareChartOptions(data.hashrates.map(val => [val.timestamp * 1000, val.avgHashrate])); this.prepareChartOptions(data.hashrates.map(val => [val.timestamp * 1000, val.avgHashrate]));
return poolId; return [slug];
}), }),
); );
}), }),
switchMap(() => { switchMap((slug) => {
return this.apiService.getPoolStats$(this.poolId); return this.apiService.getPoolStats$(slug);
}),
tap(() => {
this.loadMoreSubject.next(this.blocks[this.blocks.length - 1]?.height);
}), }),
map((poolStats) => { map((poolStats) => {
this.seoService.setTitle(poolStats.pool.name);
let regexes = '"'; let regexes = '"';
for (const regex of poolStats.pool.regexes) { for (const regex of poolStats.pool.regexes) {
regexes += regex + '", "'; regexes += regex + '", "';
@ -71,6 +76,10 @@ export class PoolComponent implements OnInit {
poolStats.pool.regexes = regexes.slice(0, -3); poolStats.pool.regexes = regexes.slice(0, -3);
poolStats.pool.addresses = poolStats.pool.addresses; poolStats.pool.addresses = poolStats.pool.addresses;
if (poolStats.reportedHashrate) {
poolStats.luck = poolStats.estimatedHashrate / poolStats.reportedHashrate * 100;
}
return Object.assign({ return Object.assign({
logo: `./resources/mining-pools/` + poolStats.pool.name.toLowerCase().replace(' ', '').replace('.', '') + '.svg' logo: `./resources/mining-pools/` + poolStats.pool.name.toLowerCase().replace(' ', '').replace('.', '') + '.svg'
}, poolStats); }, poolStats);
@ -79,21 +88,37 @@ export class PoolComponent implements OnInit {
this.blocks$ = this.loadMoreSubject this.blocks$ = this.loadMoreSubject
.pipe( .pipe(
distinctUntilChanged(),
switchMap((flag) => { switchMap((flag) => {
if (this.poolId === undefined) { if (this.slug === undefined) {
return []; return [];
} }
return this.apiService.getPoolBlocks$(this.poolId, this.blocks[this.blocks.length - 1]?.height); return this.apiService.getPoolBlocks$(this.slug, this.blocks[this.blocks.length - 1]?.height);
}), }),
tap((newBlocks) => { tap((newBlocks) => {
this.blocks = this.blocks.concat(newBlocks); this.blocks = this.blocks.concat(newBlocks);
}), }),
map(() => this.blocks) map(() => this.blocks),
share(),
); );
} }
prepareChartOptions(data) { prepareChartOptions(data) {
let title: object;
if (data.length === 0) {
title = {
textStyle: {
color: 'grey',
fontSize: 15
},
text: `No data`,
left: 'center',
top: 'center'
};
}
this.chartOptions = { this.chartOptions = {
title: title,
animation: false, animation: false,
color: [ color: [
new graphic.LinearGradient(0, 0, 0, 0.65, [ new graphic.LinearGradient(0, 0, 0, 0.65, [
@ -124,7 +149,7 @@ export class PoolComponent implements OnInit {
align: 'left', align: 'left',
}, },
borderColor: '#000', borderColor: '#000',
formatter: function(ticks: any[]) { formatter: function (ticks: any[]) {
let hashratePowerOfTen: any = selectPowerOfTen(1); let hashratePowerOfTen: any = selectPowerOfTen(1);
let hashrate = ticks[0].data[1]; let hashrate = ticks[0].data[1];
@ -142,6 +167,9 @@ export class PoolComponent implements OnInit {
xAxis: { xAxis: {
type: 'time', type: 'time',
splitNumber: (this.isMobile()) ? 5 : 10, splitNumber: (this.isMobile()) ? 5 : 10,
axisLabel: {
hideOverlap: true,
}
}, },
yAxis: [ yAxis: [
{ {
@ -164,6 +192,7 @@ export class PoolComponent implements OnInit {
], ],
series: [ series: [
{ {
zlevel: 0,
name: 'Hashrate', name: 'Hashrate',
showSymbol: false, showSymbol: false,
symbol: 'none', symbol: 'none',
@ -174,6 +203,34 @@ export class PoolComponent implements OnInit {
}, },
}, },
], ],
dataZoom: data.length === 0 ? undefined : [{
type: 'inside',
realtime: true,
zoomLock: true,
maxSpan: 100,
minSpan: 10,
moveOnMouseMove: false,
}, {
fillerColor: '#aaaaff15',
borderColor: '#ffffff88',
showDetail: false,
show: true,
type: 'slider',
brushSelect: false,
realtime: true,
bottom: 0,
left: 20,
right: 15,
selectedDataBackground: {
lineStyle: {
color: '#fff',
opacity: 0.45,
},
areaStyle: {
opacity: 0,
},
},
}],
}; };
} }
@ -182,7 +239,7 @@ export class PoolComponent implements OnInit {
} }
loadMore() { loadMore() {
this.loadMoreSubject.next(this.poolId); this.loadMoreSubject.next(this.blocks[this.blocks.length - 1]?.height);
} }
trackByBlock(index: number, block: BlockExtended) { trackByBlock(index: number, block: BlockExtended) {

View File

@ -11,7 +11,7 @@
<div class="text-left"> <div class="text-left">
<p *ngIf="officialMempoolSpace">The <a href="https://mempool.space/">mempool.space</a> website, the <a href="https://liquid.network/">liquid.network</a> website, the <a href="https://bisq.markets/">bisq.markets</a> website, their associated API services, and related network and server infrastructure (collectively, the "Website") are operated by Mempool Space K.K. in Japan ("Mempool", "We", or "Us") and self-hosted from <a href="https://wq.apnic.net/static/search.html?query=AS142052">AS142052</a>.</p> <p *ngIf="officialMempoolSpace">The <a href="https://mempool.space/">mempool.space</a> website, the <a href="https://liquid.network/">liquid.network</a> website, the <a href="https://bisq.markets/">bisq.markets</a> website, their associated API services, and related network and server infrastructure (collectively, the "Website") are operated by Mempool Space K.K. in Japan ("Mempool", "We", or "Us") and self-hosted from <a href="https://bgp.tools/as/142052#connectivity">AS142052</a>.</p>
<p *ngIf="!officialMempoolSpace">This website and its API service (collectively, the "Website") are operated by a member of the Bitcoin community ("We" or "Us"). Mempool Space K.K. in Japan ("Mempool") has no affiliation with the operator of this Website, and does not sponsor or endorse the information provided herein.</p> <p *ngIf="!officialMempoolSpace">This website and its API service (collectively, the "Website") are operated by a member of the Bitcoin community ("We" or "Us"). Mempool Space K.K. in Japan ("Mempool") has no affiliation with the operator of this Website, and does not sponsor or endorse the information provided herein.</p>

View File

@ -2,7 +2,7 @@
<div class="fee-estimation-container"> <div class="fee-estimation-container">
<div class="item"> <div class="item">
<h5 class="card-title" i18n="mining.rewards">Miners Reward</h5> <h5 class="card-title" i18n="mining.rewards">Miners Reward</h5>
<div class="card-text" i18n-ngbTooltip="Transaction fee tooltip" <div class="card-text" i18n-ngbTooltip="mining.rewards-desc"
ngbTooltip="Amount being paid to miners in the past 144 blocks" placement="bottom"> ngbTooltip="Amount being paid to miners in the past 144 blocks" placement="bottom">
<div class="fee-text"> <div class="fee-text">
<app-amount [satoshis]="rewardStats.totalReward" digitsInfo="1.2-2" [noFiat]="true"></app-amount> <app-amount [satoshis]="rewardStats.totalReward" digitsInfo="1.2-2" [noFiat]="true"></app-amount>
@ -14,10 +14,10 @@
</div> </div>
<div class="item"> <div class="item">
<h5 class="card-title" i18n="mining.rewards-per-tx">Reward Per Tx</h5> <h5 class="card-title" i18n="mining.rewards-per-tx">Reward Per Tx</h5>
<div class="card-text" i18n-ngbTooltip="Transaction fee tooltip" <div class="card-text" i18n-ngbTooltip="mining.rewards-per-tx-desc"
ngbTooltip="Average miners' reward per transaction in the past 144 blocks" placement="bottom"> ngbTooltip="Average miners' reward per transaction in the past 144 blocks" placement="bottom">
<div class="fee-text"> <div class="fee-text">
{{ rewardStats.rewardPerTx | amountShortener }} {{ rewardStats.rewardPerTx | amountShortener: 2 }}
<span i18n="shared.sat-vbyte|sat/vB">sats/tx</span> <span i18n="shared.sat-vbyte|sat/vB">sats/tx</span>
</div> </div>
<span class="fiat"> <span class="fiat">
@ -27,9 +27,9 @@
</div> </div>
<div class="item"> <div class="item">
<h5 class="card-title" i18n="mining.average-fee">Average Fee</h5> <h5 class="card-title" i18n="mining.average-fee">Average Fee</h5>
<div class="card-text" i18n-ngbTooltip="Transaction fee tooltip" <div class="card-text" i18n-ngbTooltip="mining.average-fee"
ngbTooltip="Fee paid on average for each transaction in the past 144 blocks" placement="bottom"> ngbTooltip="Fee paid on average for each transaction in the past 144 blocks" placement="bottom">
<div class="fee-text">{{ rewardStats.feePerTx | amountShortener }} <div class="fee-text">{{ rewardStats.feePerTx | amountShortener: 2 }}
<span i18n="shared.sat-vbyte|sat/vB">sats/tx</span> <span i18n="shared.sat-vbyte|sat/vB">sats/tx</span>
</div> </div>
<span class="fiat"> <span class="fiat">
@ -65,55 +65,3 @@
</div> </div>
</div> </div>
</ng-template> </ng-template>
<!-- <div class="reward-container" *ngIf="$rewardStats | async as rewardStats; else loadingReward">
<div class="item">
<h5 class="card-title" i18n="mining.rewards">Miners Reward</h5>
<div class="card-text">
<app-amount [satoshis]="rewardStats.totalReward" digitsInfo="1.2-2" [noFiat]="true"></app-amount>
<div class="symbol" i18n="rewardStats.totalReward-desc">were rewarded to miners</div>
</div>
</div>
<div class="item">
<h5 class="card-title" i18n="mining.rewards-per-tx">Reward Per Tx</h5>
<div class="card-text">
{{ rewardStats.rewardPerTx | amountShortener }}
<span class="symbol" i18n="mining.sats-per-tx">sats/tx</span>
<div class="symbol" i18n="mining.rewards-per-tx-desc">miners reward / tx count</div>
</div>
</div>
<div class="item">
<h5 class="card-title" i18n="mining.average-fee">Average Fee</h5>
<div class="card-text">
{{ rewardStats.feePerTx | amountShortener}}
<span class="symbol">sats/tx</span>
<div class="symbol" i18n="mining.average-fee-desc">were paid per tx</div>
</div>
</div>
</div>
<ng-template #loadingReward>
<div class="reward-container">
<div class="item">
<h5 class="card-title" i18n="mining.rewards">Miners Reward</h5>
<div class="card-text skeleton">
<div class="skeleton-loader"></div>
<div class="skeleton-loader"></div>
</div>
</div>
<div class="item">
<h5 class="card-title" i18n="mining.rewards-per-tx">Reward Per Tx</h5>
<div class="card-text skeleton">
<div class="skeleton-loader"></div>
<div class="skeleton-loader"></div>
</div>
</div>
<div class="item">
<h5 class="card-title" i18n="mining.average-fee">Average Fee</h5>
<div class="card-text skeleton">
<div class="skeleton-loader"></div>
<div class="skeleton-loader"></div>
</div>
</div>
</div>
</ng-template> -->

View File

@ -1,6 +1,6 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs'; import { concat, Observable } from 'rxjs';
import { map, skip, switchMap } from 'rxjs/operators'; import { map, switchMap, tap } from 'rxjs/operators';
import { ApiService } from 'src/app/services/api.service'; import { ApiService } from 'src/app/services/api.service';
import { StateService } from 'src/app/services/state.service'; import { StateService } from 'src/app/services/state.service';
@ -12,25 +12,39 @@ import { StateService } from 'src/app/services/state.service';
}) })
export class RewardStatsComponent implements OnInit { export class RewardStatsComponent implements OnInit {
public $rewardStats: Observable<any>; public $rewardStats: Observable<any>;
private lastBlockHeight: number;
constructor(private apiService: ApiService, private stateService: StateService) { } constructor(private apiService: ApiService, private stateService: StateService) { }
ngOnInit(): void { ngOnInit(): void {
this.$rewardStats = this.stateService.blocks$ this.$rewardStats = concat(
// We fetch the latest reward stats when the page load and
// wait for the API response before listening to websocket blocks
this.apiService.getRewardStats$()
.pipe(
tap((stats) => {
this.lastBlockHeight = stats.endBlock;
})
),
// Or when we receive a newer block, newer than the latest reward stats api call
this.stateService.blocks$
.pipe(
switchMap((block) => {
if (block[0].height <= this.lastBlockHeight) {
return []; // Return an empty stream so the last pipe is not executed
}
this.lastBlockHeight = block[0].height;
return this.apiService.getRewardStats$();
})
)
)
.pipe( .pipe(
// (we always receives some blocks at start so only trigger for the last one) map((stats) => {
skip(this.stateService.env.MEMPOOL_BLOCKS_AMOUNT - 1), return {
switchMap(() => { totalReward: stats.totalReward,
return this.apiService.getRewardStats$() rewardPerTx: stats.totalReward / stats.totalTx,
.pipe( feePerTx: stats.totalFee / stats.totalTx,
map((stats) => { };
return {
totalReward: stats.totalReward,
rewardPerTx: stats.totalReward / stats.totalTx,
feePerTx: stats.totalFee / stats.totalTx,
};
})
);
}) })
); );
} }

View File

@ -11,7 +11,7 @@
<div class="text-left"> <div class="text-left">
<p *ngIf="officialMempoolSpace">The <a href="https://mempool.space/">mempool.space</a> website, the <a href="https://liquid.network/">liquid.network</a> website, the <a href="https://bisq.markets/">bisq.markets</a> website, their associated API services, and related network and server infrastructure (collectively, the "Website") are operated by Mempool Space K.K. in Japan ("Mempool", "We", or "Us") and self-hosted from <a href="https://wq.apnic.net/static/search.html?query=AS142052">AS142052</a>.</p> <p *ngIf="officialMempoolSpace">The <a href="https://mempool.space/">mempool.space</a> website, the <a href="https://liquid.network/">liquid.network</a> website, the <a href="https://bisq.markets/">bisq.markets</a> website, their associated API services, and related network and server infrastructure (collectively, the "Website") are operated by Mempool Space K.K. in Japan ("Mempool", "We", or "Us") and self-hosted from <a href="https://bgp.tools/as/142052#connectivity">AS142052</a>.</p>
<p *ngIf="!officialMempoolSpace">This website and its API service (collectively, the "Website") are operated by a member of the Bitcoin community ("We" or "Us"). Mempool Space K.K. in Japan ("Mempool") has no affiliation with the operator of this Website, and does not sponsor or endorse the information provided herein.</p> <p *ngIf="!officialMempoolSpace">This website and its API service (collectively, the "Website") are operated by a member of the Bitcoin community ("We" or "Us"). Mempool Space K.K. in Japan ("Mempool") has no affiliation with the operator of this Website, and does not sponsor or endorse the information provided herein.</p>

View File

@ -200,7 +200,7 @@
<app-transactions-list #txList [transactions]="[tx]" [errorUnblinded]="errorUnblinded" [outputIndex]="outputIndex" [transactionPage]="true"></app-transactions-list> <app-transactions-list #txList [transactions]="[tx]" [errorUnblinded]="errorUnblinded" [outputIndex]="outputIndex" [transactionPage]="true"></app-transactions-list>
<div class="title"> <div class="title text-left">
<h2 i18n="transaction.details">Details</h2> <h2 i18n="transaction.details">Details</h2>
</div> </div>
<div class="box"> <div class="box">

View File

@ -111,7 +111,10 @@
<td style="text-align: left;" [innerHTML]="vin.inner_redeemscript_asm | asmStyler"></td> <td style="text-align: left;" [innerHTML]="vin.inner_redeemscript_asm | asmStyler"></td>
</tr> </tr>
<tr *ngIf="vin.inner_witnessscript_asm"> <tr *ngIf="vin.inner_witnessscript_asm">
<td i18n="transactions-list.p2wsh-witness-script">P2WSH witness script</td> <td *ngIf="vin.prevout && vin.prevout.scriptpubkey_type == 'v1_p2tr'; else p2wsh" i18n="transactions-list.p2tr-tapscript">P2TR tapscript</td>
<ng-template #p2wsh>
<td i18n="transactions-list.p2wsh-witness-script">P2WSH witness script</td>
</ng-template>
<td style="text-align: left;" [innerHTML]="vin.inner_witnessscript_asm | asmStyler"></td> <td style="text-align: left;" [innerHTML]="vin.inner_witnessscript_asm | asmStyler"></td>
</tr> </tr>
<tr> <tr>
@ -155,6 +158,9 @@
<span class="d-block d-lg-none">{{ vout.scriptpubkey_address | shortenString : 16 }}</span> <span class="d-block d-lg-none">{{ vout.scriptpubkey_address | shortenString : 16 }}</span>
<span class="d-none d-lg-block">{{ vout.scriptpubkey_address | shortenString : 35 }}</span> <span class="d-none d-lg-block">{{ vout.scriptpubkey_address | shortenString : 35 }}</span>
</a> </a>
<div>
<app-address-labels [vout]="vout"></app-address-labels>
</div>
<ng-template #scriptpubkey_type> <ng-template #scriptpubkey_type>
<ng-template [ngIf]="vout.pegout" [ngIfElse]="defaultscriptpubkey_type"> <ng-template [ngIf]="vout.pegout" [ngIfElse]="defaultscriptpubkey_type">
<ng-container i18n="transactions-list.peg-out-to">Peg-out to <ng-container *ngTemplateOutlet="pegOutLink"></ng-container></ng-container> <ng-container i18n="transactions-list.peg-out-to">Peg-out to <ng-container *ngTemplateOutlet="pegOutLink"></ng-container></ng-container>

View File

@ -120,7 +120,7 @@
<td class="table-cell-height" ><a [routerLink]="['/block' | relativeUrl, block.id]" [state]="{ data: { block: block } }">{{ block.height }}</a></td> <td class="table-cell-height" ><a [routerLink]="['/block' | relativeUrl, block.id]" [state]="{ data: { block: block } }">{{ block.height }}</a></td>
<td *ngIf="!stateService.env.MINING_DASHBOARD" class="table-cell-mined" ><app-time-since [time]="block.timestamp" [fastRender]="true"></app-time-since></td> <td *ngIf="!stateService.env.MINING_DASHBOARD" class="table-cell-mined" ><app-time-since [time]="block.timestamp" [fastRender]="true"></app-time-since></td>
<td *ngIf="stateService.env.MINING_DASHBOARD" class="table-cell-mined pl-lg-4"> <td *ngIf="stateService.env.MINING_DASHBOARD" class="table-cell-mined pl-lg-4">
<a class="clear-link" [routerLink]="[('/mining/pool/' + block.extras.pool.id) | relativeUrl]"> <a class="clear-link" [routerLink]="[('/mining/pool/' + block.extras.pool.slug) | relativeUrl]">
<img width="20" height="20" src="{{ block.extras.pool['logo'] }}" <img width="20" height="20" src="{{ block.extras.pool['logo'] }}"
onError="this.src = './resources/mining-pools/default.svg'"> onError="this.src = './resources/mining-pools/default.svg'">
<span class="pool-name">{{ block.extras.pool.name }}</span> <span class="pool-name">{{ block.extras.pool.name }}</span>

View File

@ -71,6 +71,7 @@ export interface SinglePoolStats {
lastEstimatedHashrate: string; lastEstimatedHashrate: string;
emptyBlockRatio: string; emptyBlockRatio: string;
logo: string; logo: string;
slug: string;
} }
export interface PoolsStats { export interface PoolsStats {
blockCount: number; blockCount: number;
@ -92,8 +93,19 @@ export interface PoolInfo {
} }
export interface PoolStat { export interface PoolStat {
pool: PoolInfo; pool: PoolInfo;
blockCount: number; blockCount: {
emptyBlocks: number; all: number,
'24h': number,
'1w': number,
};
blockShare: {
all: number,
'24h': number,
'1w': number,
};
estimatedHashrate: number;
reportedHashrate: number;
luck?: number;
} }
export interface BlockExtension { export interface BlockExtension {
@ -107,6 +119,7 @@ export interface BlockExtension {
pool?: { pool?: {
id: number; id: number;
name: string; name: string;
slug: string;
} }
stage?: number; // Frontend only stage?: number; // Frontend only
@ -117,6 +130,8 @@ export interface BlockExtended extends Block {
} }
export interface RewardStats { export interface RewardStats {
startBlock: number;
endBlock: number;
totalReward: number; totalReward: number;
totalFee: number; totalFee: number;
totalTx: number; totalTx: number;

View File

@ -132,17 +132,17 @@ export class ApiService {
); );
} }
getPoolStats$(poolId: number): Observable<PoolStat> { getPoolStats$(slug: string): Observable<PoolStat> {
return this.httpClient.get<PoolStat>(this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/pool/${poolId}`); return this.httpClient.get<PoolStat>(this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/pool/${slug}`);
} }
getPoolHashrate$(poolId: number): Observable<any> { getPoolHashrate$(slug: string): Observable<any> {
return this.httpClient.get<any>(this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/pool/${poolId}/hashrate`); return this.httpClient.get<any>(this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/pool/${slug}/hashrate`);
} }
getPoolBlocks$(poolId: number, fromHeight: number): Observable<BlockExtended[]> { getPoolBlocks$(slug: string, fromHeight: number): Observable<BlockExtended[]> {
return this.httpClient.get<BlockExtended[]>( return this.httpClient.get<BlockExtended[]>(
this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/pool/${poolId}/blocks` + this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/pool/${slug}/blocks` +
(fromHeight !== undefined ? `/${fromHeight}` : '') (fromHeight !== undefined ? `/${fromHeight}` : '')
); );
} }
@ -154,13 +154,6 @@ export class ApiService {
); );
} }
getHistoricalDifficulty$(interval: string | undefined): Observable<any> {
return this.httpClient.get<any[]>(
this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/difficulty` +
(interval !== undefined ? `/${interval}` : '')
);
}
getHistoricalHashrate$(interval: string | undefined): Observable<any> { getHistoricalHashrate$(interval: string | undefined): Observable<any> {
return this.httpClient.get<any[]>( return this.httpClient.get<any[]>(
this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/hashrate` + this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/hashrate` +
@ -175,6 +168,20 @@ export class ApiService {
); );
} }
getHistoricalBlockFees$(interval: string | undefined) : Observable<any> {
return this.httpClient.get<any[]>(
this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/blocks/fees` +
(interval !== undefined ? `/${interval}` : '')
);
}
getHistoricalBlockRewards$(interval: string | undefined) : Observable<any> {
return this.httpClient.get<any[]>(
this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/blocks/rewards` +
(interval !== undefined ? `/${interval}` : '')
);
}
getRewardStats$(blockCount: number = 144): Observable<RewardStats> { getRewardStats$(blockCount: number = 144): Observable<RewardStats> {
return this.httpClient.get<RewardStats>(this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/reward-stats/${blockCount}`); return this.httpClient.get<RewardStats>(this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/reward-stats/${blockCount}`);
} }

View File

@ -4,6 +4,7 @@ import { map } from 'rxjs/operators';
import { PoolsStats, SinglePoolStats } from '../interfaces/node-api.interface'; import { PoolsStats, SinglePoolStats } from '../interfaces/node-api.interface';
import { ApiService } from '../services/api.service'; import { ApiService } from '../services/api.service';
import { StateService } from './state.service'; import { StateService } from './state.service';
import { StorageService } from './storage.service';
export interface MiningUnits { export interface MiningUnits {
hashrateDivider: number; hashrateDivider: number;
@ -28,8 +29,12 @@ export class MiningService {
constructor( constructor(
private stateService: StateService, private stateService: StateService,
private apiService: ApiService, private apiService: ApiService,
private storageService: StorageService,
) { } ) { }
/**
* Generate pool ranking stats
*/
public getMiningStats(interval: string): Observable<MiningStats> { public getMiningStats(interval: string): Observable<MiningStats> {
return this.apiService.listPools$(interval).pipe( return this.apiService.listPools$(interval).pipe(
map(pools => this.generateMiningStats(pools)) map(pools => this.generateMiningStats(pools))
@ -63,6 +68,20 @@ export class MiningService {
}; };
} }
/**
* Get the default selection timespan, cap with `min`
*/
public getDefaultTimespan(min: string): string {
const timespans = [
'24h', '3d', '1w', '1m', '3m', '6m', '1y', '2y', '3y', 'all'
];
const preference = this.storageService.getValue('miningWindowPreference') ?? '1w';
if (timespans.indexOf(preference) < timespans.indexOf(min)) {
return min;
}
return preference;
}
private generateMiningStats(stats: PoolsStats): MiningStats { private generateMiningStats(stats: PoolsStats): MiningStats {
const miningUnits = this.getMiningUnits(); const miningUnits = this.getMiningUnits();
const hashrateDivider = miningUnits.hashrateDivider; const hashrateDivider = miningUnits.hashrateDivider;

View File

@ -7,21 +7,21 @@ import { Router, ActivatedRoute } from '@angular/router';
export class StorageService { export class StorageService {
constructor(private router: Router, private route: ActivatedRoute) { constructor(private router: Router, private route: ActivatedRoute) {
this.setDefaultValueIfNeeded('graphWindowPreference', '2h'); this.setDefaultValueIfNeeded('graphWindowPreference', '2h');
this.setDefaultValueIfNeeded('poolsWindowPreference', '1w'); this.setDefaultValueIfNeeded('miningWindowPreference', '1w');
} }
setDefaultValueIfNeeded(key: string, defaultValue: string) { setDefaultValueIfNeeded(key: string, defaultValue: string) {
let graphWindowPreference: string = this.getValue(key); const graphWindowPreference: string = this.getValue(key);
if (graphWindowPreference === null) { // First visit to mempool.space if (graphWindowPreference === null) { // First visit to mempool.space
if (this.router.url.includes('graphs') && key === 'graphWindowPreference' || if (this.router.url.includes('graphs') && key === 'graphWindowPreference' ||
this.router.url.includes('pools') && key === 'poolsWindowPreference' this.router.url.includes('pools') && key === 'miningWindowPreference'
) { ) {
this.setValue(key, this.route.snapshot.fragment ? this.route.snapshot.fragment : defaultValue); this.setValue(key, this.route.snapshot.fragment ? this.route.snapshot.fragment : defaultValue);
} else { } else {
this.setValue(key, defaultValue); this.setValue(key, defaultValue);
} }
} else if (this.router.url.includes('graphs') && key === 'graphWindowPreference' || } else if (this.router.url.includes('graphs') && key === 'graphWindowPreference' ||
this.router.url.includes('pools') && key === 'poolsWindowPreference' this.router.url.includes('pools') && key === 'miningWindowPreference'
) { ) {
// Visit a different graphs#fragment from last visit // Visit a different graphs#fragment from last visit
if (this.route.snapshot.fragment !== null && graphWindowPreference !== this.route.snapshot.fragment) { if (this.route.snapshot.fragment !== null && graphWindowPreference !== this.route.snapshot.fragment) {

View File

@ -4,12 +4,14 @@ import { Pipe, PipeTransform } from '@angular/core';
name: 'amountShortener' name: 'amountShortener'
}) })
export class AmountShortenerPipe implements PipeTransform { export class AmountShortenerPipe implements PipeTransform {
transform(num: number, ...args: number[]): unknown { transform(num: number, ...args: any[]): unknown {
const digits = args[0] || 1;
const unit = args[1] || undefined;
if (num < 1000) { if (num < 1000) {
return num; return num.toFixed(digits);
} }
const digits = args[0] || 1;
const lookup = [ const lookup = [
{ value: 1, symbol: '' }, { value: 1, symbol: '' },
{ value: 1e3, symbol: 'k' }, { value: 1e3, symbol: 'k' },
@ -20,7 +22,12 @@ export class AmountShortenerPipe implements PipeTransform {
{ value: 1e18, symbol: 'E' } { value: 1e18, symbol: 'E' }
]; ];
const rx = /\.0+$|(\.[0-9]*[1-9])0+$/; const rx = /\.0+$|(\.[0-9]*[1-9])0+$/;
var item = lookup.slice().reverse().find((item) => num >= item.value); const item = lookup.slice().reverse().find((item) => num >= item.value);
return item ? (num / item.value).toFixed(digits).replace(rx, '$1') + item.symbol : '0';
if (unit !== undefined) {
return item ? (num / item.value).toFixed(digits).replace(rx, '$1') + ' ' + item.symbol + unit : '0';
} else {
return item ? (num / item.value).toFixed(digits).replace(rx, '$1') + item.symbol : '0';
}
} }
} }

View File

@ -264,6 +264,7 @@ export class AsmStylerPipe implements PipeTransform {
case 'LESSTHAN': case 'LESSTHAN':
case 'GREATERTHAN': case 'GREATERTHAN':
case 'LESSTHANOREQUAL': case 'LESSTHANOREQUAL':
case 'GREATERTHANOREQUAL':
case 'MIN': case 'MIN':
case 'MAX': case 'MAX':
case 'WITHIN': case 'WITHIN':
@ -279,12 +280,13 @@ export class AsmStylerPipe implements PipeTransform {
case 'CHECKSIG': case 'CHECKSIG':
case 'CHECKSIGVERIFY': case 'CHECKSIGVERIFY':
case 'CHECKMULTISIG': case 'CHECKMULTISIG':
case 'CHCEKMULTISIGVERIFY': case 'CHECKMULTISIGVERIFY':
case 'CHECKSIGADD':
style = 'crypto'; style = 'crypto';
break; break;
case 'CHECKLOCKTIMEVERIFY': case 'CLTV':
case 'CHECKSEQUENCEVERIFY': case 'CSV':
style = 'locktime'; style = 'locktime';
break; break;

View File

@ -50,7 +50,6 @@ $dropdown-link-active-bg: #11131f;
html, body { html, body {
height: 100%; height: 100%;
scroll-behavior: smooth;
} }
body { body {
@ -66,6 +65,11 @@ body {
.container-xl { .container-xl {
padding-bottom: 60px; padding-bottom: 60px;
} }
.full-height {
@media (max-width: 767.98px) {
min-height: 100vh;
}
}
:focus { :focus {
outline: none !important; outline: none !important;
@ -655,10 +659,6 @@ h1, h2, h3 {
margin-top: 0.75rem !important; margin-top: 0.75rem !important;
} }
.tooltip-inner {
max-width: inherit;
}
.alert-mempool { .alert-mempool {
color: #ffffff; color: #ffffff;
background-color: #653b9c; background-color: #653b9c;
@ -698,6 +698,16 @@ th {
margin-right: 0px; margin-right: 0px;
text-align: right; text-align: right;
} }
.nav-pills {
@extend .nav-pills;
display: inline-block;
}
.description {
direction: rtl;
}
.dropdown { .dropdown {
margin-right: 1rem; margin-right: 1rem;
margin-left: 0; margin-left: 0;
@ -712,12 +722,29 @@ th {
left: 0px; left: 0px;
right: auto; right: auto;
} }
.fa-arrow-alt-circle-right { .fa-circle-right {
@extend .fa-arrow-alt-circle-right; @extend .fa-circle-right;
-webkit-transform: scaleX(-1); -webkit-transform: scaleX(-1);
transform: scaleX(-1); transform: scaleX(-1);
} }
.btn.ml-2 {
margin-right: 0.5rem !important;
}
.pool-name {
@extend .pool-name;
padding-right: 10px;
}
.endpoint-container {
@extend .endpoint-container;
.section-header {
@extend .section-header;
text-align: left;
}
}
.table td { .table td {
text-align: right; text-align: right;
.fiat { .fiat {
@ -809,6 +836,14 @@ th {
} }
} }
.full-container {
@extend .full-container;
.formRadioGroup {
@extend .formRadioGroup;
direction: ltr;
}
}
.mempool-graph { .mempool-graph {
@extend .mempool-graph; @extend .mempool-graph;
direction: ltr; direction: ltr;

View File

@ -321,7 +321,7 @@ LIQUIDTESTNET_ASSET_REGISTRY_DB_NAME=asset_registry_testnet_db
# packages needed for mempool ecosystem # packages needed for mempool ecosystem
DEBIAN_PKG=() DEBIAN_PKG=()
DEBIAN_PKG+=(zsh vim curl screen openssl python3) DEBIAN_PKG+=(zsh vim curl screen openssl python3)
DEBIAN_PKG+=(build-essential git git-lfs clang cmake) DEBIAN_PKG+=(build-essential git git-lfs clang cmake jq)
DEBIAN_PKG+=(autotools-dev autoconf automake pkg-config bsdmainutils) DEBIAN_PKG+=(autotools-dev autoconf automake pkg-config bsdmainutils)
DEBIAN_PKG+=(libevent-dev libdb-dev libssl-dev libtool-dev autotools-dev) DEBIAN_PKG+=(libevent-dev libdb-dev libssl-dev libtool-dev autotools-dev)
DEBIAN_PKG+=(libboost-system-dev libboost-filesystem-dev libboost-chrono-dev libboost-test-dev libboost-thread-dev) DEBIAN_PKG+=(libboost-system-dev libboost-filesystem-dev libboost-chrono-dev libboost-test-dev libboost-thread-dev)
@ -330,7 +330,7 @@ DEBIAN_PKG+=(nodejs npm mariadb-server nginx-core python-certbot-nginx rsync ufw
# packages needed for mempool ecosystem # packages needed for mempool ecosystem
FREEBSD_PKG=() FREEBSD_PKG=()
FREEBSD_PKG+=(zsh sudo git screen curl wget calc neovim) FREEBSD_PKG+=(zsh sudo git screen curl wget calc neovim)
FREEBSD_PKG+=(openssh-portable py38-pip rust llvm90) FREEBSD_PKG+=(openssh-portable py38-pip rust llvm90 jq)
FREEBSD_PKG+=(boost-libs autoconf automake gmake gcc libevent libtool pkgconf) FREEBSD_PKG+=(boost-libs autoconf automake gmake gcc libevent libtool pkgconf)
FREEBSD_PKG+=(nginx rsync py38-certbot-nginx mariadb105-server keybase) FREEBSD_PKG+=(nginx rsync py38-certbot-nginx mariadb105-server keybase)

View File

@ -1,5 +1,7 @@
#!/usr/bin/env zsh #!/usr/bin/env zsh
hostname=$(hostname) hostname=$(hostname)
slugs=(`curl -sSL https://raw.githubusercontent.com/mempool/mining-pools/master/pools.json | jq -r '.slugs[]'`)
while true while true
do for url in / \ do for url in / \
'/api/v1/statistics/2h' \ '/api/v1/statistics/2h' \
@ -34,17 +36,37 @@ do for url in / \
'/api/v1/mining/hashrate/pools/3y' \ '/api/v1/mining/hashrate/pools/3y' \
'/api/v1/mining/hashrate/pools/all' \ '/api/v1/mining/hashrate/pools/all' \
'/api/v1/mining/reward-stats/144' \ '/api/v1/mining/reward-stats/144' \
'/api/v1/mining/blocks-extras' \
'/api/v1/mining/blocks/fees/24h' \
'/api/v1/mining/blocks/fees/3d' \
'/api/v1/mining/blocks/fees/1w' \
'/api/v1/mining/blocks/fees/1m' \
'/api/v1/mining/blocks/fees/3m' \
'/api/v1/mining/blocks/fees/6m' \
'/api/v1/mining/blocks/fees/1y' \
'/api/v1/mining/blocks/fees/2y' \
'/api/v1/mining/blocks/fees/3y' \
'/api/v1/mining/blocks/fees/all' \
'/api/v1/mining/blocks/rewards/24h' \
'/api/v1/mining/blocks/rewards/3d' \
'/api/v1/mining/blocks/rewards/1w' \
'/api/v1/mining/blocks/rewards/1m' \
'/api/v1/mining/blocks/rewards/3m' \
'/api/v1/mining/blocks/rewards/6m' \
'/api/v1/mining/blocks/rewards/1y' \
'/api/v1/mining/blocks/rewards/2y' \
'/api/v1/mining/blocks/rewards/3y' \
'/api/v1/mining/blocks/rewards/all' \
do do
curl -s "https://${hostname}${url}" >/dev/null curl -s "https://${hostname}${url}" >/dev/null
done done
counter=1 for slug in $slugs
while [ $counter -le 134 ]
do do
curl -s "https://${hostname}/api/v1/mining/pool/${counter}/hashrate" >/dev/null curl -s "https://${hostname}/api/v1/mining/pool/${slug}" >/dev/null
curl -s "https://${hostname}/api/v1/mining/pool/${counter}" >/dev/null curl -s "https://${hostname}/api/v1/mining/pool/${slug}/hashrate" >/dev/null
((counter++)) curl -s "https://${hostname}/api/v1/mining/pool/${slug}/blocks" >/dev/null
done done
sleep 10 sleep 10

View File

@ -1,7 +1,7 @@
location /api/v1/statistics { location /api/v1/statistics {
try_files /dev/null @mempool-api-v1-warmcache; try_files /dev/null @mempool-api-v1-warmcache;
} }
location /api/v1/mining/pools { location /api/v1/mining {
try_files /dev/null @mempool-api-v1-warmcache; try_files /dev/null @mempool-api-v1-warmcache;
} }
location /api/v1 { location /api/v1 {