Merge branch 'master' into nymkappa/bugfix/block-prediction-chart-no-data
This commit is contained in:
commit
d373fd1424
@ -578,7 +578,7 @@ class Blocks {
|
|||||||
|
|
||||||
// Index the response if needed
|
// Index the response if needed
|
||||||
if (Common.blocksSummariesIndexingEnabled() === true) {
|
if (Common.blocksSummariesIndexingEnabled() === true) {
|
||||||
await BlocksSummariesRepository.$saveSummary(block.height, summary);
|
await BlocksSummariesRepository.$saveSummary(block.height, summary, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return summary.transactions;
|
return summary.transactions;
|
||||||
|
@ -4,7 +4,7 @@ import logger from '../logger';
|
|||||||
import { Common } from './common';
|
import { Common } from './common';
|
||||||
|
|
||||||
class DatabaseMigration {
|
class DatabaseMigration {
|
||||||
private static currentVersion = 31;
|
private static currentVersion = 33;
|
||||||
private queryTimeout = 120000;
|
private queryTimeout = 120000;
|
||||||
private statisticsAddedIndexed = false;
|
private statisticsAddedIndexed = false;
|
||||||
private uniqueLogs: string[] = [];
|
private uniqueLogs: string[] = [];
|
||||||
@ -297,7 +297,14 @@ class DatabaseMigration {
|
|||||||
await this.$executeQuery('ALTER TABLE `prices` ADD `id` int NULL AUTO_INCREMENT UNIQUE');
|
await this.$executeQuery('ALTER TABLE `prices` ADD `id` int NULL AUTO_INCREMENT UNIQUE');
|
||||||
await this.$executeQuery('DROP TABLE IF EXISTS `blocks_prices`');
|
await this.$executeQuery('DROP TABLE IF EXISTS `blocks_prices`');
|
||||||
await this.$executeQuery(this.getCreateBlocksPricesTableQuery(), await this.$checkIfTableExists('blocks_prices'));
|
await this.$executeQuery(this.getCreateBlocksPricesTableQuery(), await this.$checkIfTableExists('blocks_prices'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (databaseSchemaVersion < 32 && isBitcoin == true) {
|
||||||
|
await this.$executeQuery('ALTER TABLE `blocks_summaries` ADD `template` JSON DEFAULT "[]"');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (databaseSchemaVersion < 33 && isBitcoin == true) {
|
||||||
|
await this.$executeQuery('ALTER TABLE `geo_names` CHANGE `type` `type` enum("city","country","division","continent","as_organization", "country_iso_code") NOT NULL');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -93,6 +93,132 @@ class NodesApi {
|
|||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async $getNodesISP() {
|
||||||
|
try {
|
||||||
|
let query = `SELECT nodes.as_number as ispId, geo_names.names as names, COUNT(DISTINCT nodes.public_key) as nodesCount, SUM(capacity) as capacity
|
||||||
|
FROM nodes
|
||||||
|
JOIN geo_names ON geo_names.id = nodes.as_number
|
||||||
|
JOIN channels ON channels.node1_public_key = nodes.public_key OR channels.node2_public_key = nodes.public_key
|
||||||
|
GROUP BY as_number
|
||||||
|
ORDER BY COUNT(DISTINCT nodes.public_key) DESC
|
||||||
|
`;
|
||||||
|
const [nodesCountPerAS]: any = await DB.query(query);
|
||||||
|
|
||||||
|
query = `SELECT COUNT(*) as total FROM nodes WHERE as_number IS NOT NULL`;
|
||||||
|
const [nodesWithAS]: any = await DB.query(query);
|
||||||
|
|
||||||
|
const nodesPerAs: any[] = [];
|
||||||
|
for (const as of nodesCountPerAS) {
|
||||||
|
nodesPerAs.push({
|
||||||
|
ispId: as.ispId,
|
||||||
|
name: JSON.parse(as.names),
|
||||||
|
count: as.nodesCount,
|
||||||
|
share: Math.floor(as.nodesCount / nodesWithAS[0].total * 10000) / 100,
|
||||||
|
capacity: as.capacity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodesPerAs;
|
||||||
|
} catch (e) {
|
||||||
|
logger.err(`Cannot get nodes grouped by AS. Reason: ${e instanceof Error ? e.message : e}`);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async $getNodesPerCountry(countryId: string) {
|
||||||
|
try {
|
||||||
|
const query = `
|
||||||
|
SELECT node_stats.public_key, node_stats.capacity, node_stats.channels, nodes.alias,
|
||||||
|
UNIX_TIMESTAMP(nodes.first_seen) as first_seen, UNIX_TIMESTAMP(nodes.updated_at) as updated_at,
|
||||||
|
geo_names_city.names as city
|
||||||
|
FROM node_stats
|
||||||
|
JOIN (
|
||||||
|
SELECT public_key, MAX(added) as last_added
|
||||||
|
FROM node_stats
|
||||||
|
GROUP BY public_key
|
||||||
|
) as b ON b.public_key = node_stats.public_key AND b.last_added = node_stats.added
|
||||||
|
JOIN nodes ON nodes.public_key = node_stats.public_key
|
||||||
|
JOIN geo_names geo_names_country ON geo_names_country.id = nodes.country_id AND geo_names_country.type = 'country'
|
||||||
|
LEFT JOIN geo_names geo_names_city ON geo_names_city.id = nodes.city_id AND geo_names_city.type = 'city'
|
||||||
|
WHERE geo_names_country.id = ?
|
||||||
|
ORDER BY capacity DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [rows]: any = await DB.query(query, [countryId]);
|
||||||
|
for (let i = 0; i < rows.length; ++i) {
|
||||||
|
rows[i].city = JSON.parse(rows[i].city);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
} catch (e) {
|
||||||
|
logger.err(`Cannot get nodes for country id ${countryId}. Reason: ${e instanceof Error ? e.message : e}`);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async $getNodesPerISP(ISPId: string) {
|
||||||
|
try {
|
||||||
|
const query = `
|
||||||
|
SELECT node_stats.public_key, node_stats.capacity, node_stats.channels, nodes.alias,
|
||||||
|
UNIX_TIMESTAMP(nodes.first_seen) as first_seen, UNIX_TIMESTAMP(nodes.updated_at) as updated_at,
|
||||||
|
geo_names_city.names as city, geo_names_country.names as country
|
||||||
|
FROM node_stats
|
||||||
|
JOIN (
|
||||||
|
SELECT public_key, MAX(added) as last_added
|
||||||
|
FROM node_stats
|
||||||
|
GROUP BY public_key
|
||||||
|
) as b ON b.public_key = node_stats.public_key AND b.last_added = node_stats.added
|
||||||
|
JOIN nodes ON nodes.public_key = node_stats.public_key
|
||||||
|
JOIN geo_names geo_names_country ON geo_names_country.id = nodes.country_id AND geo_names_country.type = 'country'
|
||||||
|
LEFT JOIN geo_names geo_names_city ON geo_names_city.id = nodes.city_id AND geo_names_city.type = 'city'
|
||||||
|
WHERE nodes.as_number = ?
|
||||||
|
ORDER BY capacity DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [rows]: any = await DB.query(query, [ISPId]);
|
||||||
|
for (let i = 0; i < rows.length; ++i) {
|
||||||
|
rows[i].country = JSON.parse(rows[i].country);
|
||||||
|
rows[i].city = JSON.parse(rows[i].city);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
} catch (e) {
|
||||||
|
logger.err(`Cannot get nodes for ISP id ${ISPId}. Reason: ${e instanceof Error ? e.message : e}`);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async $getNodesCountries() {
|
||||||
|
try {
|
||||||
|
let query = `SELECT geo_names.names as names, geo_names_iso.names as iso_code, COUNT(DISTINCT nodes.public_key) as nodesCount, SUM(capacity) as capacity
|
||||||
|
FROM nodes
|
||||||
|
JOIN geo_names ON geo_names.id = nodes.country_id AND geo_names.type = 'country'
|
||||||
|
JOIN geo_names geo_names_iso ON geo_names_iso.id = nodes.country_id AND geo_names_iso.type = 'country_iso_code'
|
||||||
|
JOIN channels ON channels.node1_public_key = nodes.public_key OR channels.node2_public_key = nodes.public_key
|
||||||
|
GROUP BY country_id
|
||||||
|
ORDER BY COUNT(DISTINCT nodes.public_key) DESC
|
||||||
|
`;
|
||||||
|
const [nodesCountPerCountry]: any = await DB.query(query);
|
||||||
|
|
||||||
|
query = `SELECT COUNT(*) as total FROM nodes WHERE country_id IS NOT NULL`;
|
||||||
|
const [nodesWithAS]: any = await DB.query(query);
|
||||||
|
|
||||||
|
const nodesPerCountry: any[] = [];
|
||||||
|
for (const country of nodesCountPerCountry) {
|
||||||
|
nodesPerCountry.push({
|
||||||
|
name: JSON.parse(country.names),
|
||||||
|
iso: country.iso_code,
|
||||||
|
count: country.nodesCount,
|
||||||
|
share: Math.floor(country.nodesCount / nodesWithAS[0].total * 10000) / 100,
|
||||||
|
capacity: country.capacity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodesPerCountry;
|
||||||
|
} catch (e) {
|
||||||
|
logger.err(`Cannot get nodes grouped by AS. Reason: ${e instanceof Error ? e.message : e}`);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new NodesApi();
|
export default new NodesApi();
|
||||||
|
@ -1,13 +1,19 @@
|
|||||||
import config from '../../config';
|
import config from '../../config';
|
||||||
import { Application, Request, Response } from 'express';
|
import { Application, Request, Response } from 'express';
|
||||||
import nodesApi from './nodes.api';
|
import nodesApi from './nodes.api';
|
||||||
|
import DB from '../../database';
|
||||||
|
|
||||||
class NodesRoutes {
|
class NodesRoutes {
|
||||||
constructor() { }
|
constructor() { }
|
||||||
|
|
||||||
public initRoutes(app: Application) {
|
public initRoutes(app: Application) {
|
||||||
app
|
app
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/country/:country', this.$getNodesPerCountry)
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/isp/:isp', this.$getNodesPerISP)
|
||||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/search/:search', this.$searchNode)
|
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/search/:search', this.$searchNode)
|
||||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/top', this.$getTopNodes)
|
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/top', this.$getTopNodes)
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/isp', this.$getNodesISP)
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/countries', this.$getNodesCountries)
|
||||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/:public_key/statistics', this.$getHistoricalNodeStats)
|
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/:public_key/statistics', this.$getHistoricalNodeStats)
|
||||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/:public_key', this.$getNode)
|
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/:public_key', this.$getNode)
|
||||||
;
|
;
|
||||||
@ -56,6 +62,85 @@ class NodesRoutes {
|
|||||||
res.status(500).send(e instanceof Error ? e.message : e);
|
res.status(500).send(e instanceof Error ? e.message : e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async $getNodesISP(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const nodesPerAs = await nodesApi.$getNodesISP();
|
||||||
|
res.header('Pragma', 'public');
|
||||||
|
res.header('Cache-control', 'public');
|
||||||
|
res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString());
|
||||||
|
res.json(nodesPerAs);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).send(e instanceof Error ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async $getNodesPerCountry(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const [country]: any[] = await DB.query(
|
||||||
|
`SELECT geo_names.id, geo_names_country.names as country_names
|
||||||
|
FROM geo_names
|
||||||
|
JOIN geo_names geo_names_country on geo_names.id = geo_names_country.id AND geo_names_country.type = 'country'
|
||||||
|
WHERE geo_names.type = 'country_iso_code' AND geo_names.names = ?`,
|
||||||
|
[req.params.country]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (country.length === 0) {
|
||||||
|
res.status(404).send(`This country does not exist or does not host any lightning nodes on clearnet`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes = await nodesApi.$getNodesPerCountry(country[0].id);
|
||||||
|
res.header('Pragma', 'public');
|
||||||
|
res.header('Cache-control', 'public');
|
||||||
|
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||||
|
res.json({
|
||||||
|
country: JSON.parse(country[0].country_names),
|
||||||
|
nodes: nodes,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).send(e instanceof Error ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async $getNodesPerISP(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const [isp]: any[] = await DB.query(
|
||||||
|
`SELECT geo_names.names as isp_name
|
||||||
|
FROM geo_names
|
||||||
|
WHERE geo_names.type = 'as_organization' AND geo_names.id = ?`,
|
||||||
|
[req.params.isp]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isp.length === 0) {
|
||||||
|
res.status(404).send(`This ISP does not exist or does not host any lightning nodes on clearnet`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes = await nodesApi.$getNodesPerISP(req.params.isp);
|
||||||
|
res.header('Pragma', 'public');
|
||||||
|
res.header('Cache-control', 'public');
|
||||||
|
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||||
|
res.json({
|
||||||
|
isp: JSON.parse(isp[0].isp_name),
|
||||||
|
nodes: nodes,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).send(e instanceof Error ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async $getNodesCountries(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const nodesPerAs = await nodesApi.$getNodesCountries();
|
||||||
|
res.header('Pragma', 'public');
|
||||||
|
res.header('Cache-control', 'public');
|
||||||
|
res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString());
|
||||||
|
res.json(nodesPerAs);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).send(e instanceof Error ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new NodesRoutes();
|
export default new NodesRoutes();
|
||||||
|
@ -26,6 +26,7 @@ class MiningRoutes {
|
|||||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/sizes-weights/:interval', this.$getHistoricalBlockSizeAndWeight)
|
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/sizes-weights/:interval', this.$getHistoricalBlockSizeAndWeight)
|
||||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/difficulty-adjustments/:interval', this.$getDifficultyAdjustments)
|
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/difficulty-adjustments/:interval', this.$getDifficultyAdjustments)
|
||||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/predictions/:interval', this.$getHistoricalBlockPrediction)
|
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/predictions/:interval', this.$getHistoricalBlockPrediction)
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/audit/:hash', this.$getBlockAudit)
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -233,6 +234,18 @@ class MiningRoutes {
|
|||||||
res.status(500).send(e instanceof Error ? e.message : e);
|
res.status(500).send(e instanceof Error ? e.message : e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async $getBlockAudit(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const audit = await BlocksAuditsRepository.$getBlockAudit(req.params.hash);
|
||||||
|
res.header('Pragma', 'public');
|
||||||
|
res.header('Cache-control', 'public');
|
||||||
|
res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24).toUTCString());
|
||||||
|
res.json(audit);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).send(e instanceof Error ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new MiningRoutes();
|
export default new MiningRoutes();
|
||||||
|
@ -17,6 +17,7 @@ import rbfCache from './rbf-cache';
|
|||||||
import difficultyAdjustment from './difficulty-adjustment';
|
import difficultyAdjustment from './difficulty-adjustment';
|
||||||
import feeApi from './fee-api';
|
import feeApi from './fee-api';
|
||||||
import BlocksAuditsRepository from '../repositories/BlocksAuditsRepository';
|
import BlocksAuditsRepository from '../repositories/BlocksAuditsRepository';
|
||||||
|
import BlocksSummariesRepository from '../repositories/BlocksSummariesRepository';
|
||||||
|
|
||||||
class WebsocketHandler {
|
class WebsocketHandler {
|
||||||
private wss: WebSocket.Server | undefined;
|
private wss: WebSocket.Server | undefined;
|
||||||
@ -442,6 +443,19 @@ class WebsocketHandler {
|
|||||||
mBlockDeltas = mempoolBlocks.getMempoolBlockDeltas();
|
mBlockDeltas = mempoolBlocks.getMempoolBlockDeltas();
|
||||||
|
|
||||||
if (Common.indexingEnabled()) {
|
if (Common.indexingEnabled()) {
|
||||||
|
const stripped = _mempoolBlocks[0].transactions.map((tx) => {
|
||||||
|
return {
|
||||||
|
txid: tx.txid,
|
||||||
|
vsize: tx.vsize,
|
||||||
|
fee: tx.fee ? Math.round(tx.fee) : 0,
|
||||||
|
value: tx.value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
BlocksSummariesRepository.$saveSummary(block.height, null, {
|
||||||
|
id: block.id,
|
||||||
|
transactions: stripped
|
||||||
|
});
|
||||||
|
|
||||||
BlocksAuditsRepository.$saveAudit({
|
BlocksAuditsRepository.$saveAudit({
|
||||||
time: block.timestamp,
|
time: block.timestamp,
|
||||||
height: block.height,
|
height: block.height,
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import transactionUtils from '../api/transaction-utils';
|
||||||
import DB from '../database';
|
import DB from '../database';
|
||||||
import logger from '../logger';
|
import logger from '../logger';
|
||||||
import { BlockAudit } from '../mempool.interfaces';
|
import { BlockAudit } from '../mempool.interfaces';
|
||||||
@ -45,6 +46,30 @@ class BlocksAuditRepositories {
|
|||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async $getBlockAudit(hash: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const [rows]: any[] = await DB.query(
|
||||||
|
`SELECT blocks.height, blocks.hash as id, UNIX_TIMESTAMP(blocks.blockTimestamp) as timestamp, blocks.size,
|
||||||
|
blocks.weight, blocks.tx_count,
|
||||||
|
transactions, template, missing_txs as missingTxs, added_txs as addedTxs, match_rate as matchRate
|
||||||
|
FROM blocks_audits
|
||||||
|
JOIN blocks ON blocks.hash = blocks_audits.hash
|
||||||
|
JOIN blocks_summaries ON blocks_summaries.id = blocks_audits.hash
|
||||||
|
WHERE blocks_audits.hash = "${hash}"
|
||||||
|
`);
|
||||||
|
|
||||||
|
rows[0].missingTxs = JSON.parse(rows[0].missingTxs);
|
||||||
|
rows[0].addedTxs = JSON.parse(rows[0].addedTxs);
|
||||||
|
rows[0].transactions = JSON.parse(rows[0].transactions);
|
||||||
|
rows[0].template = JSON.parse(rows[0].template);
|
||||||
|
|
||||||
|
return rows[0];
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.err(`Cannot fetch block audit from db. Reason: ` + (e instanceof Error ? e.message : e));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new BlocksAuditRepositories();
|
export default new BlocksAuditRepositories();
|
||||||
|
@ -17,14 +17,24 @@ class BlocksSummariesRepository {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async $saveSummary(height: number, summary: BlockSummary) {
|
public async $saveSummary(height: number, mined: BlockSummary | null = null, template: BlockSummary | null = null) {
|
||||||
|
const blockId = mined?.id ?? template?.id;
|
||||||
try {
|
try {
|
||||||
await DB.query(`INSERT INTO blocks_summaries VALUE (?, ?, ?)`, [height, summary.id, JSON.stringify(summary.transactions)]);
|
const [dbSummary]: any[] = await DB.query(`SELECT * FROM blocks_summaries WHERE id = "${blockId}"`);
|
||||||
|
if (dbSummary.length === 0) { // First insertion
|
||||||
|
await DB.query(`INSERT INTO blocks_summaries VALUE (?, ?, ?, ?)`, [
|
||||||
|
height, blockId, JSON.stringify(mined?.transactions ?? []), JSON.stringify(template?.transactions ?? [])
|
||||||
|
]);
|
||||||
|
} else if (mined !== null) { // Update mined block summary
|
||||||
|
await DB.query(`UPDATE blocks_summaries SET transactions = ? WHERE id = "${mined.id}"`, [JSON.stringify(mined?.transactions)]);
|
||||||
|
} else if (template !== null) { // Update template block summary
|
||||||
|
await DB.query(`UPDATE blocks_summaries SET template = ? WHERE id = "${template.id}"`, [JSON.stringify(template?.transactions)]);
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.errno === 1062) { // ER_DUP_ENTRY - This scenario is possible upon node backend restart
|
if (e.errno === 1062) { // ER_DUP_ENTRY - This scenario is possible upon node backend restart
|
||||||
logger.debug(`Cannot save block summary for ${summary.id} because it has already been indexed, ignoring`);
|
logger.debug(`Cannot save block summary for ${blockId} because it has already been indexed, ignoring`);
|
||||||
} else {
|
} else {
|
||||||
logger.debug(`Cannot save block summary for ${summary.id}. Reason: ${e instanceof Error ? e.message : e}`);
|
logger.debug(`Cannot save block summary for ${blockId}. Reason: ${e instanceof Error ? e.message : e}`);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -39,6 +39,13 @@ export async function $lookupNodeLocation(): Promise<void> {
|
|||||||
[city.country?.geoname_id, JSON.stringify(city.country?.names)]);
|
[city.country?.geoname_id, JSON.stringify(city.country?.names)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store Country ISO code
|
||||||
|
if (city.country?.iso_code) {
|
||||||
|
await DB.query(
|
||||||
|
`INSERT IGNORE INTO geo_names (id, type, names) VALUES (?, 'country_iso_code', ?)`,
|
||||||
|
[city.country?.geoname_id, city.country?.iso_code]);
|
||||||
|
}
|
||||||
|
|
||||||
// Store Division
|
// Store Division
|
||||||
if (city.subdivisions && city.subdivisions[0]) {
|
if (city.subdivisions && city.subdivisions[0]) {
|
||||||
await DB.query(
|
await DB.query(
|
||||||
|
@ -62,7 +62,7 @@ class KrakenApi implements PriceFeed {
|
|||||||
// CHF weekly price history goes back to timestamp 1575504000 (December 5, 2019)
|
// CHF weekly price history goes back to timestamp 1575504000 (December 5, 2019)
|
||||||
// AUD weekly price history goes back to timestamp 1591833600 (June 11, 2020)
|
// AUD weekly price history goes back to timestamp 1591833600 (June 11, 2020)
|
||||||
|
|
||||||
const priceHistory: any = {}; // map: timestamp -> Prices
|
let priceHistory: any = {}; // map: timestamp -> Prices
|
||||||
|
|
||||||
for (const currency of this.currencies) {
|
for (const currency of this.currencies) {
|
||||||
const response = await query(this.urlHist.replace('{GRANULARITY}', '10080') + currency);
|
const response = await query(this.urlHist.replace('{GRANULARITY}', '10080') + currency);
|
||||||
@ -83,6 +83,10 @@ class KrakenApi implements PriceFeed {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const time in priceHistory) {
|
for (const time in priceHistory) {
|
||||||
|
if (priceHistory[time].USD === -1) {
|
||||||
|
delete priceHistory[time];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
await PricesRepository.$savePrices(parseInt(time, 10), priceHistory[time]);
|
await PricesRepository.$savePrices(parseInt(time, 10), priceHistory[time]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,6 +3,7 @@ import { Routes, RouterModule, PreloadAllModules } from '@angular/router';
|
|||||||
import { StartComponent } from './components/start/start.component';
|
import { StartComponent } from './components/start/start.component';
|
||||||
import { TransactionComponent } from './components/transaction/transaction.component';
|
import { TransactionComponent } from './components/transaction/transaction.component';
|
||||||
import { BlockComponent } from './components/block/block.component';
|
import { BlockComponent } from './components/block/block.component';
|
||||||
|
import { BlockAuditComponent } from './components/block-audit/block-audit.component';
|
||||||
import { AddressComponent } from './components/address/address.component';
|
import { AddressComponent } from './components/address/address.component';
|
||||||
import { MasterPageComponent } from './components/master-page/master-page.component';
|
import { MasterPageComponent } from './components/master-page/master-page.component';
|
||||||
import { AboutComponent } from './components/about/about.component';
|
import { AboutComponent } from './components/about/about.component';
|
||||||
@ -88,6 +89,15 @@ let routes: Routes = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'block-audit',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: ':id',
|
||||||
|
component: BlockAuditComponent,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'docs',
|
path: 'docs',
|
||||||
loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule)
|
loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule)
|
||||||
@ -182,6 +192,15 @@ let routes: Routes = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'block-audit',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: ':id',
|
||||||
|
component: BlockAuditComponent,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'docs',
|
path: 'docs',
|
||||||
loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule)
|
loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule)
|
||||||
@ -273,6 +292,15 @@ let routes: Routes = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'block-audit',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: ':id',
|
||||||
|
component: BlockAuditComponent
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'docs',
|
path: 'docs',
|
||||||
loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule)
|
loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule)
|
||||||
|
@ -0,0 +1,111 @@
|
|||||||
|
<div class="container-xl" (window:resize)="onResize($event)">
|
||||||
|
|
||||||
|
<div *ngIf="(auditObservable$ | async) as blockAudit; else skeleton">
|
||||||
|
<div class="title-block" id="block">
|
||||||
|
<h1>
|
||||||
|
<span class="next-previous-blocks">
|
||||||
|
<span i18n="shared.block-title">Block </span>
|
||||||
|
|
||||||
|
<a [routerLink]="['/block/' | relativeUrl, blockAudit.id]">{{ blockAudit.height }}</a>
|
||||||
|
|
||||||
|
<span i18n="shared.template-vs-mined">Template vs Mined</span>
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div class="grow"></div>
|
||||||
|
|
||||||
|
<button [routerLink]="['/' | relativeUrl]" class="btn btn-sm">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- OVERVIEW -->
|
||||||
|
<div class="box mb-3">
|
||||||
|
<div class="row">
|
||||||
|
<!-- LEFT COLUMN -->
|
||||||
|
<div class="col-sm">
|
||||||
|
<table class="table table-borderless table-striped">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="td-width" i18n="block.hash">Hash</td>
|
||||||
|
<td><a [routerLink]="['/block/' | relativeUrl, blockAudit.id]" title="{{ blockAudit.id }}">{{ blockAudit.id | shortenString : 13 }}</a>
|
||||||
|
<app-clipboard class="d-none d-sm-inline-block" [text]="blockAudit.id"></app-clipboard>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td i18n="blockAudit.timestamp">Timestamp</td>
|
||||||
|
<td>
|
||||||
|
‎{{ blockAudit.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }}
|
||||||
|
<div class="lg-inline">
|
||||||
|
<i class="symbol">(<app-time-since [time]="blockAudit.timestamp" [fastRender]="true">
|
||||||
|
</app-time-since>)</i>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td i18n="blockAudit.size">Size</td>
|
||||||
|
<td [innerHTML]="'‎' + (blockAudit.size | bytes: 2)"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td i18n="block.weight">Weight</td>
|
||||||
|
<td [innerHTML]="'‎' + (blockAudit.weight | wuBytes: 2)"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT COLUMN -->
|
||||||
|
<div class="col-sm" *ngIf="blockAudit">
|
||||||
|
<table class="table table-borderless table-striped">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="td-width" i18n="shared.transaction-count">Transactions</td>
|
||||||
|
<td>{{ blockAudit.tx_count }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td i18n="block.match-rate">Match rate</td>
|
||||||
|
<td>{{ blockAudit.matchRate }}%</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td i18n="block.missing-txs">Missing txs</td>
|
||||||
|
<td>{{ blockAudit.missingTxs.length }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td i18n="block.added-txs">Added txs</td>
|
||||||
|
<td>{{ blockAudit.addedTxs.length }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div> <!-- row -->
|
||||||
|
</div> <!-- box -->
|
||||||
|
|
||||||
|
<!-- ADDED vs MISSING button -->
|
||||||
|
<div class="d-flex justify-content-center menu mt-3" *ngIf="isMobile">
|
||||||
|
<a routerLinkActive="active" class="btn btn-primary w-50 mr-1 ml-1 menu-button" i18n="block.missing-txs"
|
||||||
|
fragment="missing" (click)="changeMode('missing')">Missing</a>
|
||||||
|
<a routerLinkActive="active" class="btn btn-primary w-50 mr-1 ml-1 menu-button" i18n="block.added-txs"
|
||||||
|
fragment="added" (click)="changeMode('added')">Added</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- VISUALIZATIONS -->
|
||||||
|
<div class="box">
|
||||||
|
<div class="row">
|
||||||
|
<!-- MISSING TX RENDERING -->
|
||||||
|
<div class="col-sm" *ngIf="webGlEnabled">
|
||||||
|
<app-block-overview-graph #blockGraphTemplate [isLoading]="isLoading" [resolution]="75"
|
||||||
|
[blockLimit]="stateService.blockVSize" [orientation]="'top'" [flip]="false"
|
||||||
|
(txClickEvent)="onTxClick($event)"></app-block-overview-graph>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ADDED TX RENDERING -->
|
||||||
|
<div class="col-sm" *ngIf="webGlEnabled && !isMobile">
|
||||||
|
<app-block-overview-graph #blockGraphMined [isLoading]="isLoading" [resolution]="75"
|
||||||
|
[blockLimit]="stateService.blockVSize" [orientation]="'top'" [flip]="false"
|
||||||
|
(txClickEvent)="onTxClick($event)"></app-block-overview-graph>
|
||||||
|
</div>
|
||||||
|
</div> <!-- row -->
|
||||||
|
</div> <!-- box -->
|
||||||
|
|
||||||
|
<ng-template #skeleton></ng-template>
|
||||||
|
|
||||||
|
</div>
|
@ -0,0 +1,40 @@
|
|||||||
|
.title-block {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
tr td {
|
||||||
|
&:last-child {
|
||||||
|
text-align: right;
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-tx-title {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
@media (min-width: 550px) {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
line-height: 1;
|
||||||
|
margin: 0;
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
@media (min-width: 550px) {
|
||||||
|
padding-bottom: 0px;
|
||||||
|
align-self: end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-button {
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
max-width: 150px;
|
||||||
|
}
|
||||||
|
}
|
120
frontend/src/app/components/block-audit/block-audit.component.ts
Normal file
120
frontend/src/app/components/block-audit/block-audit.component.ts
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
|
||||||
|
import { ActivatedRoute, ParamMap, Router } from '@angular/router';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { map, share, switchMap, tap } from 'rxjs/operators';
|
||||||
|
import { BlockAudit, TransactionStripped } from 'src/app/interfaces/node-api.interface';
|
||||||
|
import { ApiService } from 'src/app/services/api.service';
|
||||||
|
import { StateService } from 'src/app/services/state.service';
|
||||||
|
import { detectWebGL } from 'src/app/shared/graphs.utils';
|
||||||
|
import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe';
|
||||||
|
import { BlockOverviewGraphComponent } from '../block-overview-graph/block-overview-graph.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-block-audit',
|
||||||
|
templateUrl: './block-audit.component.html',
|
||||||
|
styleUrls: ['./block-audit.component.scss'],
|
||||||
|
styles: [`
|
||||||
|
.loadingGraphs {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: calc(50% - 15px);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
`],
|
||||||
|
})
|
||||||
|
export class BlockAuditComponent implements OnInit, OnDestroy {
|
||||||
|
blockAudit: BlockAudit = undefined;
|
||||||
|
transactions: string[];
|
||||||
|
auditObservable$: Observable<BlockAudit>;
|
||||||
|
|
||||||
|
paginationMaxSize: number;
|
||||||
|
page = 1;
|
||||||
|
itemsPerPage: number;
|
||||||
|
|
||||||
|
mode: 'missing' | 'added' = 'missing';
|
||||||
|
isLoading = true;
|
||||||
|
webGlEnabled = true;
|
||||||
|
isMobile = window.innerWidth <= 767.98;
|
||||||
|
|
||||||
|
@ViewChild('blockGraphTemplate') blockGraphTemplate: BlockOverviewGraphComponent;
|
||||||
|
@ViewChild('blockGraphMined') blockGraphMined: BlockOverviewGraphComponent;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
public stateService: StateService,
|
||||||
|
private router: Router,
|
||||||
|
private apiService: ApiService
|
||||||
|
) {
|
||||||
|
this.webGlEnabled = detectWebGL();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.paginationMaxSize = window.matchMedia('(max-width: 670px)').matches ? 3 : 5;
|
||||||
|
this.itemsPerPage = this.stateService.env.ITEMS_PER_PAGE;
|
||||||
|
|
||||||
|
this.auditObservable$ = this.route.paramMap.pipe(
|
||||||
|
switchMap((params: ParamMap) => {
|
||||||
|
const blockHash: string = params.get('id') || '';
|
||||||
|
return this.apiService.getBlockAudit$(blockHash)
|
||||||
|
.pipe(
|
||||||
|
map((response) => {
|
||||||
|
const blockAudit = response.body;
|
||||||
|
for (let i = 0; i < blockAudit.template.length; ++i) {
|
||||||
|
if (blockAudit.missingTxs.includes(blockAudit.template[i].txid)) {
|
||||||
|
blockAudit.template[i].status = 'missing';
|
||||||
|
} else if (blockAudit.addedTxs.includes(blockAudit.template[i].txid)) {
|
||||||
|
blockAudit.template[i].status = 'added';
|
||||||
|
} else {
|
||||||
|
blockAudit.template[i].status = 'found';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = 0; i < blockAudit.transactions.length; ++i) {
|
||||||
|
if (blockAudit.missingTxs.includes(blockAudit.transactions[i].txid)) {
|
||||||
|
blockAudit.transactions[i].status = 'missing';
|
||||||
|
} else if (blockAudit.addedTxs.includes(blockAudit.transactions[i].txid)) {
|
||||||
|
blockAudit.transactions[i].status = 'added';
|
||||||
|
} else {
|
||||||
|
blockAudit.transactions[i].status = 'found';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return blockAudit;
|
||||||
|
}),
|
||||||
|
tap((blockAudit) => {
|
||||||
|
this.changeMode(this.mode);
|
||||||
|
if (this.blockGraphTemplate) {
|
||||||
|
this.blockGraphTemplate.destroy();
|
||||||
|
this.blockGraphTemplate.setup(blockAudit.template);
|
||||||
|
}
|
||||||
|
if (this.blockGraphMined) {
|
||||||
|
this.blockGraphMined.destroy();
|
||||||
|
this.blockGraphMined.setup(blockAudit.transactions);
|
||||||
|
}
|
||||||
|
this.isLoading = false;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
share()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onResize(event: any) {
|
||||||
|
this.isMobile = event.target.innerWidth <= 767.98;
|
||||||
|
this.paginationMaxSize = event.target.innerWidth < 670 ? 3 : 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
changeMode(mode: 'missing' | 'added') {
|
||||||
|
this.router.navigate([], { fragment: mode });
|
||||||
|
this.mode = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
onTxClick(event: TransactionStripped): void {
|
||||||
|
const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.txid}`);
|
||||||
|
this.router.navigate([url]);
|
||||||
|
}
|
||||||
|
|
||||||
|
pageChange(page: number, target: HTMLElement) {
|
||||||
|
}
|
||||||
|
}
|
@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
<div class="full-container">
|
<div class="full-container">
|
||||||
<div class="card-header mb-0 mb-md-4">
|
<div class="card-header mb-0 mb-md-4">
|
||||||
|
<div class="d-flex d-md-block align-items-baseline">
|
||||||
<span i18n="mining.block-fee-rates">Block Fee Rates</span>
|
<span i18n="mining.block-fee-rates">Block Fee Rates</span>
|
||||||
<button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
|
<button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
|
||||||
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
|
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats">
|
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | 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="stats.blockCount >= 144">
|
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 144">
|
||||||
|
@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
<div class="full-container">
|
<div class="full-container">
|
||||||
<div class="card-header mb-0 mb-md-4">
|
<div class="card-header mb-0 mb-md-4">
|
||||||
|
<div class="d-flex d-md-block align-items-baseline">
|
||||||
<span i18n="mining.block-fees">Block Fees</span>
|
<span i18n="mining.block-fees">Block Fees</span>
|
||||||
<button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
|
<button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
|
||||||
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
|
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats">
|
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | 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="stats.blockCount >= 4320">
|
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 4320">
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { Component, ElementRef, ViewChild, HostListener, Input, Output, EventEmitter, NgZone, AfterViewInit, OnDestroy } from '@angular/core';
|
import { Component, ElementRef, ViewChild, HostListener, Input, Output, EventEmitter, NgZone, AfterViewInit, OnDestroy } from '@angular/core';
|
||||||
import { MempoolBlockDelta, TransactionStripped } from 'src/app/interfaces/websocket.interface';
|
import { TransactionStripped } from 'src/app/interfaces/websocket.interface';
|
||||||
import { WebsocketService } from 'src/app/services/websocket.service';
|
|
||||||
import { FastVertexArray } from './fast-vertex-array';
|
import { FastVertexArray } from './fast-vertex-array';
|
||||||
import BlockScene from './block-scene';
|
import BlockScene from './block-scene';
|
||||||
import TxSprite from './tx-sprite';
|
import TxSprite from './tx-sprite';
|
||||||
|
@ -25,6 +25,7 @@ export default class TxView implements TransactionStripped {
|
|||||||
vsize: number;
|
vsize: number;
|
||||||
value: number;
|
value: number;
|
||||||
feerate: number;
|
feerate: number;
|
||||||
|
status?: 'found' | 'missing' | 'added';
|
||||||
|
|
||||||
initialised: boolean;
|
initialised: boolean;
|
||||||
vertexArray: FastVertexArray;
|
vertexArray: FastVertexArray;
|
||||||
@ -43,6 +44,7 @@ export default class TxView implements TransactionStripped {
|
|||||||
this.vsize = tx.vsize;
|
this.vsize = tx.vsize;
|
||||||
this.value = tx.value;
|
this.value = tx.value;
|
||||||
this.feerate = tx.fee / tx.vsize;
|
this.feerate = tx.fee / tx.vsize;
|
||||||
|
this.status = tx.status;
|
||||||
this.initialised = false;
|
this.initialised = false;
|
||||||
this.vertexArray = vertexArray;
|
this.vertexArray = vertexArray;
|
||||||
|
|
||||||
@ -140,6 +142,16 @@ export default class TxView implements TransactionStripped {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getColor(): Color {
|
getColor(): Color {
|
||||||
|
// Block audit
|
||||||
|
if (this.status === 'found') {
|
||||||
|
// return hexToColor('1a4987');
|
||||||
|
} else if (this.status === 'missing') {
|
||||||
|
return hexToColor('039BE5');
|
||||||
|
} else if (this.status === 'added') {
|
||||||
|
return hexToColor('D81B60');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block component
|
||||||
const feeLevelIndex = feeLevels.findIndex((feeLvl) => Math.max(1, this.feerate) < feeLvl) - 1;
|
const feeLevelIndex = feeLevels.findIndex((feeLvl) => Math.max(1, this.feerate) < feeLvl) - 1;
|
||||||
return hexToColor(mempoolFeeColors[feeLevelIndex] || mempoolFeeColors[mempoolFeeColors.length - 1]);
|
return hexToColor(mempoolFeeColors[feeLevelIndex] || mempoolFeeColors[mempoolFeeColors.length - 1]);
|
||||||
}
|
}
|
||||||
|
@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
<div class="full-container">
|
<div class="full-container">
|
||||||
<div class="card-header mb-0 mb-md-4">
|
<div class="card-header mb-0 mb-md-4">
|
||||||
|
<div class="d-flex d-md-block align-items-baseline">
|
||||||
<span i18n="mining.block-prediction-accuracy">Block Prediction Accuracy</span>
|
<span i18n="mining.block-prediction-accuracy">Block Prediction Accuracy</span>
|
||||||
<button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
|
<button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
|
||||||
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
|
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats">
|
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | 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="stats.blockCount >= 144">
|
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 144">
|
||||||
|
@ -3,10 +3,13 @@
|
|||||||
<div class="full-container">
|
<div class="full-container">
|
||||||
|
|
||||||
<div class="card-header mb-0 mb-md-4">
|
<div class="card-header mb-0 mb-md-4">
|
||||||
|
<div class="d-flex d-md-block align-items-baseline">
|
||||||
<span i18n="mining.block-rewards">Block Rewards</span>
|
<span i18n="mining.block-rewards">Block Rewards</span>
|
||||||
<button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
|
<button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
|
||||||
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
|
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats">
|
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | 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="stats.blockCount >= 4320">
|
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 4320">
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
<div class="full-container">
|
<div class="full-container">
|
||||||
|
|
||||||
<div class="card-header mb-0 mb-md-4">
|
<div class="card-header mb-0 mb-md-4">
|
||||||
|
<div class="d-flex d-md-block align-items-baseline">
|
||||||
<span i18n="mining.block-sizes-weights">Block Sizes and Weights</span>
|
<span i18n="mining.block-sizes-weights">Block Sizes and Weights</span>
|
||||||
<button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
|
<button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
|
||||||
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
|
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(blockSizesWeightsObservable$ | async) as stats">
|
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(blockSizesWeightsObservable$ | 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">
|
||||||
|
@ -12,6 +12,7 @@ import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.
|
|||||||
import { BlockExtended, TransactionStripped } from 'src/app/interfaces/node-api.interface';
|
import { BlockExtended, TransactionStripped } from 'src/app/interfaces/node-api.interface';
|
||||||
import { ApiService } from 'src/app/services/api.service';
|
import { ApiService } from 'src/app/services/api.service';
|
||||||
import { BlockOverviewGraphComponent } from 'src/app/components/block-overview-graph/block-overview-graph.component';
|
import { BlockOverviewGraphComponent } from 'src/app/components/block-overview-graph/block-overview-graph.component';
|
||||||
|
import { detectWebGL } from 'src/app/shared/graphs.utils';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-block',
|
selector: 'app-block',
|
||||||
@ -391,9 +392,3 @@ export class BlockComponent implements OnInit, OnDestroy {
|
|||||||
this.router.navigate([url]);
|
this.router.navigate([url]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectWebGL() {
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
|
|
||||||
return (gl && gl instanceof WebGLRenderingContext);
|
|
||||||
}
|
|
||||||
|
@ -31,9 +31,13 @@
|
|||||||
<button class="btn btn-primary w-100" id="dropdownBasic1" ngbDropdownToggle i18n="lightning">Lightning</button>
|
<button class="btn btn-primary w-100" id="dropdownBasic1" ngbDropdownToggle i18n="lightning">Lightning</button>
|
||||||
<div ngbDropdownMenu aria-labelledby="dropdownBasic1">
|
<div ngbDropdownMenu aria-labelledby="dropdownBasic1">
|
||||||
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/nodes-networks' | relativeUrl]"
|
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/nodes-networks' | relativeUrl]"
|
||||||
i18n="lightning.nodes-networks">Nodes per network</a>
|
i18n="lightning.nodes-networks">Lightning nodes per network</a>
|
||||||
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/capacity' | relativeUrl]"
|
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/capacity' | relativeUrl]"
|
||||||
i18n="lightning.capacity">Network capacity</a>
|
i18n="lightning.capacity">Network capacity</a>
|
||||||
|
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/nodes-per-isp' | relativeUrl]"
|
||||||
|
i18n="lightning.nodes-per-isp">Lightning nodes per ISP</a>
|
||||||
|
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/nodes-per-country' | relativeUrl]"
|
||||||
|
i18n="lightning.nodes-per-isp">Lightning nodes per country</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -23,10 +23,12 @@
|
|||||||
</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' : ''">
|
||||||
|
<div class="d-flex d-md-block align-items-baseline">
|
||||||
<span i18n="mining.hashrate-difficulty">Hashrate & Difficulty</span>
|
<span i18n="mining.hashrate-difficulty">Hashrate & Difficulty</span>
|
||||||
<button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
|
<button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
|
||||||
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
|
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(hashrateObservable$ | async) as stats">
|
<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">
|
||||||
|
@ -109,7 +109,7 @@ export class HashrateChartComponent implements OnInit {
|
|||||||
while (hashIndex < data.hashrates.length) {
|
while (hashIndex < data.hashrates.length) {
|
||||||
diffFixed.push({
|
diffFixed.push({
|
||||||
timestamp: data.hashrates[hashIndex].timestamp,
|
timestamp: data.hashrates[hashIndex].timestamp,
|
||||||
difficulty: data.difficulty[data.difficulty.length - 1].difficulty
|
difficulty: data.difficulty.length > 0 ? data.difficulty[data.difficulty.length - 1].difficulty : null
|
||||||
});
|
});
|
||||||
++hashIndex;
|
++hashIndex;
|
||||||
}
|
}
|
||||||
@ -231,11 +231,15 @@ export class HashrateChartComponent implements OnInit {
|
|||||||
} else if (tick.seriesIndex === 1) { // Difficulty
|
} else if (tick.seriesIndex === 1) { // Difficulty
|
||||||
let difficultyPowerOfTen = hashratePowerOfTen;
|
let difficultyPowerOfTen = hashratePowerOfTen;
|
||||||
let difficulty = tick.data[1];
|
let difficulty = tick.data[1];
|
||||||
|
if (difficulty === null) {
|
||||||
|
difficultyString = `${tick.marker} ${tick.seriesName}: No data<br>`;
|
||||||
|
} else {
|
||||||
if (this.isMobile()) {
|
if (this.isMobile()) {
|
||||||
difficultyPowerOfTen = selectPowerOfTen(tick.data[1]);
|
difficultyPowerOfTen = selectPowerOfTen(tick.data[1]);
|
||||||
difficulty = Math.round(tick.data[1] / difficultyPowerOfTen.divider);
|
difficulty = Math.round(tick.data[1] / difficultyPowerOfTen.divider);
|
||||||
}
|
}
|
||||||
difficultyString = `${tick.marker} ${tick.seriesName}: ${formatNumber(difficulty, this.locale, '1.2-2')} ${difficultyPowerOfTen.unit}<br>`;
|
difficultyString = `${tick.marker} ${tick.seriesName}: ${formatNumber(difficulty, this.locale, '1.2-2')} ${difficultyPowerOfTen.unit}<br>`;
|
||||||
|
}
|
||||||
} else if (tick.seriesIndex === 2) { // Hashrate MA
|
} else if (tick.seriesIndex === 2) { // Hashrate MA
|
||||||
let hashrate = tick.data[1];
|
let hashrate = tick.data[1];
|
||||||
if (this.isMobile()) {
|
if (this.isMobile()) {
|
||||||
|
@ -3,10 +3,13 @@
|
|||||||
<div class="full-container">
|
<div class="full-container">
|
||||||
|
|
||||||
<div class="card-header mb-0 mb-md-4">
|
<div class="card-header mb-0 mb-md-4">
|
||||||
|
<div class="d-flex d-md-block align-items-baseline">
|
||||||
<span i18n="mining.pools-dominance">Pools Dominance</span>
|
<span i18n="mining.pools-dominance">Pools Dominance</span>
|
||||||
<button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
|
<button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
|
||||||
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
|
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(hashrateObservable$ | async) as stats">
|
<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="stats.blockCount >= 25920">
|
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 25920">
|
||||||
|
@ -44,9 +44,6 @@
|
|||||||
<li class="nav-item" routerLinkActive="active" id="btn-graphs">
|
<li class="nav-item" routerLinkActive="active" id="btn-graphs">
|
||||||
<a class="nav-link" [routerLink]="['/graphs' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'chart-area']" [fixedWidth]="true" i18n-title="master-page.graphs" title="Graphs"></fa-icon></a>
|
<a class="nav-link" [routerLink]="['/graphs' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'chart-area']" [fixedWidth]="true" i18n-title="master-page.graphs" title="Graphs"></fa-icon></a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item d-none d-lg-block" routerLinkActive="active" id="btn-tv">
|
|
||||||
<a class="nav-link" [routerLink]="['/tv' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'tv']" [fixedWidth]="true" i18n-title="master-page.tvview" title="TV view"></fa-icon></a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item" routerLinkActive="active" id="btn-docs">
|
<li class="nav-item" routerLinkActive="active" id="btn-docs">
|
||||||
<a class="nav-link" [routerLink]="['/docs' | relativeUrl ]" (click)="collapse()"><fa-icon [icon]="['fas', 'book']" [fixedWidth]="true" i18n-title="documentation.title" title="Documentation"></fa-icon></a>
|
<a class="nav-link" [routerLink]="['/docs' | relativeUrl ]" (click)="collapse()"><fa-icon [icon]="['fas', 'book']" [fixedWidth]="true" i18n-title="documentation.title" title="Documentation"></fa-icon></a>
|
||||||
</li>
|
</li>
|
||||||
|
@ -32,10 +32,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-header" *ngIf="!widget">
|
<div class="card-header" *ngIf="!widget">
|
||||||
|
<div class="d-flex d-md-block align-items-baseline">
|
||||||
<span i18n="mining.pools">Pools Ranking</span>
|
<span i18n="mining.pools">Pools Ranking</span>
|
||||||
<button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
|
<button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
|
||||||
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
|
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
<form [formGroup]="radioGroupForm" class="formRadioGroup"
|
<form [formGroup]="radioGroupForm" class="formRadioGroup"
|
||||||
*ngIf="!widget && (miningStatsObservable$ | async) as stats">
|
*ngIf="!widget && (miningStatsObservable$ | 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">
|
||||||
|
@ -3,14 +3,22 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<i class="fa fa-area-chart"></i>
|
<div class="d-flex d-md-block align-items-baseline">
|
||||||
<span i18n="statistics.memory-by-vBytes">Mempool by vBytes (sat/vByte)</span>
|
<span i18n="statistics.memory-by-vBytes">Mempool by vBytes (sat/vByte)</span>
|
||||||
<button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart('mempool')">
|
<button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart('mempool')">
|
||||||
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
|
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form [formGroup]="radioGroupForm" class="formRadioGroup"
|
<form [formGroup]="radioGroupForm" class="formRadioGroup"
|
||||||
[class]="stateService.env.MINING_DASHBOARD ? 'mining' : ''" (click)="saveGraphPreference()">
|
[class]="stateService.env.MINING_DASHBOARD ? 'mining' : ''" (click)="saveGraphPreference()">
|
||||||
|
<div *ngIf="!isMobile()" class="btn-group btn-group-toggle">
|
||||||
|
<label ngbButtonLabel class="btn-primary btn-sm mr-2">
|
||||||
|
<a [routerLink]="['/tv' | relativeUrl]" style="color: white">
|
||||||
|
<fa-icon [icon]="['fas', 'tv']" [fixedWidth]="true" i18n-title="master-page.tvview" title="TV view"></fa-icon>
|
||||||
|
</a>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<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">
|
<label ngbButtonLabel class="btn-primary btn-sm">
|
||||||
<input ngbButton type="radio" [value]="'2h'" [routerLink]="['/graphs' | relativeUrl]" fragment="2h"> 2H
|
<input ngbButton type="radio" [value]="'2h'" [routerLink]="['/graphs' | relativeUrl]" fragment="2h"> 2H
|
||||||
@ -84,12 +92,13 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<i class="fa fa-area-chart"></i>
|
<div class="d-flex d-md-block align-items-baseline">
|
||||||
<span i18n="statistics.transaction-vbytes-per-second">Transaction vBytes per second (vB/s)</span>
|
<span i18n="statistics.transaction-vbytes-per-second">Transaction vBytes per second (vB/s)</span>
|
||||||
<button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart('incoming')">
|
<button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart('incoming')">
|
||||||
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
|
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="incoming-transactions-graph">
|
<div class="incoming-transactions-graph">
|
||||||
|
@ -210,4 +210,8 @@ export class StatisticsComponent implements OnInit {
|
|||||||
this.incomingGraph.onSaveChart(this.timespan);
|
this.incomingGraph.onSaveChart(this.timespan);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isMobile() {
|
||||||
|
return (window.innerWidth <= 767.98);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -20,6 +20,8 @@ import { TelevisionComponent } from '../components/television/television.compone
|
|||||||
import { DashboardComponent } from '../dashboard/dashboard.component';
|
import { DashboardComponent } from '../dashboard/dashboard.component';
|
||||||
import { NodesNetworksChartComponent } from '../lightning/nodes-networks-chart/nodes-networks-chart.component';
|
import { NodesNetworksChartComponent } from '../lightning/nodes-networks-chart/nodes-networks-chart.component';
|
||||||
import { LightningStatisticsChartComponent } from '../lightning/statistics-chart/lightning-statistics-chart.component';
|
import { LightningStatisticsChartComponent } from '../lightning/statistics-chart/lightning-statistics-chart.component';
|
||||||
|
import { NodesPerISPChartComponent } from '../lightning/nodes-per-isp-chart/nodes-per-isp-chart.component';
|
||||||
|
import { NodesPerCountryChartComponent } from '../lightning/nodes-per-country-chart/nodes-per-country-chart.component';
|
||||||
|
|
||||||
const browserWindow = window || {};
|
const browserWindow = window || {};
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@ -99,6 +101,14 @@ const routes: Routes = [
|
|||||||
path: 'lightning/capacity',
|
path: 'lightning/capacity',
|
||||||
component: LightningStatisticsChartComponent,
|
component: LightningStatisticsChartComponent,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'lightning/nodes-per-isp',
|
||||||
|
component: NodesPerISPChartComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'lightning/nodes-per-country',
|
||||||
|
component: NodesPerCountryChartComponent,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
redirectTo: 'mempool',
|
redirectTo: 'mempool',
|
||||||
|
@ -128,11 +128,20 @@ export interface BlockExtended extends Block {
|
|||||||
extras?: BlockExtension;
|
extras?: BlockExtension;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BlockAudit extends BlockExtended {
|
||||||
|
missingTxs: string[],
|
||||||
|
addedTxs: string[],
|
||||||
|
matchRate: number,
|
||||||
|
template: TransactionStripped[],
|
||||||
|
transactions: TransactionStripped[],
|
||||||
|
}
|
||||||
|
|
||||||
export interface TransactionStripped {
|
export interface TransactionStripped {
|
||||||
txid: string;
|
txid: string;
|
||||||
fee: number;
|
fee: number;
|
||||||
vsize: number;
|
vsize: number;
|
||||||
value: number;
|
value: number;
|
||||||
|
status?: 'found' | 'missing' | 'added';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RewardStats {
|
export interface RewardStats {
|
||||||
|
@ -70,6 +70,7 @@ export interface TransactionStripped {
|
|||||||
fee: number;
|
fee: number;
|
||||||
vsize: number;
|
vsize: number;
|
||||||
value: number;
|
value: number;
|
||||||
|
status?: 'found' | 'missing' | 'added';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IBackendInfo {
|
export interface IBackendInfo {
|
||||||
|
@ -18,6 +18,10 @@ import { NodeStatisticsChartComponent } from './node-statistics-chart/node-stati
|
|||||||
import { GraphsModule } from '../graphs/graphs.module';
|
import { GraphsModule } from '../graphs/graphs.module';
|
||||||
import { NodesNetworksChartComponent } from './nodes-networks-chart/nodes-networks-chart.component';
|
import { NodesNetworksChartComponent } from './nodes-networks-chart/nodes-networks-chart.component';
|
||||||
import { ChannelsStatisticsComponent } from './channels-statistics/channels-statistics.component';
|
import { ChannelsStatisticsComponent } from './channels-statistics/channels-statistics.component';
|
||||||
|
import { NodesPerISPChartComponent } from './nodes-per-isp-chart/nodes-per-isp-chart.component';
|
||||||
|
import { NodesPerCountry } from './nodes-per-country/nodes-per-country.component';
|
||||||
|
import { NodesPerISP } from './nodes-per-isp/nodes-per-isp.component';
|
||||||
|
import { NodesPerCountryChartComponent } from '../lightning/nodes-per-country-chart/nodes-per-country-chart.component';
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [
|
declarations: [
|
||||||
LightningDashboardComponent,
|
LightningDashboardComponent,
|
||||||
@ -33,6 +37,10 @@ import { ChannelsStatisticsComponent } from './channels-statistics/channels-stat
|
|||||||
LightningStatisticsChartComponent,
|
LightningStatisticsChartComponent,
|
||||||
NodesNetworksChartComponent,
|
NodesNetworksChartComponent,
|
||||||
ChannelsStatisticsComponent,
|
ChannelsStatisticsComponent,
|
||||||
|
NodesPerISPChartComponent,
|
||||||
|
NodesPerCountry,
|
||||||
|
NodesPerISP,
|
||||||
|
NodesPerCountryChartComponent,
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
@ -4,6 +4,8 @@ import { LightningDashboardComponent } from './lightning-dashboard/lightning-das
|
|||||||
import { LightningWrapperComponent } from './lightning-wrapper/lightning-wrapper.component';
|
import { LightningWrapperComponent } from './lightning-wrapper/lightning-wrapper.component';
|
||||||
import { NodeComponent } from './node/node.component';
|
import { NodeComponent } from './node/node.component';
|
||||||
import { ChannelComponent } from './channel/channel.component';
|
import { ChannelComponent } from './channel/channel.component';
|
||||||
|
import { NodesPerCountry } from './nodes-per-country/nodes-per-country.component';
|
||||||
|
import { NodesPerISP } from './nodes-per-isp/nodes-per-isp.component';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{
|
{
|
||||||
@ -22,6 +24,14 @@ const routes: Routes = [
|
|||||||
path: 'channel/:short_id',
|
path: 'channel/:short_id',
|
||||||
component: ChannelComponent,
|
component: ChannelComponent,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'nodes/country/:country',
|
||||||
|
component: NodesPerCountry,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'nodes/isp/:isp',
|
||||||
|
component: NodesPerISP,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '**',
|
path: '**',
|
||||||
redirectTo: ''
|
redirectTo: ''
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
<div [class]="widget === false ? 'full-container' : ''">
|
<div [class]="widget === false ? 'full-container' : ''">
|
||||||
|
|
||||||
<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.nodes-networks">Nodes count by network</span>
|
<div class="d-flex d-md-block align-items-baseline">
|
||||||
<button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
|
<span i18n="lightning.nodes-networks">Lightning nodes per network</span>
|
||||||
|
<button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
|
||||||
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
|
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(nodesNetworkObservable$ | async) as stats">
|
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(nodesNetworkObservable$ | 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">
|
||||||
|
@ -61,7 +61,7 @@ export class NodesNetworksChartComponent implements OnInit {
|
|||||||
if (this.widget) {
|
if (this.widget) {
|
||||||
this.miningWindowPreference = '1y';
|
this.miningWindowPreference = '1y';
|
||||||
} else {
|
} else {
|
||||||
this.seoService.setTitle($localize`Nodes per network`);
|
this.seoService.setTitle($localize`Lightning nodes per network`);
|
||||||
this.miningWindowPreference = this.miningService.getDefaultTimespan('all');
|
this.miningWindowPreference = this.miningService.getDefaultTimespan('all');
|
||||||
}
|
}
|
||||||
this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference });
|
this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference });
|
||||||
|
@ -0,0 +1,58 @@
|
|||||||
|
<div class="full-container h-100">
|
||||||
|
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="d-flex d-md-block align-items-baseline" style="margin-bottom: -5px">
|
||||||
|
<span i18n="lightning.nodes-per-country">Lightning nodes per country</span>
|
||||||
|
<button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
|
||||||
|
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small style="color: #ffffff66" i18n="lightning.tor-nodes-excluded">(Tor nodes excluded)</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container pb-lg-0 bottom-padding">
|
||||||
|
<div class="pb-lg-5">
|
||||||
|
<div class="chart w-100" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
|
||||||
|
(chartInit)="onChartInit($event)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center loadingGraphs" *ngIf="isLoading">
|
||||||
|
<div class="spinner-border text-light"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="table table-borderless text-center m-auto" style="max-width: 900px">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="text-left rank" i18n="mining.rank">Rank</th>
|
||||||
|
<th class="text-left name" i18n="lightning.as-name">Name</th>
|
||||||
|
<th class="text-right share" i18n="lightning.share">Share</th>
|
||||||
|
<th class="text-right nodes" i18n="lightning.nodes-count">Nodes</th>
|
||||||
|
<th class="text-right capacity" i18n="lightning.capacity">Capacity</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody [attr.data-cy]="'pools-table'" *ngIf="(nodesPerCountryObservable$ | async) as countries">
|
||||||
|
<tr *ngFor="let country of countries">
|
||||||
|
<td class="text-left rank">{{ country.rank }}</td>
|
||||||
|
<td class="text-left text-truncate name">
|
||||||
|
<div class="d-flex">
|
||||||
|
<span style="font-size: 20px">{{ country.flag }}</span>
|
||||||
|
|
||||||
|
<a class="mt-auto mb-auto" [routerLink]="['/lightning/nodes/country' | relativeUrl, country.iso]">{{ country.name.en }}</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-right share">{{ country.share }}%</td>
|
||||||
|
<td class="text-right nodes">{{ country.count }}</td>
|
||||||
|
<td class="text-right capacity">
|
||||||
|
<app-amount *ngIf="country.capacity > 100000000; else smallchannel" [satoshis]="country.capacity" [digitsInfo]="'1.2-2'" [noFiat]="true"></app-amount>
|
||||||
|
<ng-template #smallchannel>
|
||||||
|
{{ country.capacity | amountShortener: 1 }}
|
||||||
|
<span class="sats" i18n="shared.sats">sats</span>
|
||||||
|
</ng-template>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
@ -0,0 +1,81 @@
|
|||||||
|
.sats {
|
||||||
|
color: #ffffff66;
|
||||||
|
font-size: 12px;
|
||||||
|
top: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
border-bottom: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
@media (min-width: 465px) {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-container {
|
||||||
|
padding: 0px 15px;
|
||||||
|
width: 100%;
|
||||||
|
height: calc(100% - 140px);
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
height: calc(100% - 190px);
|
||||||
|
};
|
||||||
|
@media (max-width: 575px) {
|
||||||
|
height: calc(100% - 230px);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart {
|
||||||
|
max-height: 400px;
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
max-height: 230px;
|
||||||
|
margin-top: -35px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-padding {
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
padding-bottom: 65px
|
||||||
|
};
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
padding-bottom: 65px
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank {
|
||||||
|
width: 20%;
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
display: none
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
width: 20%;
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
width: 80%;
|
||||||
|
max-width: 150px;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.share {
|
||||||
|
width: 20%;
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
display: none
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodes {
|
||||||
|
width: 20%;
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
width: 10%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.capacity {
|
||||||
|
width: 20%;
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
width: 10%;
|
||||||
|
max-width: 100px;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,235 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, OnInit, HostBinding, NgZone } from '@angular/core';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { EChartsOption, PieSeriesOption } from 'echarts';
|
||||||
|
import { map, Observable, share, tap } from 'rxjs';
|
||||||
|
import { chartColors } from 'src/app/app.constants';
|
||||||
|
import { ApiService } from 'src/app/services/api.service';
|
||||||
|
import { SeoService } from 'src/app/services/seo.service';
|
||||||
|
import { StateService } from 'src/app/services/state.service';
|
||||||
|
import { download } from 'src/app/shared/graphs.utils';
|
||||||
|
import { AmountShortenerPipe } from 'src/app/shared/pipes/amount-shortener.pipe';
|
||||||
|
import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe';
|
||||||
|
import { getFlagEmoji } from 'src/app/shared/graphs.utils';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-nodes-per-country-chart',
|
||||||
|
templateUrl: './nodes-per-country-chart.component.html',
|
||||||
|
styleUrls: ['./nodes-per-country-chart.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class NodesPerCountryChartComponent implements OnInit {
|
||||||
|
miningWindowPreference: string;
|
||||||
|
|
||||||
|
isLoading = true;
|
||||||
|
chartOptions: EChartsOption = {};
|
||||||
|
chartInitOptions = {
|
||||||
|
renderer: 'svg',
|
||||||
|
};
|
||||||
|
timespan = '';
|
||||||
|
chartInstance: any = undefined;
|
||||||
|
|
||||||
|
@HostBinding('attr.dir') dir = 'ltr';
|
||||||
|
|
||||||
|
nodesPerCountryObservable$: Observable<any>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private apiService: ApiService,
|
||||||
|
private seoService: SeoService,
|
||||||
|
private amountShortenerPipe: AmountShortenerPipe,
|
||||||
|
private zone: NgZone,
|
||||||
|
private stateService: StateService,
|
||||||
|
private router: Router,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.seoService.setTitle($localize`Lightning nodes per country`);
|
||||||
|
|
||||||
|
this.nodesPerCountryObservable$ = this.apiService.getNodesPerCountry()
|
||||||
|
.pipe(
|
||||||
|
map(data => {
|
||||||
|
for (let i = 0; i < data.length; ++i) {
|
||||||
|
data[i].rank = i + 1;
|
||||||
|
data[i].iso = data[i].iso.toLowerCase();
|
||||||
|
data[i].flag = getFlagEmoji(data[i].iso);
|
||||||
|
}
|
||||||
|
return data.slice(0, 100);
|
||||||
|
}),
|
||||||
|
tap(data => {
|
||||||
|
this.isLoading = false;
|
||||||
|
this.prepareChartOptions(data);
|
||||||
|
}),
|
||||||
|
share()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
generateChartSerieData(country) {
|
||||||
|
const shareThreshold = this.isMobile() ? 2 : 1;
|
||||||
|
const data: object[] = [];
|
||||||
|
let totalShareOther = 0;
|
||||||
|
let totalNodeOther = 0;
|
||||||
|
|
||||||
|
let edgeDistance: string | number = '10%';
|
||||||
|
if (this.isMobile()) {
|
||||||
|
edgeDistance = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
country.forEach((country) => {
|
||||||
|
if (country.share < shareThreshold) {
|
||||||
|
totalShareOther += country.share;
|
||||||
|
totalNodeOther += country.count;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
data.push({
|
||||||
|
value: country.share,
|
||||||
|
name: country.name.en + (this.isMobile() ? `` : ` (${country.share}%)`),
|
||||||
|
label: {
|
||||||
|
overflow: 'truncate',
|
||||||
|
color: '#b1b1b1',
|
||||||
|
alignTo: 'edge',
|
||||||
|
edgeDistance: edgeDistance,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
show: !this.isMobile(),
|
||||||
|
backgroundColor: 'rgba(17, 19, 31, 1)',
|
||||||
|
borderRadius: 4,
|
||||||
|
shadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
textStyle: {
|
||||||
|
color: '#b1b1b1',
|
||||||
|
},
|
||||||
|
borderColor: '#000',
|
||||||
|
formatter: () => {
|
||||||
|
return `<b style="color: white">${country.name.en} (${country.share}%)</b><br>` +
|
||||||
|
$localize`${country.count.toString()} nodes<br>` +
|
||||||
|
$localize`${this.amountShortenerPipe.transform(country.capacity / 100000000, 2)} BTC capacity`
|
||||||
|
;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data: country.iso,
|
||||||
|
} as PieSeriesOption);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 'Other'
|
||||||
|
data.push({
|
||||||
|
itemStyle: {
|
||||||
|
color: 'grey',
|
||||||
|
},
|
||||||
|
value: totalShareOther,
|
||||||
|
name: 'Other' + (this.isMobile() ? `` : ` (${totalShareOther.toFixed(2)}%)`),
|
||||||
|
label: {
|
||||||
|
overflow: 'truncate',
|
||||||
|
color: '#b1b1b1',
|
||||||
|
alignTo: 'edge',
|
||||||
|
edgeDistance: edgeDistance
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: 'rgba(17, 19, 31, 1)',
|
||||||
|
borderRadius: 4,
|
||||||
|
shadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
textStyle: {
|
||||||
|
color: '#b1b1b1',
|
||||||
|
},
|
||||||
|
borderColor: '#000',
|
||||||
|
formatter: () => {
|
||||||
|
return `<b style="color: white">${'Other'} (${totalShareOther.toFixed(2)}%)</b><br>` +
|
||||||
|
totalNodeOther.toString() + ` nodes`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: 9999 as any
|
||||||
|
} as PieSeriesOption);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareChartOptions(country) {
|
||||||
|
let pieSize = ['20%', '80%']; // Desktop
|
||||||
|
if (this.isMobile()) {
|
||||||
|
pieSize = ['15%', '60%'];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.chartOptions = {
|
||||||
|
animation: false,
|
||||||
|
color: chartColors,
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'item',
|
||||||
|
textStyle: {
|
||||||
|
align: 'left',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
zlevel: 0,
|
||||||
|
minShowLabelAngle: 3.6,
|
||||||
|
name: 'Mining pool',
|
||||||
|
type: 'pie',
|
||||||
|
radius: pieSize,
|
||||||
|
data: this.generateChartSerieData(country),
|
||||||
|
labelLine: {
|
||||||
|
lineStyle: {
|
||||||
|
width: 2,
|
||||||
|
},
|
||||||
|
length: this.isMobile() ? 1 : 20,
|
||||||
|
length2: this.isMobile() ? 1 : undefined,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
itemStyle: {
|
||||||
|
borderRadius: 1,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#000',
|
||||||
|
},
|
||||||
|
emphasis: {
|
||||||
|
itemStyle: {
|
||||||
|
shadowBlur: 40,
|
||||||
|
shadowColor: 'rgba(0, 0, 0, 0.75)',
|
||||||
|
},
|
||||||
|
labelLine: {
|
||||||
|
lineStyle: {
|
||||||
|
width: 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
isMobile() {
|
||||||
|
return (window.innerWidth <= 767.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
onChartInit(ec) {
|
||||||
|
if (this.chartInstance !== undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.chartInstance = ec;
|
||||||
|
|
||||||
|
this.chartInstance.on('click', (e) => {
|
||||||
|
if (e.data.data === 9999) { // "Other"
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.zone.run(() => {
|
||||||
|
const url = new RelativeUrlPipe(this.stateService).transform(`/lightning/nodes/country/${e.data.data}`);
|
||||||
|
this.router.navigate([url]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onSaveChart() {
|
||||||
|
const now = new Date();
|
||||||
|
this.chartOptions.backgroundColor = '#11131f';
|
||||||
|
this.chartInstance.setOption(this.chartOptions);
|
||||||
|
download(this.chartInstance.getDataURL({
|
||||||
|
pixelRatio: 2,
|
||||||
|
excludeComponents: ['dataZoom'],
|
||||||
|
}), `lightning-nodes-per-country-${Math.round(now.getTime() / 1000)}.svg`);
|
||||||
|
this.chartOptions.backgroundColor = 'none';
|
||||||
|
this.chartInstance.setOption(this.chartOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
isEllipsisActive(e) {
|
||||||
|
return (e.offsetWidth < e.scrollWidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
|||||||
|
<div class="container-xl full-height" style="min-height: 335px">
|
||||||
|
<h1 class="float-left" i18n="lightning.nodes-in-country">
|
||||||
|
<span>Lightning nodes in {{ country?.name }}</span>
|
||||||
|
<span style="font-size: 50px; vertical-align:sub;"> {{ country?.flag }}</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div style="min-height: 295px">
|
||||||
|
<table class="table table-borderless">
|
||||||
|
<thead>
|
||||||
|
<th class="alias text-left" i18n="lightning.alias">Alias</th>
|
||||||
|
<th class="timestamp-first text-left" i18n="lightning.first_seen">First seen</th>
|
||||||
|
<th class="timestamp-update text-left" i18n="lightning.last_update">Last update</th>
|
||||||
|
<th class="capacity text-right" i18n="lightning.capacity">Capacity</th>
|
||||||
|
<th class="channels text-right" i18n="lightning.channels">Channels</th>
|
||||||
|
<th class="city text-right" i18n="lightning.city">City</th>
|
||||||
|
</thead>
|
||||||
|
<tbody *ngIf="nodes$ | async as nodes">
|
||||||
|
<tr *ngFor="let node of nodes; let i= index; trackBy: trackByPublicKey">
|
||||||
|
<td class="alias text-left text-truncate">
|
||||||
|
<a [routerLink]="['/lightning/node/' | relativeUrl, node.public_key]">{{ node.alias }}</a>
|
||||||
|
</td>
|
||||||
|
<td class="timestamp-first text-left">
|
||||||
|
<app-timestamp [customFormat]="'yyyy-MM-dd'" [unixTime]="node.first_seen"></app-timestamp>
|
||||||
|
</td>
|
||||||
|
<td class="timestamp-update text-left">
|
||||||
|
<app-timestamp [customFormat]="'yyyy-MM-dd'" [unixTime]="node.updated_at"></app-timestamp>
|
||||||
|
</td>
|
||||||
|
<td class="capacity text-right">
|
||||||
|
<app-amount *ngIf="node.capacity > 100000000; else smallchannel" [satoshis]="node.capacity" [digitsInfo]="'1.2-2'" [noFiat]="true"></app-amount>
|
||||||
|
<ng-template #smallchannel>
|
||||||
|
{{ node.capacity | amountShortener: 1 }}
|
||||||
|
<span class="sats" i18n="shared.sats">sats</span>
|
||||||
|
</ng-template>
|
||||||
|
</td>
|
||||||
|
<td class="channels text-right">
|
||||||
|
{{ node.channels }}
|
||||||
|
</td>
|
||||||
|
<td class="city text-right text-truncate">
|
||||||
|
{{ node?.city?.en ?? '-' }}
|
||||||
|
</td>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
@ -0,0 +1,56 @@
|
|||||||
|
.container-xl {
|
||||||
|
max-width: 1400px;
|
||||||
|
padding-bottom: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sats {
|
||||||
|
color: #ffffff66;
|
||||||
|
font-size: 12px;
|
||||||
|
top: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alias {
|
||||||
|
width: 30%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding-right: 70px;
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
width: 50%;
|
||||||
|
max-width: 150px;
|
||||||
|
padding-right: 0px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.timestamp-first {
|
||||||
|
width: 20%;
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
display: none
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.timestamp-update {
|
||||||
|
width: 16%;
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
display: none
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.capacity {
|
||||||
|
width: 10%;
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
width: 25%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels {
|
||||||
|
width: 10%;
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
width: 25%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.city {
|
||||||
|
max-width: 150px;
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
display: none
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,41 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { map, Observable } from 'rxjs';
|
||||||
|
import { ApiService } from 'src/app/services/api.service';
|
||||||
|
import { SeoService } from 'src/app/services/seo.service';
|
||||||
|
import { getFlagEmoji } from 'src/app/shared/graphs.utils';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-nodes-per-country',
|
||||||
|
templateUrl: './nodes-per-country.component.html',
|
||||||
|
styleUrls: ['./nodes-per-country.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class NodesPerCountry implements OnInit {
|
||||||
|
nodes$: Observable<any>;
|
||||||
|
country: {name: string, flag: string};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private apiService: ApiService,
|
||||||
|
private seoService: SeoService,
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.nodes$ = this.apiService.getNodeForCountry$(this.route.snapshot.params.country)
|
||||||
|
.pipe(
|
||||||
|
map(response => {
|
||||||
|
this.country = {
|
||||||
|
name: response.country.en,
|
||||||
|
flag: getFlagEmoji(this.route.snapshot.params.country)
|
||||||
|
};
|
||||||
|
this.seoService.setTitle($localize`Lightning nodes in ${this.country.name}`);
|
||||||
|
return response.nodes;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
trackByPublicKey(index: number, node: any) {
|
||||||
|
return node.public_key;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,48 @@
|
|||||||
|
<div class="full-container h-100">
|
||||||
|
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="d-flex d-md-block align-items-baseline" style="margin-bottom: -5px">
|
||||||
|
<span i18n="lightning.nodes-per-isp">Lightning nodes per ISP</span>
|
||||||
|
<button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
|
||||||
|
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small style="color: #ffffff66" i18n="lightning.tor-nodes-excluded">(Tor nodes excluded)</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container pb-lg-0 bottom-padding">
|
||||||
|
<div class="pb-lg-5" *ngIf="nodesPerAsObservable$ | async">
|
||||||
|
<div class="chart w-100" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
|
||||||
|
(chartInit)="onChartInit($event)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center loadingGraphs" *ngIf="isLoading">
|
||||||
|
<div class="spinner-border text-light"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="table table-borderless text-center m-auto" style="max-width: 900px">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="rank text-left pl-0" i18n="mining.rank">Rank</th>
|
||||||
|
<th class="name text-left" i18n="lightning.isp">ISP</th>
|
||||||
|
<th class="share text-right" i18n="lightning.share">Share</th>
|
||||||
|
<th class="nodes text-right" i18n="lightning.nodes-count">Nodes</th>
|
||||||
|
<th class="capacity text-right pr-0" i18n="lightning.capacity">Capacity</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody [attr.data-cy]="'pools-table'" *ngIf="(nodesPerAsObservable$ | async) as asList">
|
||||||
|
<tr *ngFor="let asEntry of asList">
|
||||||
|
<td class="rank text-left pl-0">{{ asEntry.rank }}</td>
|
||||||
|
<td class="name text-left text-truncate" style="max-width: 100px">
|
||||||
|
<a [routerLink]="[('/lightning/nodes/isp/' + asEntry.ispId) | relativeUrl]">{{ asEntry.name }}</a>
|
||||||
|
</td>
|
||||||
|
<td class="share text-right">{{ asEntry.share }}%</td>
|
||||||
|
<td class="nodes text-right">{{ asEntry.count }}</td>
|
||||||
|
<td class="capacity text-right pr-0"><app-amount [satoshis]="asEntry.capacity" [digitsInfo]="'1.2-2'" [noFiat]="true"></app-amount></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
@ -0,0 +1,75 @@
|
|||||||
|
.card-header {
|
||||||
|
border-bottom: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
@media (min-width: 465px) {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-container {
|
||||||
|
padding: 0px 15px;
|
||||||
|
width: 100%;
|
||||||
|
height: calc(100% - 140px);
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
height: calc(100% - 190px);
|
||||||
|
};
|
||||||
|
@media (max-width: 575px) {
|
||||||
|
height: calc(100% - 230px);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart {
|
||||||
|
max-height: 400px;
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
max-height: 230px;
|
||||||
|
margin-top: -35px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-padding {
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
padding-bottom: 65px
|
||||||
|
};
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
padding-bottom: 65px
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank {
|
||||||
|
width: 20%;
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
display: none
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
width: 20%;
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
width: 80%;
|
||||||
|
max-width: 150px;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.share {
|
||||||
|
width: 20%;
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
display: none
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodes {
|
||||||
|
width: 20%;
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
width: 10%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.capacity {
|
||||||
|
width: 20%;
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
width: 10%;
|
||||||
|
max-width: 100px;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,231 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, OnInit, HostBinding, NgZone } from '@angular/core';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { EChartsOption, PieSeriesOption } from 'echarts';
|
||||||
|
import { map, Observable, share, tap } from 'rxjs';
|
||||||
|
import { chartColors } from 'src/app/app.constants';
|
||||||
|
import { ApiService } from 'src/app/services/api.service';
|
||||||
|
import { SeoService } from 'src/app/services/seo.service';
|
||||||
|
import { StateService } from 'src/app/services/state.service';
|
||||||
|
import { download } from 'src/app/shared/graphs.utils';
|
||||||
|
import { AmountShortenerPipe } from 'src/app/shared/pipes/amount-shortener.pipe';
|
||||||
|
import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-nodes-per-isp-chart',
|
||||||
|
templateUrl: './nodes-per-isp-chart.component.html',
|
||||||
|
styleUrls: ['./nodes-per-isp-chart.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class NodesPerISPChartComponent implements OnInit {
|
||||||
|
miningWindowPreference: string;
|
||||||
|
|
||||||
|
isLoading = true;
|
||||||
|
chartOptions: EChartsOption = {};
|
||||||
|
chartInitOptions = {
|
||||||
|
renderer: 'svg',
|
||||||
|
};
|
||||||
|
timespan = '';
|
||||||
|
chartInstance: any = undefined;
|
||||||
|
|
||||||
|
@HostBinding('attr.dir') dir = 'ltr';
|
||||||
|
|
||||||
|
nodesPerAsObservable$: Observable<any>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private apiService: ApiService,
|
||||||
|
private seoService: SeoService,
|
||||||
|
private amountShortenerPipe: AmountShortenerPipe,
|
||||||
|
private router: Router,
|
||||||
|
private zone: NgZone,
|
||||||
|
private stateService: StateService,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.seoService.setTitle($localize`Lightning nodes per ISP`);
|
||||||
|
|
||||||
|
this.nodesPerAsObservable$ = this.apiService.getNodesPerAs()
|
||||||
|
.pipe(
|
||||||
|
tap(data => {
|
||||||
|
this.isLoading = false;
|
||||||
|
this.prepareChartOptions(data);
|
||||||
|
}),
|
||||||
|
map(data => {
|
||||||
|
for (let i = 0; i < data.length; ++i) {
|
||||||
|
data[i].rank = i + 1;
|
||||||
|
}
|
||||||
|
return data.slice(0, 100);
|
||||||
|
}),
|
||||||
|
share()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
generateChartSerieData(as) {
|
||||||
|
const shareThreshold = this.isMobile() ? 2 : 1;
|
||||||
|
const data: object[] = [];
|
||||||
|
let totalShareOther = 0;
|
||||||
|
let totalNodeOther = 0;
|
||||||
|
|
||||||
|
let edgeDistance: string | number = '10%';
|
||||||
|
if (this.isMobile()) {
|
||||||
|
edgeDistance = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
as.forEach((as) => {
|
||||||
|
if (as.share < shareThreshold) {
|
||||||
|
totalShareOther += as.share;
|
||||||
|
totalNodeOther += as.count;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
data.push({
|
||||||
|
value: as.share,
|
||||||
|
name: as.name + (this.isMobile() ? `` : ` (${as.share}%)`),
|
||||||
|
label: {
|
||||||
|
overflow: 'truncate',
|
||||||
|
color: '#b1b1b1',
|
||||||
|
alignTo: 'edge',
|
||||||
|
edgeDistance: edgeDistance,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
show: !this.isMobile(),
|
||||||
|
backgroundColor: 'rgba(17, 19, 31, 1)',
|
||||||
|
borderRadius: 4,
|
||||||
|
shadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
textStyle: {
|
||||||
|
color: '#b1b1b1',
|
||||||
|
},
|
||||||
|
borderColor: '#000',
|
||||||
|
formatter: () => {
|
||||||
|
return `<b style="color: white">${as.name} (${as.share}%)</b><br>` +
|
||||||
|
$localize`${as.count.toString()} nodes<br>` +
|
||||||
|
$localize`${this.amountShortenerPipe.transform(as.capacity / 100000000, 2)} BTC capacity`
|
||||||
|
;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data: as.ispId,
|
||||||
|
} as PieSeriesOption);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 'Other'
|
||||||
|
data.push({
|
||||||
|
itemStyle: {
|
||||||
|
color: 'grey',
|
||||||
|
},
|
||||||
|
value: totalShareOther,
|
||||||
|
name: 'Other' + (this.isMobile() ? `` : ` (${totalShareOther.toFixed(2)}%)`),
|
||||||
|
label: {
|
||||||
|
overflow: 'truncate',
|
||||||
|
color: '#b1b1b1',
|
||||||
|
alignTo: 'edge',
|
||||||
|
edgeDistance: edgeDistance
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: 'rgba(17, 19, 31, 1)',
|
||||||
|
borderRadius: 4,
|
||||||
|
shadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
textStyle: {
|
||||||
|
color: '#b1b1b1',
|
||||||
|
},
|
||||||
|
borderColor: '#000',
|
||||||
|
formatter: () => {
|
||||||
|
return `<b style="color: white">${'Other'} (${totalShareOther.toFixed(2)}%)</b><br>` +
|
||||||
|
totalNodeOther.toString() + ` nodes`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data: 9999 as any,
|
||||||
|
} as PieSeriesOption);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareChartOptions(as) {
|
||||||
|
let pieSize = ['20%', '80%']; // Desktop
|
||||||
|
if (this.isMobile()) {
|
||||||
|
pieSize = ['15%', '60%'];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.chartOptions = {
|
||||||
|
color: chartColors,
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'item',
|
||||||
|
textStyle: {
|
||||||
|
align: 'left',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
zlevel: 0,
|
||||||
|
minShowLabelAngle: 3.6,
|
||||||
|
name: 'Lightning nodes',
|
||||||
|
type: 'pie',
|
||||||
|
radius: pieSize,
|
||||||
|
data: this.generateChartSerieData(as),
|
||||||
|
labelLine: {
|
||||||
|
lineStyle: {
|
||||||
|
width: 2,
|
||||||
|
},
|
||||||
|
length: this.isMobile() ? 1 : 20,
|
||||||
|
length2: this.isMobile() ? 1 : undefined,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
itemStyle: {
|
||||||
|
borderRadius: 1,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#000',
|
||||||
|
},
|
||||||
|
emphasis: {
|
||||||
|
itemStyle: {
|
||||||
|
shadowBlur: 40,
|
||||||
|
shadowColor: 'rgba(0, 0, 0, 0.75)',
|
||||||
|
},
|
||||||
|
labelLine: {
|
||||||
|
lineStyle: {
|
||||||
|
width: 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
isMobile() {
|
||||||
|
return (window.innerWidth <= 767.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
onChartInit(ec) {
|
||||||
|
if (this.chartInstance !== undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.chartInstance = ec;
|
||||||
|
|
||||||
|
this.chartInstance.on('click', (e) => {
|
||||||
|
if (e.data.data === 9999) { // "Other"
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.zone.run(() => {
|
||||||
|
const url = new RelativeUrlPipe(this.stateService).transform(`/lightning/nodes/isp/${e.data.data}`);
|
||||||
|
this.router.navigate([url]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onSaveChart() {
|
||||||
|
const now = new Date();
|
||||||
|
this.chartOptions.backgroundColor = '#11131f';
|
||||||
|
this.chartInstance.setOption(this.chartOptions);
|
||||||
|
download(this.chartInstance.getDataURL({
|
||||||
|
pixelRatio: 2,
|
||||||
|
excludeComponents: ['dataZoom'],
|
||||||
|
}), `ln-nodes-per-as-${this.timespan}-${Math.round(now.getTime() / 1000)}.svg`);
|
||||||
|
this.chartOptions.backgroundColor = 'none';
|
||||||
|
this.chartInstance.setOption(this.chartOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
isEllipsisActive(e) {
|
||||||
|
return (e.offsetWidth < e.scrollWidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
|||||||
|
<div class="container-xl full-height" style="min-height: 335px">
|
||||||
|
<h1 class="float-left" i18n="lightning.nodes-for-isp">Lightning nodes on ISP: {{ isp?.name }} [AS {{isp?.id}}]</h1>
|
||||||
|
|
||||||
|
<div style="min-height: 295px">
|
||||||
|
<table class="table table-borderless">
|
||||||
|
<thead>
|
||||||
|
<th class="alias text-left" i18n="lightning.alias">Alias</th>
|
||||||
|
<th class="timestamp-first text-left" i18n="lightning.first_seen">First seen</th>
|
||||||
|
<th class="timestamp-update text-left" i18n="lightning.last_update">Last update</th>
|
||||||
|
<th class="capacity text-right" i18n="lightning.capacity">Capacity</th>
|
||||||
|
<th class="channels text-right" i18n="lightning.channels">Channels</th>
|
||||||
|
<th class="city text-right" i18n="lightning.city">City</th>
|
||||||
|
</thead>
|
||||||
|
<tbody *ngIf="nodes$ | async as nodes">
|
||||||
|
<tr *ngFor="let node of nodes; let i= index; trackBy: trackByPublicKey">
|
||||||
|
<td class="alias text-left text-truncate">
|
||||||
|
<a [routerLink]="['/lightning/node/' | relativeUrl, node.public_key]">{{ node.alias }}</a>
|
||||||
|
</td>
|
||||||
|
<td class="timestamp-first text-left">
|
||||||
|
<app-timestamp [customFormat]="'yyyy-MM-dd'" [unixTime]="node.first_seen"></app-timestamp>
|
||||||
|
</td>
|
||||||
|
<td class="timestamp-update text-left">
|
||||||
|
<app-timestamp [customFormat]="'yyyy-MM-dd'" [unixTime]="node.updated_at"></app-timestamp>
|
||||||
|
</td>
|
||||||
|
<td class="capacity text-right">
|
||||||
|
<app-amount *ngIf="node.capacity > 100000000; else smallchannel" [satoshis]="node.capacity" [digitsInfo]="'1.2-2'" [noFiat]="true"></app-amount>
|
||||||
|
<ng-template #smallchannel>
|
||||||
|
{{ node.capacity | amountShortener: 1 }}
|
||||||
|
<span class="sats" i18n="shared.sats">sats</span>
|
||||||
|
</ng-template>
|
||||||
|
</td>
|
||||||
|
<td class="channels text-right">
|
||||||
|
{{ node.channels }}
|
||||||
|
</td>
|
||||||
|
<td class="city text-right text-truncate">
|
||||||
|
{{ node?.city?.en ?? '-' }}
|
||||||
|
</td>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
@ -0,0 +1,62 @@
|
|||||||
|
.container-xl {
|
||||||
|
max-width: 1400px;
|
||||||
|
padding-bottom: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sats {
|
||||||
|
color: #ffffff66;
|
||||||
|
font-size: 12px;
|
||||||
|
top: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alias {
|
||||||
|
width: 30%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding-right: 70px;
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
width: 50%;
|
||||||
|
max-width: 150px;
|
||||||
|
padding-right: 0px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.timestamp-first {
|
||||||
|
width: 20%;
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
display: none
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.timestamp-update {
|
||||||
|
width: 16%;
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
display: none
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.capacity {
|
||||||
|
width: 10%;
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
width: 25%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels {
|
||||||
|
width: 10%;
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
width: 25%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.city {
|
||||||
|
max-width: 150px;
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
display: none
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,40 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { map, Observable } from 'rxjs';
|
||||||
|
import { ApiService } from 'src/app/services/api.service';
|
||||||
|
import { SeoService } from 'src/app/services/seo.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-nodes-per-isp',
|
||||||
|
templateUrl: './nodes-per-isp.component.html',
|
||||||
|
styleUrls: ['./nodes-per-isp.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class NodesPerISP implements OnInit {
|
||||||
|
nodes$: Observable<any>;
|
||||||
|
isp: {name: string, id: number};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private apiService: ApiService,
|
||||||
|
private seoService: SeoService,
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.nodes$ = this.apiService.getNodeForISP$(this.route.snapshot.params.isp)
|
||||||
|
.pipe(
|
||||||
|
map(response => {
|
||||||
|
this.isp = {
|
||||||
|
name: response.isp,
|
||||||
|
id: this.route.snapshot.params.isp
|
||||||
|
};
|
||||||
|
this.seoService.setTitle($localize`Lightning nodes on ISP: ${response.isp} [AS${this.route.snapshot.params.isp}]`);
|
||||||
|
return response.nodes;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
trackByPublicKey(index: number, node: any) {
|
||||||
|
return node.public_key;
|
||||||
|
}
|
||||||
|
}
|
@ -1,10 +1,12 @@
|
|||||||
<div [class]="widget === false ? 'full-container' : ''">
|
<div [class]="widget === false ? 'full-container' : ''">
|
||||||
|
|
||||||
<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.hashrate-difficulty">Hashrate & Difficulty</span>
|
<div class="d-flex d-md-block align-items-baseline">
|
||||||
<button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
|
<span i18n="mining.channels-and-capacity">Channels & Capacity</span>
|
||||||
|
<button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
|
||||||
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
|
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(capacityObservable$ | async) as stats">
|
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(capacityObservable$ | 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">
|
||||||
|
@ -228,6 +228,12 @@ export class ApiService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getBlockAudit$(hash: string) : Observable<any> {
|
||||||
|
return this.httpClient.get<any>(
|
||||||
|
this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/blocks/audit/` + hash, { observe: 'response' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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}`);
|
||||||
}
|
}
|
||||||
@ -245,4 +251,19 @@ export class ApiService {
|
|||||||
return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/search', { params });
|
return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/search', { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getNodesPerAs(): Observable<any> {
|
||||||
|
return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/isp');
|
||||||
|
}
|
||||||
|
|
||||||
|
getNodeForCountry$(country: string): Observable<any> {
|
||||||
|
return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/country/' + country);
|
||||||
|
}
|
||||||
|
|
||||||
|
getNodeForISP$(isp: string): Observable<any> {
|
||||||
|
return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/isp/' + isp);
|
||||||
|
}
|
||||||
|
|
||||||
|
getNodesPerCountry(): Observable<any> {
|
||||||
|
return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/countries');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
‎{{ seconds * 1000 | date:'yyyy-MM-dd HH:mm' }}
|
‎{{ seconds * 1000 | date: customFormat ?? 'yyyy-MM-dd HH:mm' }}
|
||||||
<div class="lg-inline">
|
<div class="lg-inline">
|
||||||
<i class="symbol">(<app-time-since [time]="seconds" [fastRender]="true"></app-time-since>)</i>
|
<i class="symbol">(<app-time-since [time]="seconds" [fastRender]="true"></app-time-since>)</i>
|
||||||
</div>
|
</div>
|
||||||
|
@ -9,6 +9,7 @@ import { ChangeDetectionStrategy, Component, Input, OnChanges } from '@angular/c
|
|||||||
export class TimestampComponent implements OnChanges {
|
export class TimestampComponent implements OnChanges {
|
||||||
@Input() unixTime: number;
|
@Input() unixTime: number;
|
||||||
@Input() dateString: string;
|
@Input() dateString: string;
|
||||||
|
@Input() customFormat: string;
|
||||||
|
|
||||||
seconds: number;
|
seconds: number;
|
||||||
|
|
||||||
|
@ -84,3 +84,17 @@ export const download = (href, name) => {
|
|||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function detectWebGL() {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
|
||||||
|
return (gl && gl instanceof WebGLRenderingContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFlagEmoji(countryCode) {
|
||||||
|
const codePoints = countryCode
|
||||||
|
.toUpperCase()
|
||||||
|
.split('')
|
||||||
|
.map(char => 127397 + char.charCodeAt());
|
||||||
|
return String.fromCodePoint(...codePoints);
|
||||||
|
}
|
||||||
|
@ -44,6 +44,7 @@ import { StartComponent } from '../components/start/start.component';
|
|||||||
import { TransactionComponent } from '../components/transaction/transaction.component';
|
import { TransactionComponent } from '../components/transaction/transaction.component';
|
||||||
import { TransactionsListComponent } from '../components/transactions-list/transactions-list.component';
|
import { TransactionsListComponent } from '../components/transactions-list/transactions-list.component';
|
||||||
import { BlockComponent } from '../components/block/block.component';
|
import { BlockComponent } from '../components/block/block.component';
|
||||||
|
import { BlockAuditComponent } from '../components/block-audit/block-audit.component';
|
||||||
import { BlockOverviewGraphComponent } from '../components/block-overview-graph/block-overview-graph.component';
|
import { BlockOverviewGraphComponent } from '../components/block-overview-graph/block-overview-graph.component';
|
||||||
import { BlockOverviewTooltipComponent } from '../components/block-overview-tooltip/block-overview-tooltip.component';
|
import { BlockOverviewTooltipComponent } from '../components/block-overview-tooltip/block-overview-tooltip.component';
|
||||||
import { AddressComponent } from '../components/address/address.component';
|
import { AddressComponent } from '../components/address/address.component';
|
||||||
@ -114,6 +115,7 @@ import { TimestampComponent } from './components/timestamp/timestamp.component';
|
|||||||
StartComponent,
|
StartComponent,
|
||||||
TransactionComponent,
|
TransactionComponent,
|
||||||
BlockComponent,
|
BlockComponent,
|
||||||
|
BlockAuditComponent,
|
||||||
BlockOverviewGraphComponent,
|
BlockOverviewGraphComponent,
|
||||||
BlockOverviewTooltipComponent,
|
BlockOverviewTooltipComponent,
|
||||||
TransactionsListComponent,
|
TransactionsListComponent,
|
||||||
@ -213,6 +215,7 @@ import { TimestampComponent } from './components/timestamp/timestamp.component';
|
|||||||
StartComponent,
|
StartComponent,
|
||||||
TransactionComponent,
|
TransactionComponent,
|
||||||
BlockComponent,
|
BlockComponent,
|
||||||
|
BlockAuditComponent,
|
||||||
BlockOverviewGraphComponent,
|
BlockOverviewGraphComponent,
|
||||||
BlockOverviewTooltipComponent,
|
BlockOverviewTooltipComponent,
|
||||||
TransactionsListComponent,
|
TransactionsListComponent,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user