Compare commits
211 Commits
wiz/featur
...
ops/fix-to
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d1624476f | ||
|
|
8f183945c0 | ||
|
|
5d704b0e43 | ||
|
|
0c6ceaefa2 | ||
|
|
a413c6ebb8 | ||
|
|
2e891eb926 | ||
|
|
21b6c6158a | ||
|
|
eaf7da9acb | ||
|
|
63a22082bc | ||
|
|
6e38caee63 | ||
|
|
cfa2690549 | ||
|
|
c097db2c3c | ||
|
|
2a96d3d213 | ||
|
|
01a5748c02 | ||
|
|
61e8e2fb2a | ||
|
|
d7fe92765c | ||
|
|
bc0c3a1eed | ||
|
|
98e5f78d5f | ||
|
|
6714533ed4 | ||
|
|
94a536af28 | ||
|
|
c3780adab2 | ||
|
|
bdb76b3d4b | ||
|
|
80ee890a9f | ||
|
|
b0a232806b | ||
|
|
9c44bf171c | ||
|
|
5d88dfd00b | ||
|
|
fc14dc95df | ||
|
|
51bdbc5d4a | ||
|
|
b2e6573743 | ||
|
|
d66cc8a213 | ||
|
|
a6663d7869 | ||
|
|
b0fe879503 | ||
|
|
f610699ef4 | ||
|
|
d2ece2993e | ||
|
|
a9248a5f13 | ||
|
|
3e2cf5c058 | ||
|
|
48496abbf4 | ||
|
|
88648da890 | ||
|
|
929491ce3d | ||
|
|
512739ae90 | ||
|
|
69930ef698 | ||
|
|
5854931430 | ||
|
|
f57fa1286c | ||
|
|
edfa0d6074 | ||
|
|
d6e9500bee | ||
|
|
f30883a018 | ||
|
|
12eea0e4cc | ||
|
|
16db740986 | ||
|
|
d1ad9efe64 | ||
|
|
1b3faa1203 | ||
|
|
ce9a4024b3 | ||
|
|
16e79a3662 | ||
|
|
a67c0b166c | ||
|
|
fbf15f05ed | ||
|
|
d1e2ead13e | ||
|
|
9bf04b0af4 | ||
|
|
d6ff8753e2 | ||
|
|
3207e2a285 | ||
|
|
a30808a972 | ||
|
|
a49cb2d611 | ||
|
|
7933a51994 | ||
|
|
fbef2157ec | ||
|
|
b2321495d8 | ||
|
|
7e504e783f | ||
|
|
a14565e288 | ||
|
|
9e6dd65e57 | ||
|
|
898fef19cc | ||
|
|
a9469f7e2b | ||
|
|
412a0ee577 | ||
|
|
d8b3c21a6c | ||
|
|
68f288f69c | ||
|
|
3ba37aaa5a | ||
|
|
b69a7a5031 | ||
|
|
2acaa45e0a | ||
|
|
6341839de4 | ||
|
|
bc132e4337 | ||
|
|
46e63ca6cf | ||
|
|
6ae05c2023 | ||
|
|
65cd708295 | ||
|
|
9656ee92b7 | ||
|
|
9ff8487feb | ||
|
|
fbdf6da314 | ||
|
|
46bce30a64 | ||
|
|
b875bc2552 | ||
|
|
37fd1fb76d | ||
|
|
777a1bb4c1 | ||
|
|
b484852a62 | ||
|
|
12e516c366 | ||
|
|
441a5fa2b4 | ||
|
|
a5b502db4a | ||
|
|
0b2b8fc56c | ||
|
|
d3e24914cd | ||
|
|
1438391515 | ||
|
|
8316c37a0e | ||
|
|
44725d9b29 | ||
|
|
d9e85fdcb6 | ||
|
|
886e7e6638 | ||
|
|
479f635754 | ||
|
|
75cd5a15b7 | ||
|
|
0f91778970 | ||
|
|
eb9c6f2231 | ||
|
|
ad9e989598 | ||
|
|
ffe22399d5 | ||
|
|
300b9e4e05 | ||
|
|
89f7f99720 | ||
|
|
b139423eb9 | ||
|
|
40f2b97075 | ||
|
|
8de89a9f26 | ||
|
|
33776b2b09 | ||
|
|
e0d189b70a | ||
|
|
2e210e7aa4 | ||
|
|
f96b7e7004 | ||
|
|
0508ac1a5d | ||
|
|
ae34225f66 | ||
|
|
c509a69f1d | ||
|
|
6635f2ce8f | ||
|
|
37c28940bf | ||
|
|
0aec1a5d68 | ||
|
|
1244eac03c | ||
|
|
ce940d7c50 | ||
|
|
d0ff377086 | ||
|
|
773e43e0cf | ||
|
|
68f63683f1 | ||
|
|
b5072f823c | ||
|
|
3d75022a6c | ||
|
|
a030fbedb4 | ||
|
|
310bb3cf24 | ||
|
|
fa18796109 | ||
|
|
41bd89521d | ||
|
|
29f36d7f9e | ||
|
|
e7e907d535 | ||
|
|
a705e44c09 | ||
|
|
72ec4c0d7b | ||
|
|
7f405ffcdd | ||
|
|
dfe50d4355 | ||
|
|
181c13025d | ||
|
|
219694db8b | ||
|
|
7b3f5c378d | ||
|
|
621789b20c | ||
|
|
7bbfc7872b | ||
|
|
9698339488 | ||
|
|
8503fd2fc0 | ||
|
|
c839abb479 | ||
|
|
fd70a51489 | ||
|
|
b1b4bdf575 | ||
|
|
b52a8c58ab | ||
|
|
58b60c1f68 | ||
|
|
76b6d2a21b | ||
|
|
93aef078cf | ||
|
|
a5f1ba92e4 | ||
|
|
3e1e47c49a | ||
|
|
c7909a1ca8 | ||
|
|
e0cc58bc6e | ||
|
|
ddb0272d60 | ||
|
|
59f84e82b4 | ||
|
|
3dc37dc34d | ||
|
|
88febf6262 | ||
|
|
29b8660602 | ||
|
|
92da3988da | ||
|
|
8b1fa82c0c | ||
|
|
d2f13eced1 | ||
|
|
d373fd1424 | ||
|
|
d5f5fffd7d | ||
|
|
cc8c52e848 | ||
|
|
c41bfe755e | ||
|
|
8c667a76a7 | ||
|
|
57e033a32c | ||
|
|
b776b935a0 | ||
|
|
3633d36b28 | ||
|
|
b4c6c9c86c | ||
|
|
681d9db900 | ||
|
|
0137d29cd2 | ||
|
|
36412bedd1 | ||
|
|
ccdeb108ee | ||
|
|
f16076b401 | ||
|
|
ac611a4518 | ||
|
|
420ff16c2b | ||
|
|
b4bcd84a53 | ||
|
|
63ebace378 | ||
|
|
75f1b52a2a | ||
|
|
d7f0dc4c05 | ||
|
|
09171c749a | ||
|
|
971f402ced | ||
|
|
3768c28b01 | ||
|
|
dbf60dd4d9 | ||
|
|
683190eaa3 | ||
|
|
93e93d44f4 | ||
|
|
0902015264 | ||
|
|
b11fb44461 | ||
|
|
376484a937 | ||
|
|
fc5fd244d0 | ||
|
|
561d75c694 | ||
|
|
82c0987e7b | ||
|
|
c75138ee50 | ||
|
|
d40545bd52 | ||
|
|
4093cc0cbf | ||
|
|
0c71e11cda | ||
|
|
3edd6f23a5 | ||
|
|
28cf0f71eb | ||
|
|
2fd34cbd91 | ||
|
|
d6158060e7 | ||
|
|
2872d2e299 | ||
|
|
d8a1c0ac1b | ||
|
|
058d15e67f | ||
|
|
16c6f030db | ||
|
|
1be7c953ea | ||
|
|
c6f33310e5 | ||
|
|
68205ddb9d | ||
|
|
1988971290 | ||
|
|
6ef200ae7f | ||
|
|
89b2e11083 |
87
.github/workflows/cypress.yml
vendored
87
.github/workflows/cypress.yml
vendored
@@ -6,86 +6,53 @@ on:
|
||||
jobs:
|
||||
cypress:
|
||||
if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')"
|
||||
runs-on: ${{ matrix.os }}
|
||||
runs-on: "ubuntu-latest"
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
containers: [1, 2, 3, 4, 5]
|
||||
os: ["ubuntu-latest"]
|
||||
browser: [chrome]
|
||||
name: E2E tests on ${{ matrix.browser }} - ${{ matrix.os }}
|
||||
module: ["mempool", "liquid", "bisq"]
|
||||
include:
|
||||
- module: "mempool"
|
||||
spec: |
|
||||
cypress/e2e/mainnet/*.spec.ts
|
||||
cypress/e2e/signet/*.spec.ts
|
||||
cypress/e2e/testnet/*.spec.ts
|
||||
- module: "liquid"
|
||||
spec: |
|
||||
cypress/e2e/liquid/liquid.spec.ts
|
||||
cypress/e2e/liquidtestnet/liquidtestnet.spec.ts
|
||||
- module: "bisq"
|
||||
spec: |
|
||||
cypress/e2e/bisq/bisq.spec.ts
|
||||
|
||||
name: E2E tests for ${{ matrix.module }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
path: ${{ matrix.module }}
|
||||
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 16.15.0
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: ${{ matrix.browser }} browser tests (Mempool)
|
||||
uses: cypress-io/github-action@v4
|
||||
with:
|
||||
tag: ${{ github.event_name }}
|
||||
working-directory: frontend
|
||||
build: npm run config:defaults:mempool
|
||||
start: npm run start:local-staging
|
||||
wait-on: 'http://localhost:4200'
|
||||
wait-on-timeout: 120
|
||||
record: true
|
||||
parallel: true
|
||||
spec: |
|
||||
cypress/e2e/mainnet/*.spec.ts
|
||||
cypress/e2e/signet/*.spec.ts
|
||||
cypress/e2e/testnet/*.spec.ts
|
||||
group: Tests on ${{ matrix.browser }} (Mempool)
|
||||
browser: ${{ matrix.browser }}
|
||||
ci-build-id: '${{ github.sha }}-${{ github.workflow }}-${{ github.event_name }}'
|
||||
env:
|
||||
COMMIT_INFO_MESSAGE: ${{ github.event.pull_request.title }}
|
||||
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }}
|
||||
cache-dependency-path: ${{ matrix.module }}/frontend/package-lock.json
|
||||
|
||||
- name: ${{ matrix.browser }} browser tests (Liquid)
|
||||
- name: Chrome browser tests (${{ matrix.module }})
|
||||
uses: cypress-io/github-action@v4
|
||||
if: always()
|
||||
with:
|
||||
tag: ${{ github.event_name }}
|
||||
working-directory: frontend
|
||||
build: npm run config:defaults:liquid
|
||||
working-directory: ${{ matrix.module }}/frontend
|
||||
build: npm run config:defaults:${{ matrix.module }}
|
||||
start: npm run start:local-staging
|
||||
wait-on: 'http://localhost:4200'
|
||||
wait-on-timeout: 120
|
||||
record: true
|
||||
parallel: true
|
||||
spec: |
|
||||
cypress/e2e/liquid/liquid.spec.ts
|
||||
cypress/e2e/liquidtestnet/liquidtestnet.spec.ts
|
||||
group: Tests on ${{ matrix.browser }} (Liquid)
|
||||
browser: ${{ matrix.browser }}
|
||||
ci-build-id: '${{ github.sha }}-${{ github.workflow }}-${{ github.event_name }}'
|
||||
env:
|
||||
COMMIT_INFO_MESSAGE: ${{ github.event.pull_request.title }}
|
||||
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }}
|
||||
|
||||
- name: ${{ matrix.browser }} browser tests (Bisq)
|
||||
uses: cypress-io/github-action@v4
|
||||
if: always()
|
||||
with:
|
||||
tag: ${{ github.event_name }}
|
||||
working-directory: frontend
|
||||
build: npm run config:defaults:bisq
|
||||
start: npm run start:local-staging
|
||||
wait-on: 'http://localhost:4200'
|
||||
wait-on-timeout: 120
|
||||
record: true
|
||||
parallel: true
|
||||
spec: cypress/e2e/bisq/bisq.spec.ts
|
||||
group: Tests on ${{ matrix.browser }} (Bisq)
|
||||
browser: ${{ matrix.browser }}
|
||||
spec: ${{ matrix.spec }}
|
||||
group: Tests on Chrome (${{ matrix.module }})
|
||||
browser: "chrome"
|
||||
ci-build-id: '${{ github.sha }}-${{ github.workflow }}-${{ github.event_name }}'
|
||||
env:
|
||||
COMMIT_INFO_MESSAGE: ${{ github.event.pull_request.title }}
|
||||
|
||||
@@ -15,10 +15,11 @@
|
||||
"@typescript-eslint/ban-types": 1,
|
||||
"@typescript-eslint/no-empty-function": 1,
|
||||
"@typescript-eslint/no-explicit-any": 1,
|
||||
"@typescript-eslint/no-inferrable-types": 1,
|
||||
"@typescript-eslint/no-inferrable-types": 0,
|
||||
"@typescript-eslint/no-namespace": 1,
|
||||
"@typescript-eslint/no-this-alias": 1,
|
||||
"@typescript-eslint/no-var-requires": 1,
|
||||
"@typescript-eslint/explicit-function-return-type": 1,
|
||||
"no-console": 1,
|
||||
"no-constant-condition": 1,
|
||||
"no-dupe-else-if": 1,
|
||||
@@ -28,6 +29,8 @@
|
||||
"no-useless-catch": 1,
|
||||
"no-var": 1,
|
||||
"prefer-const": 1,
|
||||
"prefer-rest-params": 1
|
||||
"prefer-rest-params": 1,
|
||||
"quotes": [1, "single", { "allowTemplateLiterals": true }],
|
||||
"semi": 1
|
||||
}
|
||||
}
|
||||
|
||||
4
backend/.gitignore
vendored
4
backend/.gitignore
vendored
@@ -1,9 +1,9 @@
|
||||
# See http://help.github.com/ignore-files/ for more about ignoring files.
|
||||
|
||||
# production config and external assets
|
||||
*.json
|
||||
!mempool-config.sample.json
|
||||
|
||||
mempool-config.json
|
||||
pools.json
|
||||
icons.json
|
||||
|
||||
# compiled output
|
||||
|
||||
@@ -66,7 +66,8 @@
|
||||
"MAXMIND": {
|
||||
"ENABLED": false,
|
||||
"GEOLITE2_CITY": "/usr/local/share/GeoIP/GeoLite2-City.mmdb",
|
||||
"GEOLITE2_ASN": "/usr/local/share/GeoIP/GeoLite2-ASN.mmdb"
|
||||
"GEOLITE2_ASN": "/usr/local/share/GeoIP/GeoLite2-ASN.mmdb",
|
||||
"GEOIP2_ISP": "/usr/local/share/GeoIP/GeoIP2-ISP.mmdb"
|
||||
},
|
||||
"BISQ": {
|
||||
"ENABLED": false,
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface AbstractBitcoinApi {
|
||||
$getBlockHash(height: number): Promise<string>;
|
||||
$getBlockHeader(hash: string): Promise<string>;
|
||||
$getBlock(hash: string): Promise<IEsploraApi.Block>;
|
||||
$getRawBlock(hash: string): Promise<string>;
|
||||
$getAddress(address: string): Promise<IEsploraApi.Address>;
|
||||
$getAddressTransactions(address: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]>;
|
||||
$getAddressPrefix(prefix: string): string[];
|
||||
|
||||
@@ -77,7 +77,8 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
}
|
||||
|
||||
$getRawBlock(hash: string): Promise<string> {
|
||||
return this.bitcoindClient.getBlock(hash, 0);
|
||||
return this.bitcoindClient.getBlock(hash, 0)
|
||||
.then((raw: string) => Buffer.from(raw, "hex"));
|
||||
}
|
||||
|
||||
$getBlockHash(height: number): Promise<string> {
|
||||
|
||||
@@ -103,9 +103,10 @@ class BitcoinRoutes {
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/header', this.getBlockHeader)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks/tip/height', this.getBlockTipHeight)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks/tip/hash', this.getBlockTipHash)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/raw', this.getRawBlock)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/txids', this.getTxIdsForBlock)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/txs', this.getBlockTransactions)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/txs/:index', this.getBlockTransactions)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/txids', this.getTxIdsForBlock)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'block-height/:height', this.getBlockHeight)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'address/:address', this.getAddress)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'address/:address/txs', this.getAddressTransactions)
|
||||
@@ -470,6 +471,16 @@ class BitcoinRoutes {
|
||||
}
|
||||
}
|
||||
|
||||
private async getRawBlock(req: Request, res: Response) {
|
||||
try {
|
||||
const result = await bitcoinApi.$getRawBlock(req.params.hash);
|
||||
res.setHeader('content-type', 'application/octet-stream');
|
||||
res.send(result);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async getTxIdsForBlock(req: Request, res: Response) {
|
||||
try {
|
||||
const result = await bitcoinApi.$getTxIdsForBlock(req.params.hash);
|
||||
|
||||
@@ -50,6 +50,11 @@ class ElectrsApi implements AbstractBitcoinApi {
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
$getRawBlock(hash: string): Promise<string> {
|
||||
return axios.get<string>(config.ESPLORA.REST_API_URL + '/block/' + hash + "/raw", this.axiosConfig)
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
$getAddress(address: string): Promise<IEsploraApi.Address> {
|
||||
throw new Error('Method getAddress not implemented.');
|
||||
}
|
||||
|
||||
@@ -578,7 +578,7 @@ class Blocks {
|
||||
|
||||
// Index the response if needed
|
||||
if (Common.blocksSummariesIndexingEnabled() === true) {
|
||||
await BlocksSummariesRepository.$saveSummary(block.height, summary);
|
||||
await BlocksSummariesRepository.$saveSummary({height: block.height, mined: summary});
|
||||
}
|
||||
|
||||
return summary.transactions;
|
||||
|
||||
@@ -4,13 +4,13 @@ import logger from '../logger';
|
||||
import { Common } from './common';
|
||||
|
||||
class DatabaseMigration {
|
||||
private static currentVersion = 31;
|
||||
private static currentVersion = 33;
|
||||
private queryTimeout = 120000;
|
||||
private statisticsAddedIndexed = false;
|
||||
private uniqueLogs: string[] = [];
|
||||
|
||||
private blocksTruncatedMessage = `'blocks' table has been truncated. Re-indexing from scratch.`;
|
||||
private hashratesTruncatedMessage = `'hashrates' table has been truncated. Re-indexing from scratch.`;
|
||||
private blocksTruncatedMessage = `'blocks' table has been truncated.`;
|
||||
private hashratesTruncatedMessage = `'hashrates' table has been truncated.`;
|
||||
|
||||
/**
|
||||
* Avoid printing multiple time the same message
|
||||
@@ -256,7 +256,9 @@ class DatabaseMigration {
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 26 && isBitcoin === true) {
|
||||
this.uniqueLog(logger.notice, `'lightning_stats' table has been truncated. Will re-generate historical data from scratch.`);
|
||||
if (config.LIGHTNING.ENABLED) {
|
||||
this.uniqueLog(logger.notice, `'lightning_stats' table has been truncated.`);
|
||||
}
|
||||
await this.$executeQuery(`TRUNCATE lightning_stats`);
|
||||
await this.$executeQuery('ALTER TABLE `lightning_stats` ADD tor_nodes int(11) NOT NULL DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE `lightning_stats` ADD clearnet_nodes int(11) NOT NULL DEFAULT "0"');
|
||||
@@ -273,6 +275,9 @@ class DatabaseMigration {
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 28 && isBitcoin === true) {
|
||||
if (config.LIGHTNING.ENABLED) {
|
||||
this.uniqueLog(logger.notice, `'lightning_stats' and 'node_stats' tables have been truncated.`);
|
||||
}
|
||||
await this.$executeQuery(`TRUNCATE lightning_stats`);
|
||||
await this.$executeQuery(`TRUNCATE node_stats`);
|
||||
await this.$executeQuery(`ALTER TABLE lightning_stats MODIFY added DATE`);
|
||||
@@ -297,7 +302,14 @@ class DatabaseMigration {
|
||||
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(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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,38 @@ class ChannelsApi {
|
||||
}
|
||||
}
|
||||
|
||||
public async $getAllChannelsGeo(publicKey?: string): Promise<any[]> {
|
||||
try {
|
||||
const params: string[] = [];
|
||||
let query = `SELECT nodes_1.public_key as node1_public_key, nodes_1.alias AS node1_alias,
|
||||
nodes_1.latitude AS node1_latitude, nodes_1.longitude AS node1_longitude,
|
||||
nodes_2.public_key as node2_public_key, nodes_2.alias AS node2_alias,
|
||||
nodes_2.latitude AS node2_latitude, nodes_2.longitude AS node2_longitude,
|
||||
channels.capacity
|
||||
FROM channels
|
||||
JOIN nodes AS nodes_1 on nodes_1.public_key = channels.node1_public_key
|
||||
JOIN nodes AS nodes_2 on nodes_2.public_key = channels.node2_public_key
|
||||
WHERE nodes_1.latitude IS NOT NULL AND nodes_1.longitude IS NOT NULL
|
||||
AND nodes_2.latitude IS NOT NULL AND nodes_2.longitude IS NOT NULL
|
||||
`;
|
||||
|
||||
if (publicKey !== undefined) {
|
||||
query += ' AND (nodes_1.public_key = ? OR nodes_2.public_key = ?)';
|
||||
params.push(publicKey);
|
||||
params.push(publicKey);
|
||||
}
|
||||
|
||||
const [rows]: any = await DB.query(query, params);
|
||||
return rows.map((row) => [
|
||||
row.node1_public_key, row.node1_alias, row.node1_longitude, row.node1_latitude,
|
||||
row.node2_public_key, row.node2_alias, row.node2_longitude, row.node2_latitude,
|
||||
row.capacity]);
|
||||
} catch (e) {
|
||||
logger.err('$getAllChannelsGeo error: ' + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $searchChannelsById(search: string): Promise<any[]> {
|
||||
try {
|
||||
const searchStripped = search.replace('%', '') + '%';
|
||||
|
||||
@@ -11,6 +11,8 @@ class ChannelsRoutes {
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/channels/search/:search', this.$searchChannelsById)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/channels/:short_id', this.$getChannel)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/channels', this.$getChannelsForNode)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/channels-geo', this.$getAllChannelsGeo)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/channels-geo/:publicKey', this.$getAllChannelsGeo)
|
||||
;
|
||||
}
|
||||
|
||||
@@ -93,6 +95,15 @@ class ChannelsRoutes {
|
||||
}
|
||||
}
|
||||
|
||||
private async $getAllChannelsGeo(req: Request, res: Response) {
|
||||
try {
|
||||
const channels = await channelsApi.$getAllChannelsGeo(req.params?.publicKey);
|
||||
res.json(channels);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default new ChannelsRoutes();
|
||||
|
||||
@@ -5,25 +5,29 @@ class NodesApi {
|
||||
public async $getNode(public_key: string): Promise<any> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT nodes.*, geo_names_as.names as as_organization, geo_names_city.names as city,
|
||||
SELECT nodes.*, geo_names_iso.names as iso_code, geo_names_as.names as as_organization, geo_names_city.names as city,
|
||||
geo_names_country.names as country, geo_names_subdivision.names as subdivision,
|
||||
(SELECT Count(*)
|
||||
FROM channels
|
||||
WHERE channels.status < 2 AND ( channels.node1_public_key = ? OR channels.node2_public_key = ? )) AS channel_count,
|
||||
WHERE channels.status = 2 AND ( channels.node1_public_key = ? OR channels.node2_public_key = ? )) AS channel_closed_count,
|
||||
(SELECT Count(*)
|
||||
FROM channels
|
||||
WHERE channels.status = 1 AND ( channels.node1_public_key = ? OR channels.node2_public_key = ? )) AS channel_active_count,
|
||||
(SELECT Sum(capacity)
|
||||
FROM channels
|
||||
WHERE channels.status < 2 AND ( channels.node1_public_key = ? OR channels.node2_public_key = ? )) AS capacity,
|
||||
WHERE channels.status = 1 AND ( channels.node1_public_key = ? OR channels.node2_public_key = ? )) AS capacity,
|
||||
(SELECT Avg(capacity)
|
||||
FROM channels
|
||||
WHERE status < 2 AND ( node1_public_key = ? OR node2_public_key = ? )) AS channels_capacity_avg
|
||||
WHERE status = 1 AND ( node1_public_key = ? OR node2_public_key = ? )) AS channels_capacity_avg
|
||||
FROM nodes
|
||||
LEFT JOIN geo_names geo_names_as on geo_names_as.id = as_number
|
||||
LEFT JOIN geo_names geo_names_city on geo_names_city.id = city_id
|
||||
LEFT JOIN geo_names geo_names_subdivision on geo_names_subdivision.id = subdivision_id
|
||||
LEFT JOIN geo_names geo_names_country on geo_names_country.id = country_id
|
||||
LEFT JOIN geo_names geo_names_iso ON geo_names_iso.id = nodes.country_id AND geo_names_iso.type = 'country_iso_code'
|
||||
WHERE public_key = ?
|
||||
`;
|
||||
const [rows]: any = await DB.query(query, [public_key, public_key, public_key, public_key, public_key, public_key, public_key]);
|
||||
const [rows]: any = await DB.query(query, [public_key, public_key, public_key, public_key, public_key, public_key, public_key, public_key, public_key]);
|
||||
if (rows.length > 0) {
|
||||
rows[0].as_organization = JSON.parse(rows[0].as_organization);
|
||||
rows[0].subdivision = JSON.parse(rows[0].subdivision);
|
||||
@@ -93,6 +97,162 @@ class NodesApi {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getNodesISP(groupBy: string, showTor: boolean) {
|
||||
try {
|
||||
const orderBy = groupBy === 'capacity' ? `CAST(SUM(capacity) as INT)` : `COUNT(DISTINCT nodes.public_key)`;
|
||||
|
||||
// Clearnet
|
||||
let query = `SELECT GROUP_CONCAT(DISTINCT(nodes.as_number)) as ispId, geo_names.names as names,
|
||||
COUNT(DISTINCT nodes.public_key) as nodesCount, CAST(SUM(capacity) as INT) 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 geo_names.names
|
||||
ORDER BY ${orderBy} DESC
|
||||
`;
|
||||
const [nodesCountPerAS]: any = await DB.query(query);
|
||||
|
||||
let total = 0;
|
||||
const nodesPerAs: any[] = [];
|
||||
|
||||
for (const asGroup of nodesCountPerAS) {
|
||||
if (groupBy === 'capacity') {
|
||||
total += asGroup.capacity;
|
||||
} else {
|
||||
total += asGroup.nodesCount;
|
||||
}
|
||||
}
|
||||
|
||||
// Tor
|
||||
if (showTor) {
|
||||
query = `SELECT COUNT(DISTINCT nodes.public_key) as nodesCount, CAST(SUM(capacity) as INT) as capacity
|
||||
FROM nodes
|
||||
JOIN channels ON channels.node1_public_key = nodes.public_key OR channels.node2_public_key = nodes.public_key
|
||||
ORDER BY ${orderBy} DESC
|
||||
`;
|
||||
const [nodesCountTor]: any = await DB.query(query);
|
||||
|
||||
total += groupBy === 'capacity' ? nodesCountTor[0].capacity : nodesCountTor[0].nodesCount;
|
||||
nodesPerAs.push({
|
||||
ispId: null,
|
||||
name: 'Tor',
|
||||
count: nodesCountTor[0].nodesCount,
|
||||
share: Math.floor((groupBy === 'capacity' ? nodesCountTor[0].capacity : nodesCountTor[0].nodesCount) / total * 10000) / 100,
|
||||
capacity: nodesCountTor[0].capacity,
|
||||
});
|
||||
}
|
||||
|
||||
for (const as of nodesCountPerAS) {
|
||||
nodesPerAs.push({
|
||||
ispId: as.ispId,
|
||||
name: JSON.parse(as.names),
|
||||
count: as.nodesCount,
|
||||
share: Math.floor((groupBy === 'capacity' ? as.capacity : as.nodesCount) / 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
|
||||
RIGHT 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 IN (?)
|
||||
ORDER BY capacity DESC
|
||||
`;
|
||||
|
||||
const [rows]: any = await DB.query(query, [ISPId.split(',')]);
|
||||
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();
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import config from '../../config';
|
||||
import { Application, Request, Response } from 'express';
|
||||
import nodesApi from './nodes.api';
|
||||
import DB from '../../database';
|
||||
|
||||
class NodesRoutes {
|
||||
constructor() { }
|
||||
|
||||
public initRoutes(app: Application) {
|
||||
app
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/country/:country', this.$getNodesPerCountry)
|
||||
.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/isp-ranking', this.$getISPRanking)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/isp/:isp', this.$getNodesPerISP)
|
||||
.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', this.$getNode)
|
||||
;
|
||||
@@ -56,6 +62,94 @@ class NodesRoutes {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getISPRanking(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const groupBy = req.query.groupBy as string;
|
||||
const showTor = req.query.showTor as string === 'true' ? true : false;
|
||||
|
||||
if (!['capacity', 'node-count'].includes(groupBy)) {
|
||||
res.status(400).send(`groupBy must be one of 'capacity' or 'node-count'`);
|
||||
return;
|
||||
}
|
||||
|
||||
const nodesPerAs = await nodesApi.$getNodesISP(groupBy, showTor);
|
||||
|
||||
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();
|
||||
|
||||
@@ -6,14 +6,14 @@ class StatisticsApi {
|
||||
public async $getStatistics(interval: string | null = null): Promise<any> {
|
||||
interval = Common.getSqlInterval(interval);
|
||||
|
||||
let query = `SELECT UNIX_TIMESTAMP(added) AS added, channel_count, node_count, total_capacity, tor_nodes, clearnet_nodes, unannounced_nodes
|
||||
let query = `SELECT UNIX_TIMESTAMP(added) AS added, channel_count, total_capacity, tor_nodes, clearnet_nodes, unannounced_nodes
|
||||
FROM lightning_stats`;
|
||||
|
||||
if (interval) {
|
||||
query += ` WHERE added BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`;
|
||||
}
|
||||
|
||||
query += ` ORDER BY id DESC`;
|
||||
query += ` ORDER BY added DESC`;
|
||||
|
||||
try {
|
||||
const [rows]: any = await DB.query(query);
|
||||
@@ -26,8 +26,8 @@ class StatisticsApi {
|
||||
|
||||
public async $getLatestStatistics(): Promise<any> {
|
||||
try {
|
||||
const [rows]: any = await DB.query(`SELECT * FROM lightning_stats ORDER BY id DESC LIMIT 1`);
|
||||
const [rows2]: any = await DB.query(`SELECT * FROM lightning_stats ORDER BY id DESC LIMIT 1 OFFSET 7`);
|
||||
const [rows]: any = await DB.query(`SELECT * FROM lightning_stats ORDER BY added DESC LIMIT 1`);
|
||||
const [rows2]: any = await DB.query(`SELECT * FROM lightning_stats ORDER BY added DESC LIMIT 1 OFFSET 7`);
|
||||
return {
|
||||
latest: rows[0],
|
||||
previous: rows2[0],
|
||||
|
||||
@@ -26,7 +26,8 @@ class MiningRoutes {
|
||||
.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/blocks/predictions/:interval', this.$getHistoricalBlockPrediction)
|
||||
;
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/audit/:hash', this.$getBlockAudit)
|
||||
;
|
||||
}
|
||||
|
||||
private async $getPool(req: Request, res: Response): Promise<void> {
|
||||
@@ -233,6 +234,18 @@ class MiningRoutes {
|
||||
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();
|
||||
|
||||
@@ -17,6 +17,7 @@ import rbfCache from './rbf-cache';
|
||||
import difficultyAdjustment from './difficulty-adjustment';
|
||||
import feeApi from './fee-api';
|
||||
import BlocksAuditsRepository from '../repositories/BlocksAuditsRepository';
|
||||
import BlocksSummariesRepository from '../repositories/BlocksSummariesRepository';
|
||||
|
||||
class WebsocketHandler {
|
||||
private wss: WebSocket.Server | undefined;
|
||||
@@ -442,6 +443,22 @@ class WebsocketHandler {
|
||||
mBlockDeltas = mempoolBlocks.getMempoolBlockDeltas();
|
||||
|
||||
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({
|
||||
height: block.height,
|
||||
template: {
|
||||
id: block.id,
|
||||
transactions: stripped
|
||||
}
|
||||
});
|
||||
|
||||
BlocksAuditsRepository.$saveAudit({
|
||||
time: block.timestamp,
|
||||
height: block.height,
|
||||
|
||||
@@ -102,6 +102,7 @@ interface IConfig {
|
||||
ENABLED: boolean;
|
||||
GEOLITE2_CITY: string;
|
||||
GEOLITE2_ASN: string;
|
||||
GEOIP2_ISP: string;
|
||||
},
|
||||
}
|
||||
|
||||
@@ -206,7 +207,8 @@ const defaults: IConfig = {
|
||||
"MAXMIND": {
|
||||
'ENABLED': false,
|
||||
"GEOLITE2_CITY": "/usr/local/share/GeoIP/GeoLite2-City.mmdb",
|
||||
"GEOLITE2_ASN": "/usr/local/share/GeoIP/GeoLite2-ASN.mmdb"
|
||||
"GEOLITE2_ASN": "/usr/local/share/GeoIP/GeoLite2-ASN.mmdb",
|
||||
"GEOIP2_ISP": "/usr/local/share/GeoIP/GeoIP2-ISP.mmdb"
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import transactionUtils from '../api/transaction-utils';
|
||||
import DB from '../database';
|
||||
import logger from '../logger';
|
||||
import { BlockAudit } from '../mempool.interfaces';
|
||||
@@ -45,6 +46,30 @@ class BlocksAuditRepositories {
|
||||
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();
|
||||
|
||||
@@ -17,14 +17,24 @@ class BlocksSummariesRepository {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public async $saveSummary(height: number, summary: BlockSummary) {
|
||||
public async $saveSummary(params: { height: number, mined?: BlockSummary, template?: BlockSummary}) {
|
||||
const blockId = params.mined?.id ?? params.template?.id;
|
||||
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 (?, ?, ?, ?)`, [
|
||||
params.height, blockId, JSON.stringify(params.mined?.transactions ?? []), JSON.stringify(params.template?.transactions ?? [])
|
||||
]);
|
||||
} else if (params.mined !== undefined) { // Update mined block summary
|
||||
await DB.query(`UPDATE blocks_summaries SET transactions = ? WHERE id = "${params.mined.id}"`, [JSON.stringify(params.mined.transactions)]);
|
||||
} else if (params.template !== undefined) { // Update template block summary
|
||||
await DB.query(`UPDATE blocks_summaries SET template = ? WHERE id = "${params.template.id}"`, [JSON.stringify(params.template?.transactions)]);
|
||||
}
|
||||
} catch (e: any) {
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -44,7 +54,7 @@ class BlocksSummariesRepository {
|
||||
/**
|
||||
* Delete blocks from the database from blockHeight
|
||||
*/
|
||||
public async $deleteBlocksFrom(blockHeight: number) {
|
||||
public async $deleteBlocksFrom(blockHeight: number) {
|
||||
logger.info(`Delete newer blocks summary from height ${blockHeight} from the database`);
|
||||
|
||||
try {
|
||||
|
||||
@@ -38,11 +38,13 @@ class NodeSyncService {
|
||||
await $lookupNodeLocation();
|
||||
}
|
||||
|
||||
await this.$setChannelsInactive();
|
||||
|
||||
const graphChannelsIds: string[] = [];
|
||||
for (const channel of networkGraph.channels) {
|
||||
await this.$saveChannel(channel);
|
||||
graphChannelsIds.push(channel.id);
|
||||
}
|
||||
await this.$setChannelsInactive(graphChannelsIds);
|
||||
|
||||
logger.info(`Channels updated.`);
|
||||
|
||||
await this.$findInactiveNodesAndChannels();
|
||||
@@ -106,7 +108,22 @@ class NodeSyncService {
|
||||
|
||||
try {
|
||||
// @ts-ignore
|
||||
const [channels]: [ILightningApi.Channel[]] = await DB.query(`SELECT channels.id FROM channels WHERE channels.status = 1 AND ((SELECT COUNT(*) FROM nodes WHERE nodes.public_key = channels.node1_public_key) = 0 OR (SELECT COUNT(*) FROM nodes WHERE nodes.public_key = channels.node2_public_key) = 0)`);
|
||||
const [channels]: [ILightningApi.Channel[]] = await DB.query(`
|
||||
SELECT channels.id
|
||||
FROM channels
|
||||
WHERE channels.status = 1
|
||||
AND (
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM nodes
|
||||
WHERE nodes.public_key = channels.node1_public_key
|
||||
) = 0
|
||||
OR (
|
||||
SELECT COUNT(*)
|
||||
FROM nodes
|
||||
WHERE nodes.public_key = channels.node2_public_key
|
||||
) = 0)
|
||||
`);
|
||||
|
||||
for (const channel of channels) {
|
||||
await this.$updateChannelStatus(channel.id, 0);
|
||||
@@ -356,9 +373,16 @@ class NodeSyncService {
|
||||
}
|
||||
}
|
||||
|
||||
private async $setChannelsInactive(): Promise<void> {
|
||||
private async $setChannelsInactive(graphChannelsIds: string[]): Promise<void> {
|
||||
try {
|
||||
await DB.query(`UPDATE channels SET status = 0 WHERE status = 1`);
|
||||
await DB.query(`
|
||||
UPDATE channels
|
||||
SET status = 0
|
||||
WHERE short_id NOT IN (
|
||||
${graphChannelsIds.map(id => `"${id}"`).join(',')}
|
||||
)
|
||||
AND status != 2
|
||||
`);
|
||||
} catch (e) {
|
||||
logger.err('$setChannelsInactive() error: ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
|
||||
@@ -141,7 +141,22 @@ class LightningStatsUpdater {
|
||||
try {
|
||||
logger.info(`Running daily node stats update...`);
|
||||
|
||||
const query = `SELECT nodes.public_key, c1.channels_count_left, c2.channels_count_right, c1.channels_capacity_left, c2.channels_capacity_right FROM nodes LEFT JOIN (SELECT node1_public_key, COUNT(id) AS channels_count_left, SUM(capacity) AS channels_capacity_left FROM channels WHERE channels.status < 2 GROUP BY node1_public_key) c1 ON c1.node1_public_key = nodes.public_key LEFT JOIN (SELECT node2_public_key, COUNT(id) AS channels_count_right, SUM(capacity) AS channels_capacity_right FROM channels WHERE channels.status < 2 GROUP BY node2_public_key) c2 ON c2.node2_public_key = nodes.public_key`;
|
||||
const query = `
|
||||
SELECT nodes.public_key, c1.channels_count_left, c2.channels_count_right, c1.channels_capacity_left,
|
||||
c2.channels_capacity_right
|
||||
FROM nodes
|
||||
LEFT JOIN (
|
||||
SELECT node1_public_key, COUNT(id) AS channels_count_left, SUM(capacity) AS channels_capacity_left
|
||||
FROM channels
|
||||
WHERE channels.status = 1
|
||||
GROUP BY node1_public_key
|
||||
) c1 ON c1.node1_public_key = nodes.public_key
|
||||
LEFT JOIN (
|
||||
SELECT node2_public_key, COUNT(id) AS channels_count_right, SUM(capacity) AS channels_capacity_right
|
||||
FROM channels WHERE channels.status = 1 GROUP BY node2_public_key
|
||||
) c2 ON c2.node2_public_key = nodes.public_key
|
||||
`;
|
||||
|
||||
const [nodes]: any = await DB.query(query);
|
||||
|
||||
for (const node of nodes) {
|
||||
@@ -224,21 +239,38 @@ class LightningStatsUpdater {
|
||||
total_capacity,
|
||||
tor_nodes,
|
||||
clearnet_nodes,
|
||||
unannounced_nodes
|
||||
unannounced_nodes,
|
||||
avg_capacity,
|
||||
avg_fee_rate,
|
||||
avg_base_fee_mtokens,
|
||||
med_capacity,
|
||||
med_fee_rate,
|
||||
med_base_fee_mtokens
|
||||
)
|
||||
VALUES (FROM_UNIXTIME(?), ?, ?, ?, ?, ?, ?)`;
|
||||
VALUES (FROM_UNIXTIME(?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
||||
|
||||
const rowTimestamp = date.getTime() / 1000; // Save timestamp for the row insertion down below
|
||||
|
||||
date.setUTCDate(date.getUTCDate() + 1);
|
||||
|
||||
// Last iteration, save channels stats
|
||||
const channelStats = (date >= currentDate ? await channelsApi.$getChannelsStats() : undefined);
|
||||
|
||||
await DB.query(query, [
|
||||
date.getTime() / 1000,
|
||||
rowTimestamp,
|
||||
channelsCount,
|
||||
nodeCount,
|
||||
totalCapacity,
|
||||
torNodes,
|
||||
clearnetNodes,
|
||||
unannouncedNodes,
|
||||
]);
|
||||
|
||||
date.setUTCDate(date.getUTCDate() + 1);
|
||||
channelStats?.avgCapacity ?? 0,
|
||||
channelStats?.avgFeeRate ?? 0,
|
||||
channelStats?.avgBaseFee ?? 0,
|
||||
channelStats?.medianCapacity ?? 0,
|
||||
channelStats?.medianFeeRate ?? 0,
|
||||
channelStats?.medianBaseFee ?? 0,
|
||||
]);
|
||||
}
|
||||
|
||||
logger.info('Historical stats populated.');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as net from 'net';
|
||||
import maxmind, { CityResponse, AsnResponse } from 'maxmind';
|
||||
import maxmind, { CityResponse, AsnResponse, IspResponse } from 'maxmind';
|
||||
import nodesApi from '../../../api/explorer/nodes.api';
|
||||
import config from '../../../config';
|
||||
import DB from '../../../database';
|
||||
@@ -11,6 +11,7 @@ export async function $lookupNodeLocation(): Promise<void> {
|
||||
const nodes = await nodesApi.$getAllNodes();
|
||||
const lookupCity = await maxmind.open<CityResponse>(config.MAXMIND.GEOLITE2_CITY);
|
||||
const lookupAsn = await maxmind.open<AsnResponse>(config.MAXMIND.GEOLITE2_ASN);
|
||||
const lookupIsp = await maxmind.open<IspResponse>(config.MAXMIND.GEOIP2_ISP);
|
||||
|
||||
for (const node of nodes) {
|
||||
const sockets: string[] = node.sockets.split(',');
|
||||
@@ -20,9 +21,29 @@ export async function $lookupNodeLocation(): Promise<void> {
|
||||
if (hasClearnet && ip !== '127.0.1.1' && ip !== '127.0.0.1') {
|
||||
const city = lookupCity.get(ip);
|
||||
const asn = lookupAsn.get(ip);
|
||||
if (city && asn) {
|
||||
const query = `UPDATE nodes SET as_number = ?, city_id = ?, country_id = ?, subdivision_id = ?, longitude = ?, latitude = ?, accuracy_radius = ? WHERE public_key = ?`;
|
||||
const params = [asn.autonomous_system_number, city.city?.geoname_id, city.country?.geoname_id, city.subdivisions ? city.subdivisions[0].geoname_id : null, city.location?.longitude, city.location?.latitude, city.location?.accuracy_radius, node.public_key];
|
||||
const isp = lookupIsp.get(ip);
|
||||
|
||||
if (city && (asn || isp)) {
|
||||
const query = `UPDATE nodes SET
|
||||
as_number = ?,
|
||||
city_id = ?,
|
||||
country_id = ?,
|
||||
subdivision_id = ?,
|
||||
longitude = ?,
|
||||
latitude = ?,
|
||||
accuracy_radius = ?
|
||||
WHERE public_key = ?`;
|
||||
|
||||
const params = [
|
||||
isp?.autonomous_system_number ?? asn?.autonomous_system_number,
|
||||
city.city?.geoname_id,
|
||||
city.country?.geoname_id,
|
||||
city.subdivisions ? city.subdivisions[0].geoname_id : null,
|
||||
city.location?.longitude,
|
||||
city.location?.latitude,
|
||||
city.location?.accuracy_radius,
|
||||
node.public_key
|
||||
];
|
||||
await DB.query(query, params);
|
||||
|
||||
// Store Continent
|
||||
@@ -39,6 +60,13 @@ export async function $lookupNodeLocation(): Promise<void> {
|
||||
[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
|
||||
if (city.subdivisions && city.subdivisions[0]) {
|
||||
await DB.query(
|
||||
@@ -54,10 +82,10 @@ export async function $lookupNodeLocation(): Promise<void> {
|
||||
}
|
||||
|
||||
// Store AS name
|
||||
if (asn.autonomous_system_organization) {
|
||||
if (isp?.autonomous_system_organization ?? asn?.autonomous_system_organization) {
|
||||
await DB.query(
|
||||
`INSERT IGNORE INTO geo_names (id, type, names) VALUES (?, 'as_organization', ?)`,
|
||||
[asn.autonomous_system_number, JSON.stringify(asn.autonomous_system_organization)]);
|
||||
[isp?.autonomous_system_number ?? asn?.autonomous_system_number, JSON.stringify(isp?.isp ?? asn?.autonomous_system_organization)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,4 +95,4 @@ export async function $lookupNodeLocation(): Promise<void> {
|
||||
} catch (e) {
|
||||
logger.err('$lookupNodeLocation() error: ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ class KrakenApi implements PriceFeed {
|
||||
// CHF weekly price history goes back to timestamp 1575504000 (December 5, 2019)
|
||||
// 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) {
|
||||
const response = await query(this.urlHist.replace('{GRANULARITY}', '10080') + currency);
|
||||
@@ -83,6 +83,10 @@ class KrakenApi implements PriceFeed {
|
||||
}
|
||||
|
||||
for (const time in priceHistory) {
|
||||
if (priceHistory[time].USD === -1) {
|
||||
delete priceHistory[time];
|
||||
continue;
|
||||
}
|
||||
await PricesRepository.$savePrices(parseInt(time, 10), priceHistory[time]);
|
||||
}
|
||||
|
||||
|
||||
3
contributors/oleonardolima.txt
Normal file
3
contributors/oleonardolima.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of July 25, 2022.
|
||||
|
||||
Signed: oleonardolima
|
||||
@@ -14,10 +14,11 @@
|
||||
"@typescript-eslint/ban-types": 1,
|
||||
"@typescript-eslint/no-empty-function": 1,
|
||||
"@typescript-eslint/no-explicit-any": 1,
|
||||
"@typescript-eslint/no-inferrable-types": 1,
|
||||
"@typescript-eslint/no-inferrable-types": 0,
|
||||
"@typescript-eslint/no-namespace": 1,
|
||||
"@typescript-eslint/no-this-alias": 1,
|
||||
"@typescript-eslint/no-var-requires": 1,
|
||||
"@typescript-eslint/explicit-function-return-type": 1,
|
||||
"no-case-declarations": 1,
|
||||
"no-console": 1,
|
||||
"no-constant-condition": 1,
|
||||
@@ -29,6 +30,8 @@
|
||||
"no-useless-catch": 1,
|
||||
"no-var": 1,
|
||||
"prefer-const": 1,
|
||||
"prefer-rest-params": 1
|
||||
"prefer-rest-params": 1,
|
||||
"quotes": [1, "single", { "allowTemplateLiterals": true }],
|
||||
"semi": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,21 +35,23 @@ const getRectangle = ($el) => $el[0].getBoundingClientRect();
|
||||
describe('Mainnet', () => {
|
||||
beforeEach(() => {
|
||||
//cy.intercept('/sockjs-node/info*').as('socket');
|
||||
cy.intercept('/api/block-height/*').as('block-height');
|
||||
cy.intercept('/api/block/*').as('block');
|
||||
cy.intercept('/api/block/*/txs/0').as('block-txs');
|
||||
cy.intercept('/api/tx/*/outspends').as('tx-outspends');
|
||||
cy.intercept('/resources/pools.json').as('pools');
|
||||
// cy.intercept('/api/block-height/*').as('block-height');
|
||||
// cy.intercept('/api/v1/block/*').as('block');
|
||||
// cy.intercept('/api/block/*/txs/0').as('block-txs');
|
||||
// cy.intercept('/api/v1/block/*/summary').as('block-summary');
|
||||
// cy.intercept('/api/v1/outspends/*').as('outspends');
|
||||
// cy.intercept('/api/tx/*/outspends').as('tx-outspends');
|
||||
// cy.intercept('/resources/pools.json').as('pools');
|
||||
|
||||
// Search Auto Complete
|
||||
cy.intercept('/api/address-prefix/1wiz').as('search-1wiz');
|
||||
cy.intercept('/api/address-prefix/1wizS').as('search-1wizS');
|
||||
cy.intercept('/api/address-prefix/1wizSA').as('search-1wizSA');
|
||||
|
||||
Cypress.Commands.add('waitForBlockData', () => {
|
||||
cy.wait('@tx-outspends');
|
||||
cy.wait('@pools');
|
||||
});
|
||||
// Cypress.Commands.add('waitForBlockData', () => {
|
||||
// cy.wait('@tx-outspends');
|
||||
// cy.wait('@pools');
|
||||
// });
|
||||
});
|
||||
|
||||
if (baseModule === 'mempool') {
|
||||
@@ -409,7 +411,7 @@ describe('Mainnet', () => {
|
||||
|
||||
it('loads the tv screen - desktop', () => {
|
||||
cy.viewport('macbook-16');
|
||||
cy.visit('/');
|
||||
cy.visit('/graphs/mempool');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('#btn-tv').click().then(() => {
|
||||
cy.viewport('macbook-16');
|
||||
|
||||
@@ -60,10 +60,10 @@ describe('Signet', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('tv mode', () => {
|
||||
describe.skip('tv mode', () => {
|
||||
it('loads the tv screen - desktop', () => {
|
||||
cy.viewport('macbook-16');
|
||||
cy.visit('/signet');
|
||||
cy.visit('/signet/graphs');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('#btn-tv').click().then(() => {
|
||||
cy.get('.chart-holder').should('be.visible');
|
||||
@@ -73,19 +73,17 @@ describe('Signet', () => {
|
||||
});
|
||||
|
||||
it('loads the tv screen - mobile', () => {
|
||||
cy.visit('/signet');
|
||||
cy.visit('/signet/graphs');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('#btn-tv').click().then(() => {
|
||||
cy.viewport('iphone-8');
|
||||
cy.get('.chart-holder').should('be.visible');
|
||||
cy.get('.tv-only').should('not.exist');
|
||||
//TODO: Remove comment when the bug is fixed
|
||||
//cy.get('#mempool-block-0').should('be.visible');
|
||||
cy.get('#mempool-block-0').should('be.visible');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('loads the api screen', () => {
|
||||
cy.visit('/signet');
|
||||
cy.waitForSkeletonGone();
|
||||
|
||||
@@ -63,18 +63,17 @@ describe('Testnet', () => {
|
||||
describe('tv mode', () => {
|
||||
it('loads the tv screen - desktop', () => {
|
||||
cy.viewport('macbook-16');
|
||||
cy.visit('/testnet');
|
||||
cy.visit('/testnet/graphs');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('#btn-tv').click().then(() => {
|
||||
cy.wait(1000);
|
||||
cy.get('.tv-only').should('not.exist');
|
||||
//TODO: Remove comment when the bug is fixed
|
||||
//cy.get('#mempool-block-0').should('be.visible');
|
||||
cy.get('#mempool-block-0').should('be.visible');
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the tv screen - mobile', () => {
|
||||
cy.visit('/testnet');
|
||||
cy.visit('/testnet/graphs');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('#btn-tv').click().then(() => {
|
||||
cy.viewport('iphone-6');
|
||||
|
||||
32
frontend/package-lock.json
generated
32
frontend/package-lock.json
generated
@@ -34,6 +34,7 @@
|
||||
"clipboard": "^2.0.10",
|
||||
"domino": "^2.1.6",
|
||||
"echarts": "~5.3.2",
|
||||
"echarts-gl": "^2.0.9",
|
||||
"express": "^4.17.1",
|
||||
"lightweight-charts": "~3.8.0",
|
||||
"ngx-echarts": "8.0.1",
|
||||
@@ -6396,6 +6397,11 @@
|
||||
"webpack": ">=4.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/claygl": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/claygl/-/claygl-1.3.0.tgz",
|
||||
"integrity": "sha512-+gGtJjT6SSHD2l2yC3MCubW/sCV40tZuSs5opdtn79vFSGUgp/lH139RNEQ6Jy078/L0aV8odCw8RSrUcMfLaQ=="
|
||||
},
|
||||
"node_modules/clean-stack": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
|
||||
@@ -8107,6 +8113,18 @@
|
||||
"zrender": "5.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/echarts-gl": {
|
||||
"version": "2.0.9",
|
||||
"resolved": "https://registry.npmjs.org/echarts-gl/-/echarts-gl-2.0.9.tgz",
|
||||
"integrity": "sha512-oKeMdkkkpJGWOzjgZUsF41DOh6cMsyrGGXimbjK2l6Xeq/dBQu4ShG2w2Dzrs/1bD27b2pLTGSaUzouY191gzA==",
|
||||
"dependencies": {
|
||||
"claygl": "^1.2.1",
|
||||
"zrender": "^5.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"echarts": "^5.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/echarts/node_modules/tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||
@@ -22520,6 +22538,11 @@
|
||||
"integrity": "sha512-g38K9Cm5WRwlaH6g03B9OEz/0qRizI+2I7n+Gz+L5DxXJAPAiWQvwlYNm1V1jkdpUv95bOe/ASm2vfi/G560jQ==",
|
||||
"requires": {}
|
||||
},
|
||||
"claygl": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/claygl/-/claygl-1.3.0.tgz",
|
||||
"integrity": "sha512-+gGtJjT6SSHD2l2yC3MCubW/sCV40tZuSs5opdtn79vFSGUgp/lH139RNEQ6Jy078/L0aV8odCw8RSrUcMfLaQ=="
|
||||
},
|
||||
"clean-stack": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
|
||||
@@ -23866,6 +23889,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"echarts-gl": {
|
||||
"version": "2.0.9",
|
||||
"resolved": "https://registry.npmjs.org/echarts-gl/-/echarts-gl-2.0.9.tgz",
|
||||
"integrity": "sha512-oKeMdkkkpJGWOzjgZUsF41DOh6cMsyrGGXimbjK2l6Xeq/dBQu4ShG2w2Dzrs/1bD27b2pLTGSaUzouY191gzA==",
|
||||
"requires": {
|
||||
"claygl": "^1.2.1",
|
||||
"zrender": "^5.1.1"
|
||||
}
|
||||
},
|
||||
"ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
"clipboard": "^2.0.10",
|
||||
"domino": "^2.1.6",
|
||||
"echarts": "~5.3.2",
|
||||
"echarts-gl": "^2.0.9",
|
||||
"express": "^4.17.1",
|
||||
"lightweight-charts": "~3.8.0",
|
||||
"ngx-echarts": "8.0.1",
|
||||
|
||||
@@ -85,7 +85,7 @@ if (configContent && configContent.BASE_MODULE == "liquid") {
|
||||
});
|
||||
} else {
|
||||
PROXY_CONFIG.push({
|
||||
context: ['/resources/pools.json', '/resources/assets.json', '/resources/assets.minimal.json'],
|
||||
context: ['/resources/pools.json', '/resources/assets.json', '/resources/assets.minimal.json', '/resources/worldmap.json'],
|
||||
target: "https://mempool.space",
|
||||
secure: false,
|
||||
changeOrigin: true,
|
||||
|
||||
@@ -3,8 +3,12 @@ import { Routes, RouterModule, PreloadAllModules } from '@angular/router';
|
||||
import { StartComponent } from './components/start/start.component';
|
||||
import { TransactionComponent } from './components/transaction/transaction.component';
|
||||
import { BlockComponent } from './components/block/block.component';
|
||||
import { BlockAuditComponent } from './components/block-audit/block-audit.component';
|
||||
import { BlockPreviewComponent } from './components/block/block-preview.component';
|
||||
import { AddressComponent } from './components/address/address.component';
|
||||
import { AddressPreviewComponent } from './components/address/address-preview.component';
|
||||
import { MasterPageComponent } from './components/master-page/master-page.component';
|
||||
import { MasterPagePreviewComponent } from './components/master-page-preview/master-page-preview.component';
|
||||
import { AboutComponent } from './components/about/about.component';
|
||||
import { StatusViewComponent } from './components/status-view/status-view.component';
|
||||
import { TermsOfServiceComponent } from './components/terms-of-service/terms-of-service.component';
|
||||
@@ -22,7 +26,7 @@ import { AssetComponent } from './components/asset/asset.component';
|
||||
import { AssetsNavComponent } from './components/assets/assets-nav/assets-nav.component';
|
||||
|
||||
let routes: Routes = [
|
||||
{
|
||||
{
|
||||
path: 'testnet',
|
||||
children: [
|
||||
{
|
||||
@@ -66,7 +70,10 @@ let routes: Routes = [
|
||||
{
|
||||
path: 'address/:id',
|
||||
children: [],
|
||||
component: AddressComponent
|
||||
component: AddressComponent,
|
||||
data: {
|
||||
ogImage: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'tx',
|
||||
@@ -84,7 +91,19 @@ let routes: Routes = [
|
||||
children: [
|
||||
{
|
||||
path: ':id',
|
||||
component: BlockComponent
|
||||
component: BlockComponent,
|
||||
data: {
|
||||
ogImage: true
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'block-audit',
|
||||
children: [
|
||||
{
|
||||
path: ':id',
|
||||
component: BlockAuditComponent,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -160,7 +179,10 @@ let routes: Routes = [
|
||||
{
|
||||
path: 'address/:id',
|
||||
children: [],
|
||||
component: AddressComponent
|
||||
component: AddressComponent,
|
||||
data: {
|
||||
ogImage: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'tx',
|
||||
@@ -178,7 +200,19 @@ let routes: Routes = [
|
||||
children: [
|
||||
{
|
||||
path: ':id',
|
||||
component: BlockComponent
|
||||
component: BlockComponent,
|
||||
data: {
|
||||
ogImage: true
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'block-audit',
|
||||
children: [
|
||||
{
|
||||
path: ':id',
|
||||
component: BlockAuditComponent,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -251,7 +285,10 @@ let routes: Routes = [
|
||||
{
|
||||
path: 'address/:id',
|
||||
children: [],
|
||||
component: AddressComponent
|
||||
component: AddressComponent,
|
||||
data: {
|
||||
ogImage: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'tx',
|
||||
@@ -269,7 +306,19 @@ let routes: Routes = [
|
||||
children: [
|
||||
{
|
||||
path: ':id',
|
||||
component: BlockComponent
|
||||
component: BlockComponent,
|
||||
data: {
|
||||
ogImage: true
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'block-audit',
|
||||
children: [
|
||||
{
|
||||
path: ':id',
|
||||
component: BlockAuditComponent
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -287,6 +336,39 @@ let routes: Routes = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'preview',
|
||||
component: MasterPagePreviewComponent,
|
||||
children: [
|
||||
{
|
||||
path: 'block/:id',
|
||||
component: BlockPreviewComponent
|
||||
},
|
||||
{
|
||||
path: 'testnet/block/:id',
|
||||
component: BlockPreviewComponent
|
||||
},
|
||||
{
|
||||
path: 'signet/block/:id',
|
||||
component: BlockPreviewComponent
|
||||
},
|
||||
{
|
||||
path: 'address/:id',
|
||||
children: [],
|
||||
component: AddressPreviewComponent
|
||||
},
|
||||
{
|
||||
path: 'testnet/address/:id',
|
||||
children: [],
|
||||
component: AddressPreviewComponent
|
||||
},
|
||||
{
|
||||
path: 'signet/address/:id',
|
||||
children: [],
|
||||
component: AddressPreviewComponent
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'status',
|
||||
component: StatusViewComponent
|
||||
@@ -358,7 +440,10 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') {
|
||||
{
|
||||
path: 'address/:id',
|
||||
children: [],
|
||||
component: AddressComponent
|
||||
component: AddressComponent,
|
||||
data: {
|
||||
ogImage: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'tx',
|
||||
@@ -376,7 +461,10 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') {
|
||||
children: [
|
||||
{
|
||||
path: ':id',
|
||||
component: BlockComponent
|
||||
component: BlockComponent,
|
||||
data: {
|
||||
ogImage: true
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -462,7 +550,10 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') {
|
||||
{
|
||||
path: 'address/:id',
|
||||
children: [],
|
||||
component: AddressComponent
|
||||
component: AddressComponent,
|
||||
data: {
|
||||
ogImage: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'tx',
|
||||
@@ -480,7 +571,10 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') {
|
||||
children: [
|
||||
{
|
||||
path: ':id',
|
||||
component: BlockComponent
|
||||
component: BlockComponent,
|
||||
data: {
|
||||
ogImage: true
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -520,6 +614,30 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'preview',
|
||||
component: MasterPagePreviewComponent,
|
||||
children: [
|
||||
{
|
||||
path: 'block/:id',
|
||||
component: BlockPreviewComponent
|
||||
},
|
||||
{
|
||||
path: 'testnet/block/:id',
|
||||
component: BlockPreviewComponent
|
||||
},
|
||||
{
|
||||
path: 'address/:id',
|
||||
children: [],
|
||||
component: AddressPreviewComponent
|
||||
},
|
||||
{
|
||||
path: 'testnet/address/:id',
|
||||
children: [],
|
||||
component: AddressPreviewComponent
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'status',
|
||||
component: StatusViewComponent
|
||||
@@ -548,4 +666,3 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') {
|
||||
})],
|
||||
})
|
||||
export class AppRoutingModule { }
|
||||
|
||||
|
||||
@@ -6,9 +6,11 @@ import { AppRoutingModule } from './app-routing.module';
|
||||
import { AppComponent } from './components/app/app.component';
|
||||
import { ElectrsApiService } from './services/electrs-api.service';
|
||||
import { StateService } from './services/state.service';
|
||||
import { EnterpriseService } from './services/enterprise.service';
|
||||
import { WebsocketService } from './services/websocket.service';
|
||||
import { AudioService } from './services/audio.service';
|
||||
import { SeoService } from './services/seo.service';
|
||||
import { OpenGraphService } from './services/opengraph.service';
|
||||
import { SharedModule } from './shared/shared.module';
|
||||
import { StorageService } from './services/storage.service';
|
||||
import { HttpCacheInterceptor } from './services/http-cache.interceptor';
|
||||
@@ -35,7 +37,9 @@ import { CapAddressPipe } from './shared/pipes/cap-address-pipe/cap-address-pipe
|
||||
WebsocketService,
|
||||
AudioService,
|
||||
SeoService,
|
||||
OpenGraphService,
|
||||
StorageService,
|
||||
EnterpriseService,
|
||||
LanguageService,
|
||||
ShortenStringPipe,
|
||||
FiatShortenerPipe,
|
||||
|
||||
@@ -5,64 +5,157 @@ const P2SH_P2WSH_COST = 35 * 4; // the WU cost for the non-witness part of P2SH
|
||||
|
||||
export function calcSegwitFeeGains(tx: Transaction) {
|
||||
// calculated in weight units
|
||||
let realizedGains = 0;
|
||||
let potentialBech32Gains = 0;
|
||||
let potentialP2shGains = 0;
|
||||
let realizedSegwitGains = 0;
|
||||
let potentialSegwitGains = 0;
|
||||
let potentialP2shSegwitGains = 0;
|
||||
let potentialTaprootGains = 0;
|
||||
let realizedTaprootGains = 0;
|
||||
|
||||
for (const vin of tx.vin) {
|
||||
if (!vin.prevout) { continue; }
|
||||
|
||||
const isP2pkh = vin.prevout.scriptpubkey_type === 'p2pkh';
|
||||
const isP2sh = vin.prevout.scriptpubkey_type === 'p2sh';
|
||||
const isP2wsh = vin.prevout.scriptpubkey_type === 'v0_p2wsh';
|
||||
const isP2wpkh = vin.prevout.scriptpubkey_type === 'v0_p2wpkh';
|
||||
const isP2tr = vin.prevout.scriptpubkey_type === 'v1_p2tr';
|
||||
const isP2pk = vin.prevout.scriptpubkey_type === 'p2pk';
|
||||
// const isBareMultisig = vin.prevout.scriptpubkey_type === 'multisig'; // type will be unknown, so use the multisig helper from the address labels
|
||||
const isBareMultisig = !!parseMultisigScript(vin.prevout.scriptpubkey_asm);
|
||||
const isP2pkh = vin.prevout.scriptpubkey_type === 'p2pkh';
|
||||
const isP2sh = vin.prevout.scriptpubkey_type === 'p2sh';
|
||||
const isP2wsh = vin.prevout.scriptpubkey_type === 'v0_p2wsh';
|
||||
const isP2wpkh = vin.prevout.scriptpubkey_type === 'v0_p2wpkh';
|
||||
const isP2tr = vin.prevout.scriptpubkey_type === 'v1_p2tr';
|
||||
|
||||
const op = vin.scriptsig ? vin.scriptsig_asm.split(' ')[0] : null;
|
||||
const isP2sh2Wpkh = isP2sh && !!vin.witness && op === 'OP_PUSHBYTES_22';
|
||||
const isP2sh2Wsh = isP2sh && !!vin.witness && op === 'OP_PUSHBYTES_34';
|
||||
const isP2shP2Wpkh = isP2sh && !!vin.witness && op === 'OP_PUSHBYTES_22';
|
||||
const isP2shP2Wsh = isP2sh && !!vin.witness && op === 'OP_PUSHBYTES_34';
|
||||
|
||||
switch (true) {
|
||||
// Native Segwit - P2WPKH/P2WSH (Bech32)
|
||||
// Native Segwit - P2WPKH/P2WSH/P2TR
|
||||
case isP2wpkh:
|
||||
case isP2wsh:
|
||||
case isP2tr:
|
||||
// maximal gains: the scriptSig is moved entirely to the witness part
|
||||
realizedGains += witnessSize(vin) * 3;
|
||||
// if taproot is used savings are 42 WU higher because it produces smaller signatures and doesn't require a pubkey in the witness
|
||||
// this number is explained above `realizedTaprootGains += 42;`
|
||||
realizedSegwitGains += (witnessSize(vin) + (isP2tr ? 42 : 0)) * 3;
|
||||
// XXX P2WSH output creation is more expensive, should we take this into consideration?
|
||||
break;
|
||||
|
||||
// Backward compatible Segwit - P2SH-P2WPKH
|
||||
case isP2sh2Wpkh:
|
||||
// the scriptSig is moved to the witness, but we have extra 21 extra non-witness bytes (48 WU)
|
||||
realizedGains += witnessSize(vin) * 3 - P2SH_P2WPKH_COST;
|
||||
potentialBech32Gains += P2SH_P2WPKH_COST;
|
||||
case isP2shP2Wpkh:
|
||||
// the scriptSig is moved to the witness, but we have extra 21 extra non-witness bytes (84 WU)
|
||||
realizedSegwitGains += witnessSize(vin) * 3 - P2SH_P2WPKH_COST;
|
||||
potentialSegwitGains += P2SH_P2WPKH_COST;
|
||||
break;
|
||||
|
||||
// Backward compatible Segwit - P2SH-P2WSH
|
||||
case isP2sh2Wsh:
|
||||
// the scriptSig is moved to the witness, but we have extra 35 extra non-witness bytes
|
||||
realizedGains += witnessSize(vin) * 3 - P2SH_P2WSH_COST;
|
||||
potentialBech32Gains += P2SH_P2WSH_COST;
|
||||
case isP2shP2Wsh:
|
||||
// the scriptSig is moved to the witness, but we have extra 35 extra non-witness bytes (140 WU)
|
||||
realizedSegwitGains += witnessSize(vin) * 3 - P2SH_P2WSH_COST;
|
||||
potentialSegwitGains += P2SH_P2WSH_COST;
|
||||
break;
|
||||
|
||||
// Non-segwit P2PKH/P2SH
|
||||
// Non-segwit P2PKH/P2SH/P2PK/bare multisig
|
||||
case isP2pkh:
|
||||
case isP2sh:
|
||||
const fullGains = scriptSigSize(vin) * 3;
|
||||
potentialBech32Gains += fullGains;
|
||||
potentialP2shGains += fullGains - (isP2pkh ? P2SH_P2WPKH_COST : P2SH_P2WSH_COST);
|
||||
case isP2pk:
|
||||
case isBareMultisig: {
|
||||
let fullGains = scriptSigSize(vin) * 3;
|
||||
if (isBareMultisig) {
|
||||
// a _bare_ multisig has the keys in the output script, but P2SH and P2WSH require them to be in the scriptSig/scriptWitness
|
||||
fullGains -= vin.prevout.scriptpubkey.length / 2;
|
||||
}
|
||||
potentialSegwitGains += fullGains;
|
||||
potentialP2shSegwitGains += fullGains - (isP2pkh ? P2SH_P2WPKH_COST : P2SH_P2WSH_COST);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: should we also consider P2PK and pay-to-bare-script (non-p2sh-wrapped) as upgradable to P2WPKH and P2WSH?
|
||||
if (isP2tr) {
|
||||
if (vin.witness.length === 1) {
|
||||
// key path spend
|
||||
// we don't know if this was a multisig or single sig (the goal of taproot :)),
|
||||
// so calculate fee savings by comparing to the cheapest single sig input type: P2WPKH and say "saved at least ...%"
|
||||
// the witness size of P2WPKH is 1 (stack size) + 1 (size) + 72 (low s signature) + 1 (size) + 33 (pubkey) = 108 WU
|
||||
// the witness size of key path P2TR is 1 (stack size) + 1 (size) + 64 (signature) = 66 WU
|
||||
realizedTaprootGains += 42;
|
||||
} else {
|
||||
// script path spend
|
||||
// complex scripts with multiple spending paths can often be made around 2x to 3x smaller with the Taproot script tree
|
||||
// because only the hash of the alternative spending path has the be in the witness data, not the entire script,
|
||||
// but only assumptions can be made because the scripts themselves are unknown (again, the goal of taproot :))
|
||||
// TODO maybe add some complex scripts that are specified somewhere, so that size is known, such as lightning scripts
|
||||
}
|
||||
} else {
|
||||
const script = isP2shP2Wsh || isP2wsh ? vin.inner_witnessscript_asm : vin.inner_redeemscript_asm;
|
||||
let replacementSize: number;
|
||||
if (
|
||||
// single sig
|
||||
isP2pk || isP2pkh || isP2wpkh || isP2shP2Wpkh ||
|
||||
// multisig
|
||||
isBareMultisig || parseMultisigScript(script)
|
||||
) {
|
||||
// the scriptSig and scriptWitness can all be replaced by a 66 witness WU with taproot
|
||||
replacementSize = 66;
|
||||
} else if (script) {
|
||||
// not single sig, not multisig: the complex scripts
|
||||
// rough calculations on spending paths
|
||||
// every OP_IF and OP_NOTIF indicates an _extra_ spending path, so add 1
|
||||
const spendingPaths = script.split(' ').filter(op => /^(OP_IF|OP_NOTIF)$/g.test(op)).length + 1;
|
||||
// now assume the script could have been split in ${spendingPaths} equal tapleaves
|
||||
replacementSize = script.length / 2 / spendingPaths +
|
||||
// but account for the leaf and branch hashes and internal key in the control block
|
||||
32 * Math.log2((spendingPaths - 1) || 1) + 33;
|
||||
}
|
||||
potentialTaprootGains += witnessSize(vin) + scriptSigSize(vin) * 4 - replacementSize;
|
||||
}
|
||||
}
|
||||
|
||||
// returned as percentage of the total tx weight
|
||||
return { realizedGains: realizedGains / (tx.weight + realizedGains) // percent of the pre-segwit tx size
|
||||
, potentialBech32Gains: potentialBech32Gains / tx.weight
|
||||
, potentialP2shGains: potentialP2shGains / tx.weight
|
||||
};
|
||||
return {
|
||||
realizedSegwitGains: realizedSegwitGains / (tx.weight + realizedSegwitGains), // percent of the pre-segwit tx size
|
||||
potentialSegwitGains: potentialSegwitGains / tx.weight,
|
||||
potentialP2shSegwitGains: potentialP2shSegwitGains / tx.weight,
|
||||
potentialTaprootGains: potentialTaprootGains / tx.weight,
|
||||
realizedTaprootGains: realizedTaprootGains / (tx.weight + realizedTaprootGains)
|
||||
};
|
||||
}
|
||||
|
||||
/** extracts m and n from a multisig script (asm), returns nothing if it is not a multisig script */
|
||||
export function parseMultisigScript(script: string): void | { m: number, n: number } {
|
||||
if (!script) {
|
||||
return;
|
||||
}
|
||||
const ops = script.split(' ');
|
||||
if (ops.length < 3 || ops.pop() !== 'OP_CHECKMULTISIG') {
|
||||
return;
|
||||
}
|
||||
const opN = ops.pop();
|
||||
if (!opN.startsWith('OP_PUSHNUM_')) {
|
||||
return;
|
||||
}
|
||||
const n = parseInt(opN.match(/[0-9]+/)[0], 10);
|
||||
if (ops.length < n * 2 + 1) {
|
||||
return;
|
||||
}
|
||||
// pop n public keys
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (!/^0((2|3)\w{64}|4\w{128})$/.test(ops.pop())) {
|
||||
return;
|
||||
}
|
||||
if (!/^OP_PUSHBYTES_(33|65)$/.test(ops.pop())) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const opM = ops.pop();
|
||||
if (!opM.startsWith('OP_PUSHNUM_')) {
|
||||
return;
|
||||
}
|
||||
const m = parseInt(opM.match(/[0-9]+/)[0], 10);
|
||||
|
||||
if (ops.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return { m, n };
|
||||
}
|
||||
|
||||
// https://github.com/shesek/move-decimal-point
|
||||
@@ -101,12 +194,12 @@ export function moveDec(num: number, n: number) {
|
||||
return neg + (int || '0') + (frac.length ? '.' + frac : '');
|
||||
}
|
||||
|
||||
function zeros(n) {
|
||||
function zeros(n: number) {
|
||||
return new Array(n + 1).join('0');
|
||||
}
|
||||
|
||||
// Formats a number for display. Treats the number as a string to avoid rounding errors.
|
||||
export const formatNumber = (s, precision = null) => {
|
||||
export const formatNumber = (s: number | string, precision: number | null = null) => {
|
||||
let [ whole, dec ] = s.toString().split('.');
|
||||
|
||||
// divide numbers into groups of three separated with a thin space (U+202F, "NARROW NO-BREAK SPACE"),
|
||||
@@ -128,31 +221,31 @@ export const formatNumber = (s, precision = null) => {
|
||||
};
|
||||
|
||||
// Utilities for segwitFeeGains
|
||||
const witnessSize = (vin: Vin) => vin.witness.reduce((S, w) => S + (w.length / 2), 0);
|
||||
const witnessSize = (vin: Vin) => vin.witness ? vin.witness.reduce((S, w) => S + (w.length / 2), 0) : 0;
|
||||
const scriptSigSize = (vin: Vin) => vin.scriptsig ? vin.scriptsig.length / 2 : 0;
|
||||
|
||||
// Power of ten wrapper
|
||||
export function selectPowerOfTen(val: number) {
|
||||
export function selectPowerOfTen(val: number): { divider: number, unit: string } {
|
||||
const powerOfTen = {
|
||||
exa: Math.pow(10, 18),
|
||||
peta: Math.pow(10, 15),
|
||||
terra: Math.pow(10, 12),
|
||||
tera: Math.pow(10, 12),
|
||||
giga: Math.pow(10, 9),
|
||||
mega: Math.pow(10, 6),
|
||||
kilo: Math.pow(10, 3),
|
||||
};
|
||||
|
||||
let selectedPowerOfTen;
|
||||
let selectedPowerOfTen: { divider: number, unit: string };
|
||||
if (val < powerOfTen.kilo) {
|
||||
selectedPowerOfTen = { divider: 1, unit: '' }; // no scaling
|
||||
} else if (val < powerOfTen.mega) {
|
||||
selectedPowerOfTen = { divider: powerOfTen.kilo, unit: 'k' };
|
||||
} else if (val < powerOfTen.giga) {
|
||||
selectedPowerOfTen = { divider: powerOfTen.mega, unit: 'M' };
|
||||
} else if (val < powerOfTen.terra) {
|
||||
} else if (val < powerOfTen.tera) {
|
||||
selectedPowerOfTen = { divider: powerOfTen.giga, unit: 'G' };
|
||||
} else if (val < powerOfTen.peta) {
|
||||
selectedPowerOfTen = { divider: powerOfTen.terra, unit: 'T' };
|
||||
selectedPowerOfTen = { divider: powerOfTen.tera, unit: 'T' };
|
||||
} else if (val < powerOfTen.exa) {
|
||||
selectedPowerOfTen = { divider: powerOfTen.peta, unit: 'P' };
|
||||
} else {
|
||||
@@ -160,4 +253,4 @@ export function selectPowerOfTen(val: number) {
|
||||
}
|
||||
|
||||
return selectedPowerOfTen;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component, ChangeDetectionStrategy, Input, OnChanges } from '@angular/core';
|
||||
import { Vin, Vout } from '../../interfaces/electrs.interface';
|
||||
import { StateService } from 'src/app/services/state.service';
|
||||
import { parseMultisigScript } from 'src/app/bitcoin.utils';
|
||||
|
||||
@Component({
|
||||
selector: 'app-address-labels',
|
||||
@@ -98,41 +99,11 @@ export class AddressLabelsComponent implements OnChanges {
|
||||
}
|
||||
|
||||
detectMultisig(script: string) {
|
||||
if (!script) {
|
||||
return;
|
||||
}
|
||||
const ops = script.split(' ');
|
||||
if (ops.length < 3 || ops.pop() !== 'OP_CHECKMULTISIG') {
|
||||
return;
|
||||
}
|
||||
const opN = ops.pop();
|
||||
if (!opN.startsWith('OP_PUSHNUM_')) {
|
||||
return;
|
||||
}
|
||||
const n = parseInt(opN.match(/[0-9]+/)[0], 10);
|
||||
if (ops.length < n * 2 + 1) {
|
||||
return;
|
||||
}
|
||||
// pop n public keys
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (!/^0((2|3)\w{64}|4\w{128})$/.test(ops.pop())) {
|
||||
return;
|
||||
}
|
||||
if (!/^OP_PUSHBYTES_(33|65)$/.test(ops.pop())) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const opM = ops.pop();
|
||||
if (!opM.startsWith('OP_PUSHNUM_')) {
|
||||
return;
|
||||
}
|
||||
const m = parseInt(opM.match(/[0-9]+/)[0], 10);
|
||||
const ms = parseMultisigScript(script);
|
||||
|
||||
if (ops.length) {
|
||||
return;
|
||||
if (ms) {
|
||||
this.label = $localize`:@@address-label.multisig:Multisig ${ms.m}:multisigM: of ${ms.n}:multisigN:`;
|
||||
}
|
||||
|
||||
this.label = $localize`:@@address-label.multisig:Multisig ${m}:multisigM: of ${n}:multisigN:`;
|
||||
}
|
||||
|
||||
handleVout() {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<div class="box preview-box" *ngIf="address && !error">
|
||||
<div class="row">
|
||||
<div class="col-md">
|
||||
<div class="title-address">
|
||||
<h1 i18n="shared.address">Address</h1>
|
||||
</div>
|
||||
<a [routerLink]="['/address/' | relativeUrl, addressString]" class="address-link" >
|
||||
<span class="truncated-address">{{addressString.slice(0,-4)}}</span><span class="last-four">{{addressString.slice(-4)}}</span>
|
||||
</a>
|
||||
<table class="table table-borderless table-striped">
|
||||
<tbody>
|
||||
<tr *ngIf="addressInfo && addressInfo.unconfidential">
|
||||
<td i18n="address.unconfidential">Unconfidential</td>
|
||||
<td><a [routerLink]="['/address/' | relativeUrl, addressInfo.unconfidential]">
|
||||
<span class="d-inline d-lg-none">{{ addressInfo.unconfidential | shortenString : 14 }}</span>
|
||||
<span class="d-none d-lg-inline">{{ addressInfo.unconfidential }}</span>
|
||||
</a> <app-clipboard [text]="addressInfo.unconfidential"></app-clipboard></td>
|
||||
</tr>
|
||||
<ng-template [ngIf]="!address.electrum">
|
||||
<tr>
|
||||
<td i18n="address.total-received">Total received</td>
|
||||
<td *ngIf="address.chain_stats.funded_txo_sum !== undefined; else confidentialTd"><app-amount [satoshis]="received" [noFiat]="true"></app-amount></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td i18n="address.total-sent">Total sent</td>
|
||||
<td *ngIf="address.chain_stats.spent_txo_sum !== undefined; else confidentialTd"><app-amount [satoshis]="sent" [noFiat]="true"></app-amount></td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
<tr>
|
||||
<td i18n="address.balance">Balance</td>
|
||||
<td *ngIf="address.chain_stats.funded_txo_sum !== undefined; else confidentialTd"><app-amount [satoshis]="received - sent" [noFiat]="true"></app-amount></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td i18n="address.transactions">Transactions</td>
|
||||
<td>{{ txCount | number }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td i18n="address.unspent_txos">Unspent TXOs</td>
|
||||
<td>{{ totalUnspent | number }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="w-100 d-block d-md-none"></div>
|
||||
<div class="col-md qrcode-col">
|
||||
<div class="qr-wrapper">
|
||||
<app-qrcode [data]="address.address" [size]="370"></app-qrcode>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-template #confidentialTd>
|
||||
<td i18n="shared.confidential">Confidential</td>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,46 @@
|
||||
h1 {
|
||||
font-size: 42px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.qr-wrapper {
|
||||
background-color: #FFF;
|
||||
padding: 10px;
|
||||
padding-bottom: 5px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.qrcode-col {
|
||||
width: 420px;
|
||||
min-width: 420px;
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.table {
|
||||
font-size: 24px;
|
||||
|
||||
::ng-deep .symbol {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.address-link {
|
||||
font-size: 20px;
|
||||
margin-bottom: 0.5em;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
.truncated-address {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
max-width: calc(505px - 4em);
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.last-four {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
116
frontend/src/app/components/address/address-preview.component.ts
Normal file
116
frontend/src/app/components/address/address-preview.component.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { ActivatedRoute, ParamMap } from '@angular/router';
|
||||
import { ElectrsApiService } from '../../services/electrs-api.service';
|
||||
import { switchMap, filter, catchError, map, tap } from 'rxjs/operators';
|
||||
import { Address, Transaction } from '../../interfaces/electrs.interface';
|
||||
import { StateService } from 'src/app/services/state.service';
|
||||
import { OpenGraphService } from 'src/app/services/opengraph.service';
|
||||
import { AudioService } from 'src/app/services/audio.service';
|
||||
import { ApiService } from 'src/app/services/api.service';
|
||||
import { of, merge, Subscription, Observable } from 'rxjs';
|
||||
import { SeoService } from 'src/app/services/seo.service';
|
||||
import { AddressInformation } from 'src/app/interfaces/node-api.interface';
|
||||
|
||||
@Component({
|
||||
selector: 'app-address-preview',
|
||||
templateUrl: './address-preview.component.html',
|
||||
styleUrls: ['./address-preview.component.scss']
|
||||
})
|
||||
export class AddressPreviewComponent implements OnInit, OnDestroy {
|
||||
network = '';
|
||||
|
||||
address: Address;
|
||||
addressString: string;
|
||||
isLoadingAddress = true;
|
||||
error: any;
|
||||
mainSubscription: Subscription;
|
||||
addressLoadingStatus$: Observable<number>;
|
||||
addressInfo: null | AddressInformation = null;
|
||||
|
||||
totalConfirmedTxCount = 0;
|
||||
loadedConfirmedTxCount = 0;
|
||||
txCount = 0;
|
||||
received = 0;
|
||||
sent = 0;
|
||||
totalUnspent = 0;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private electrsApiService: ElectrsApiService,
|
||||
private stateService: StateService,
|
||||
private apiService: ApiService,
|
||||
private seoService: SeoService,
|
||||
private openGraphService: OpenGraphService,
|
||||
) { }
|
||||
|
||||
ngOnInit() {
|
||||
this.openGraphService.setPreviewLoading();
|
||||
this.stateService.networkChanged$.subscribe((network) => this.network = network);
|
||||
|
||||
this.addressLoadingStatus$ = this.route.paramMap
|
||||
.pipe(
|
||||
switchMap(() => this.stateService.loadingIndicators$),
|
||||
map((indicators) => indicators['address-' + this.addressString] !== undefined ? indicators['address-' + this.addressString] : 0)
|
||||
);
|
||||
|
||||
this.mainSubscription = this.route.paramMap
|
||||
.pipe(
|
||||
switchMap((params: ParamMap) => {
|
||||
this.error = undefined;
|
||||
this.isLoadingAddress = true;
|
||||
this.loadedConfirmedTxCount = 0;
|
||||
this.address = null;
|
||||
this.addressInfo = null;
|
||||
this.addressString = params.get('id') || '';
|
||||
if (/^[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100}$/.test(this.addressString)) {
|
||||
this.addressString = this.addressString.toLowerCase();
|
||||
}
|
||||
this.seoService.setTitle($localize`:@@address.component.browser-title:Address: ${this.addressString}:INTERPOLATION:`);
|
||||
|
||||
return this.electrsApiService.getAddress$(this.addressString)
|
||||
.pipe(
|
||||
catchError((err) => {
|
||||
this.isLoadingAddress = false;
|
||||
this.error = err;
|
||||
console.log(err);
|
||||
return of(null);
|
||||
})
|
||||
);
|
||||
})
|
||||
)
|
||||
.pipe(
|
||||
filter((address) => !!address),
|
||||
tap((address: Address) => {
|
||||
if ((this.stateService.network === 'liquid' || this.stateService.network === 'liquidtestnet') && /^([m-zA-HJ-NP-Z1-9]{26,35}|[a-z]{2,5}1[ac-hj-np-z02-9]{8,100}|[a-km-zA-HJ-NP-Z1-9]{80})$/.test(address.address)) {
|
||||
this.apiService.validateAddress$(address.address)
|
||||
.subscribe((addressInfo) => {
|
||||
this.addressInfo = addressInfo;
|
||||
});
|
||||
}
|
||||
this.address = address;
|
||||
this.updateChainStats();
|
||||
this.isLoadingAddress = false;
|
||||
this.openGraphService.setPreviewReady();
|
||||
})
|
||||
)
|
||||
.subscribe(() => {},
|
||||
(error) => {
|
||||
console.log(error);
|
||||
this.error = error;
|
||||
this.isLoadingAddress = false;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
updateChainStats() {
|
||||
this.received = this.address.chain_stats.funded_txo_sum + this.address.mempool_stats.funded_txo_sum;
|
||||
this.sent = this.address.chain_stats.spent_txo_sum + this.address.mempool_stats.spent_txo_sum;
|
||||
this.txCount = this.address.chain_stats.tx_count + this.address.mempool_stats.tx_count;
|
||||
this.totalConfirmedTxCount = this.address.chain_stats.tx_count;
|
||||
this.totalUnspent = this.address.chain_stats.funded_txo_count - this.address.chain_stats.spent_txo_count;
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.mainSubscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Location } from '@angular/common';
|
||||
import { Component, HostListener, OnInit, Inject, LOCALE_ID, HostBinding } from '@angular/core';
|
||||
import { Router, NavigationEnd } from '@angular/router';
|
||||
import { StateService } from 'src/app/services/state.service';
|
||||
import { OpenGraphService } from 'src/app/services/opengraph.service';
|
||||
import { NgbTooltipConfig } from '@ng-bootstrap/ng-bootstrap';
|
||||
|
||||
@Component({
|
||||
@@ -16,6 +17,7 @@ export class AppComponent implements OnInit {
|
||||
constructor(
|
||||
public router: Router,
|
||||
private stateService: StateService,
|
||||
private openGraphService: OpenGraphService,
|
||||
private location: Location,
|
||||
tooltipConfig: NgbTooltipConfig,
|
||||
@Inject(LOCALE_ID) private locale: string,
|
||||
|
||||
@@ -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="card-header mb-0 mb-md-4">
|
||||
<span i18n="mining.block-fee-rates">Block Fee Rates</span>
|
||||
<button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
|
||||
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
|
||||
</button>
|
||||
<div class="d-flex d-md-block align-items-baseline">
|
||||
<span i18n="mining.block-fee-rates">Block Fee Rates</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>
|
||||
|
||||
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats">
|
||||
<div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
|
||||
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 144">
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
<div class="full-container">
|
||||
<div class="card-header mb-0 mb-md-4">
|
||||
<span i18n="mining.block-fees">Block Fees</span>
|
||||
<button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
|
||||
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
|
||||
</button>
|
||||
<div class="d-flex d-md-block align-items-baseline">
|
||||
<span i18n="mining.block-fees">Block Fees</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>
|
||||
|
||||
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats">
|
||||
<div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
|
||||
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 4320">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Component, ElementRef, ViewChild, HostListener, Input, Output, EventEmitter, NgZone, AfterViewInit, OnDestroy } from '@angular/core';
|
||||
import { MempoolBlockDelta, TransactionStripped } from 'src/app/interfaces/websocket.interface';
|
||||
import { WebsocketService } from 'src/app/services/websocket.service';
|
||||
import { TransactionStripped } from 'src/app/interfaces/websocket.interface';
|
||||
import { FastVertexArray } from './fast-vertex-array';
|
||||
import BlockScene from './block-scene';
|
||||
import TxSprite from './tx-sprite';
|
||||
|
||||
@@ -25,6 +25,7 @@ export default class TxView implements TransactionStripped {
|
||||
vsize: number;
|
||||
value: number;
|
||||
feerate: number;
|
||||
status?: 'found' | 'missing' | 'added';
|
||||
|
||||
initialised: boolean;
|
||||
vertexArray: FastVertexArray;
|
||||
@@ -43,6 +44,7 @@ export default class TxView implements TransactionStripped {
|
||||
this.vsize = tx.vsize;
|
||||
this.value = tx.value;
|
||||
this.feerate = tx.fee / tx.vsize;
|
||||
this.status = tx.status;
|
||||
this.initialised = false;
|
||||
this.vertexArray = vertexArray;
|
||||
|
||||
@@ -140,6 +142,14 @@ export default class TxView implements TransactionStripped {
|
||||
}
|
||||
|
||||
getColor(): Color {
|
||||
// Block audit
|
||||
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;
|
||||
return hexToColor(mempoolFeeColors[feeLevelIndex] || mempoolFeeColors[mempoolFeeColors.length - 1]);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
<div class="full-container">
|
||||
<div class="card-header mb-0 mb-md-4">
|
||||
<span i18n="mining.block-prediction-accuracy">Block Prediction Accuracy</span>
|
||||
<button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
|
||||
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
|
||||
</button>
|
||||
<div class="d-flex d-md-block align-items-baseline">
|
||||
<span i18n="mining.block-prediction-accuracy">Block Prediction Accuracy</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>
|
||||
|
||||
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats">
|
||||
<div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
|
||||
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 144">
|
||||
|
||||
@@ -98,7 +98,21 @@ export class BlockPredictionGraphComponent implements OnInit {
|
||||
}
|
||||
|
||||
prepareChartOptions(data) {
|
||||
let title: object;
|
||||
if (data.length === 0) {
|
||||
title = {
|
||||
textStyle: {
|
||||
color: 'grey',
|
||||
fontSize: 15
|
||||
},
|
||||
text: $localize`No data to display yet. Try again later.`,
|
||||
left: 'center',
|
||||
top: 'center'
|
||||
};
|
||||
}
|
||||
|
||||
this.chartOptions = {
|
||||
title: data.length === 0 ? title : undefined,
|
||||
animation: false,
|
||||
grid: {
|
||||
top: 30,
|
||||
@@ -133,17 +147,16 @@ export class BlockPredictionGraphComponent implements OnInit {
|
||||
return tooltip;
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
xAxis: data.length === 0 ? undefined : {
|
||||
name: formatterXAxisLabel(this.locale, this.timespan),
|
||||
nameLocation: 'middle',
|
||||
nameTextStyle: {
|
||||
padding: [10, 0, 0, 0],
|
||||
},
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
axisLine: { onZero: true },
|
||||
axisLabel: {
|
||||
formatter: val => formatterXAxisTimeCategory(this.locale, this.timespan, parseInt(val, 10)),
|
||||
formatter: val => formatterXAxisTimeCategory(this.locale, this.timespan, parseInt(val, 10) * 1000),
|
||||
align: 'center',
|
||||
fontSize: 11,
|
||||
lineHeight: 12,
|
||||
@@ -152,7 +165,7 @@ export class BlockPredictionGraphComponent implements OnInit {
|
||||
},
|
||||
data: data.map(prediction => prediction[0])
|
||||
},
|
||||
yAxis: [
|
||||
yAxis: data.length === 0 ? undefined : [
|
||||
{
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
@@ -170,7 +183,7 @@ export class BlockPredictionGraphComponent implements OnInit {
|
||||
},
|
||||
},
|
||||
],
|
||||
series: [
|
||||
series: data.length === 0 ? undefined : [
|
||||
{
|
||||
zlevel: 0,
|
||||
name: $localize`Match rate`,
|
||||
@@ -183,9 +196,10 @@ export class BlockPredictionGraphComponent implements OnInit {
|
||||
})),
|
||||
type: 'bar',
|
||||
barWidth: '90%',
|
||||
barMaxWidth: 50,
|
||||
},
|
||||
],
|
||||
dataZoom: [{
|
||||
dataZoom: data.length === 0 ? undefined : [{
|
||||
type: 'inside',
|
||||
realtime: true,
|
||||
zoomLock: true,
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
<div class="full-container">
|
||||
|
||||
<div class="card-header mb-0 mb-md-4">
|
||||
<span i18n="mining.block-rewards">Block Rewards</span>
|
||||
<button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
|
||||
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
|
||||
</button>
|
||||
<div class="d-flex d-md-block align-items-baseline">
|
||||
<span i18n="mining.block-rewards">Block Rewards</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>
|
||||
|
||||
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats">
|
||||
<div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
|
||||
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 4320">
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<div class="full-container">
|
||||
|
||||
<div class="card-header mb-0 mb-md-4">
|
||||
<span i18n="mining.block-sizes-weights">Block Sizes and Weights</span>
|
||||
<button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
|
||||
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
|
||||
</button>
|
||||
<div class="d-flex d-md-block align-items-baseline">
|
||||
<span i18n="mining.block-sizes-weights">Block Sizes and Weights</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>
|
||||
|
||||
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(blockSizesWeightsObservable$ | async) as stats">
|
||||
<div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<div class="box preview-box" *ngIf="!error">
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<h1 class="block-title">
|
||||
<ng-template [ngIf]="blockHeight === 0"><ng-container i18n="@@2303359202781425764">Genesis</ng-container>
|
||||
<span class="next-previous-blocks">
|
||||
<a [routerLink]="['/block/' | relativeUrl, blockHash]">{{ blockHeight }}</a>
|
||||
</span>
|
||||
</ng-template>
|
||||
<ng-template [ngIf]="blockHeight" i18n="shared.block-title">Block <ng-container *ngTemplateOutlet="blockTemplateContent"></ng-container></ng-template>
|
||||
<ng-template #blockTemplateContent>
|
||||
<span class="next-previous-blocks">
|
||||
<a [routerLink]="['/block/' | relativeUrl, blockHash]">{{ blockHeight }}</a>
|
||||
</span>
|
||||
</ng-template>
|
||||
</h1>
|
||||
<table class="table table-borderless table-striped">
|
||||
<tbody>
|
||||
<!-- <tr>
|
||||
<td class="td-width" i18n="block.hash">Hash</td>
|
||||
<td>‎<a [routerLink]="['/block/' | relativeUrl, block?.id]" title="{{ block?.id }}">{{ block?.id | shortenString : 13 }}</a></td>
|
||||
</tr> -->
|
||||
<tr>
|
||||
<td i18n="block.timestamp">Timestamp</td>
|
||||
<td>
|
||||
{{ block?.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td i18n="block.weight">Weight</td>
|
||||
<td [innerHTML]="'‎' + (block?.weight | wuBytes: 2)"></td>
|
||||
</tr>
|
||||
<ng-template [ngIf]="webGlEnabled">
|
||||
<tr *ngIf="block?.extras?.medianFee != undefined">
|
||||
<td class="td-width" i18n="block.median-fee">Median fee</td>
|
||||
<td>~{{ block?.extras?.medianFee | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span></td>
|
||||
</tr>
|
||||
<ng-template [ngIf]="fees !== undefined">
|
||||
<tr>
|
||||
<td i18n="block.total-fees|Total fees in a block">Total fees</td>
|
||||
<td *ngIf="network !== 'liquid' && network !== 'liquidtestnet'; else liquidTotalFees">
|
||||
<app-amount [satoshis]="block?.extras.totalFees" digitsInfo="1.2-3" [noFiat]="true"></app-amount>
|
||||
</td>
|
||||
<ng-template #liquidTotalFees>
|
||||
<td>
|
||||
<app-amount [satoshis]="fees * 100000000" digitsInfo="1.2-2" [noFiat]="true"></app-amount>
|
||||
</td>
|
||||
</ng-template>
|
||||
</tr>
|
||||
</ng-template>
|
||||
<tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'">
|
||||
<td i18n="block.miner">Miner</td>
|
||||
<td *ngIf="stateService.env.MINING_DASHBOARD">
|
||||
<a [attr.data-cy]="'block-details-miner-badge'" placement="bottom" [routerLink]="['/mining/pool' | relativeUrl, block?.extras.pool.slug]" class="badge"
|
||||
[class]="block?.extras.pool.name === 'Unknown' ? 'badge-secondary' : 'badge-primary'">
|
||||
{{ block?.extras.pool.name }}
|
||||
</a>
|
||||
</td>
|
||||
<td *ngIf="!stateService.env.MINING_DASHBOARD && stateService.env.BASE_MODULE === 'mempool'">
|
||||
<span [attr.data-cy]="'block-details-miner-badge'" placement="bottom" class="badge"
|
||||
[class]="block?.extras.pool.name === 'Unknown' ? 'badge-secondary' : 'badge-primary'">
|
||||
{{ block?.extras.pool.name }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-sm chart-container" *ngIf="webGlEnabled">
|
||||
<app-block-overview-graph
|
||||
#blockGraph
|
||||
[isLoading]="false"
|
||||
[resolution]="75"
|
||||
[blockLimit]="stateService.blockVSize"
|
||||
[orientation]="'top'"
|
||||
[flip]="false"
|
||||
(txClickEvent)="onTxClick($event)"
|
||||
></app-block-overview-graph>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
.block-title {
|
||||
margin-bottom: 0.75em;
|
||||
font-size: 42px;
|
||||
|
||||
::ng-deep .next-previous-blocks {
|
||||
font-size: 42px;
|
||||
}
|
||||
}
|
||||
|
||||
.table {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
width: 420px;
|
||||
min-width: 420px;
|
||||
}
|
||||
|
||||
::ng-deep .symbol {
|
||||
font-size: 18px;
|
||||
}
|
||||
11
frontend/src/app/components/block/block-preview.component.ts
Normal file
11
frontend/src/app/components/block/block-preview.component.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { BlockComponent } from './block.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-block-preview',
|
||||
templateUrl: './block-preview.component.html',
|
||||
styleUrls: ['./block.component.scss', './block-preview.component.scss']
|
||||
})
|
||||
export class BlockPreviewComponent extends BlockComponent {
|
||||
|
||||
}
|
||||
@@ -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 { ApiService } from 'src/app/services/api.service';
|
||||
import { BlockOverviewGraphComponent } from 'src/app/components/block-overview-graph/block-overview-graph.component';
|
||||
import { detectWebGL } from 'src/app/shared/graphs.utils';
|
||||
|
||||
@Component({
|
||||
selector: 'app-block',
|
||||
@@ -390,10 +391,4 @@ export class BlockComponent implements OnInit, OnDestroy {
|
||||
const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.txid}`);
|
||||
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,17 @@
|
||||
<button class="btn btn-primary w-100" id="dropdownBasic1" ngbDropdownToggle i18n="lightning">Lightning</button>
|
||||
<div ngbDropdownMenu aria-labelledby="dropdownBasic1">
|
||||
<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]"
|
||||
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-country">Lightning nodes per country</a>
|
||||
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/nodes-map' | relativeUrl]"
|
||||
i18n="lightning.lightning.nodes-heatmap">Lightning nodes world map</a>
|
||||
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/nodes-channels-map' | relativeUrl]"
|
||||
i18n="lightning.nodes-channels-world-map">Lightning nodes channels world map</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,10 +23,12 @@
|
||||
</div>
|
||||
|
||||
<div class="card-header mb-0 mb-md-4" [style]="widget ? 'display:none' : ''">
|
||||
<span i18n="mining.hashrate-difficulty">Hashrate & Difficulty</span>
|
||||
<button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
|
||||
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
|
||||
</button>
|
||||
<div class="d-flex d-md-block align-items-baseline">
|
||||
<span i18n="mining.hashrate-difficulty">Hashrate & Difficulty</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>
|
||||
|
||||
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(hashrateObservable$ | async) as stats">
|
||||
<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) {
|
||||
diffFixed.push({
|
||||
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;
|
||||
}
|
||||
@@ -231,11 +231,15 @@ export class HashrateChartComponent implements OnInit {
|
||||
} else if (tick.seriesIndex === 1) { // Difficulty
|
||||
let difficultyPowerOfTen = hashratePowerOfTen;
|
||||
let difficulty = tick.data[1];
|
||||
if (this.isMobile()) {
|
||||
difficultyPowerOfTen = selectPowerOfTen(tick.data[1]);
|
||||
difficulty = Math.round(tick.data[1] / difficultyPowerOfTen.divider);
|
||||
if (difficulty === null) {
|
||||
difficultyString = `${tick.marker} ${tick.seriesName}: No data<br>`;
|
||||
} else {
|
||||
if (this.isMobile()) {
|
||||
difficultyPowerOfTen = selectPowerOfTen(tick.data[1]);
|
||||
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
|
||||
let hashrate = tick.data[1];
|
||||
if (this.isMobile()) {
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
<div class="full-container">
|
||||
|
||||
<div class="card-header mb-0 mb-md-4">
|
||||
<span i18n="mining.pools-dominance">Pools Dominance</span>
|
||||
<button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
|
||||
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
|
||||
</button>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(hashrateObservable$ | async) as stats">
|
||||
<div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
|
||||
<label ngbButtonLabel class="btn-primary btn-sm" *ngIf="stats.blockCount >= 25920">
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<ng-container *ngIf="{ val: network$ | async } as network">
|
||||
<div class="preview-wrapper">
|
||||
<router-outlet></router-outlet>
|
||||
|
||||
<footer>
|
||||
<span class="footer-brand" style="position: relative;">
|
||||
<img *ngIf="!officialMempoolSpace" src="/resources/mempool-logo.png" height="35" width="140" class="logo">
|
||||
<app-svg-images *ngIf="officialMempoolSpace" name="officialMempoolSpace" style="width: 140px; height: 35px" width="500" height="126" viewBox="0 0 500 126"></app-svg-images>
|
||||
</span>
|
||||
|
||||
<div [ngSwitch]="network.val">
|
||||
<span *ngSwitchCase="'signet'" class="network signet"><img src="/resources/signet-logo.png" style="width: 30px;" class="signet mr-1" alt="logo"> Signet</span>
|
||||
<span *ngSwitchCase="'testnet'" class="network testnet"><img src="/resources/testnet-logo.png" style="width: 30px;" class="mr-1" alt="testnet logo"> Testnet</span>
|
||||
<span *ngSwitchCase="'bisq'" class="network bisq"><img src="/resources/bisq-logo.png" style="width: 30px;" class="mr-1" alt="bisq logo"> Bisq</span>
|
||||
<span *ngSwitchCase="'liquid'" class="network liquid"><img src="/resources/liquid-logo.png" style="width: 30px;" class="mr-1" alt="liquid mainnet logo"> Liquid</span>
|
||||
<span *ngSwitchCase="'liquidtestnet'" class="network liquidtestnet"><img src="/resources/liquidtestnet-logo.png" style="width: 30px;" class="mr-1" alt="liquid testnet logo"> Liquid Testnet</span>
|
||||
<span *ngSwitchDefault class="network mainnet"><img src="/resources/bitcoin-logo.png" style="width: 30px;" class="mainnet mr-1" alt="bitcoin logo"> Mainnet</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</ng-container>
|
||||
@@ -0,0 +1,36 @@
|
||||
.preview-wrapper {
|
||||
position: relative;
|
||||
display: block;
|
||||
margin: auto;
|
||||
max-width: 1024px;
|
||||
max-height: 512px;
|
||||
padding-bottom: 64px;
|
||||
|
||||
footer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
min-height: 64px;
|
||||
padding: 0rem 2rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: #11131f;
|
||||
text-align: start;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.footer-brand {
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.network {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { StateService } from '../../services/state.service';
|
||||
import { Observable, merge, of } from 'rxjs';
|
||||
import { LanguageService } from 'src/app/services/language.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-master-page-preview',
|
||||
templateUrl: './master-page-preview.component.html',
|
||||
styleUrls: ['./master-page-preview.component.scss'],
|
||||
})
|
||||
export class MasterPagePreviewComponent implements OnInit {
|
||||
network$: Observable<string>;
|
||||
officialMempoolSpace = this.stateService.env.OFFICIAL_MEMPOOL_SPACE;
|
||||
urlLanguage: string;
|
||||
|
||||
constructor(
|
||||
public stateService: StateService,
|
||||
private languageService: LanguageService,
|
||||
) { }
|
||||
|
||||
ngOnInit() {
|
||||
this.network$ = merge(of(''), this.stateService.networkChanged$);
|
||||
this.urlLanguage = this.languageService.getLanguageForUrl();
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
<ng-container *ngIf="{ val: network$ | async } as network">
|
||||
<header>
|
||||
<nav class="navbar navbar-expand-md navbar-dark bg-dark">
|
||||
<a class="navbar-brand" [routerLink]="['/' | relativeUrl]" style="position: relative;">
|
||||
<a class="navbar-brand" [routerLink]="['/' | relativeUrl]">
|
||||
<ng-template [ngIf]="subdomain">
|
||||
<div class="subdomain_container">
|
||||
<img [src]="'/api/v1/enterprise/images/' + subdomain + '/logo'" class="subdomain_logo">
|
||||
</div>
|
||||
</ng-template>
|
||||
<ng-container *ngIf="{ val: connectionState$ | async } as connectionState">
|
||||
<img *ngIf="!officialMempoolSpace" src="/resources/mempool-logo.png" height="35" width="140" class="logo" [ngStyle]="{'opacity': connectionState.val === 2 ? 1 : 0.5 }" alt="The Mempool Open Source Project logo">
|
||||
<app-svg-images *ngIf="officialMempoolSpace" name="officialMempoolSpace" style="width: 140px; height: 35px" width="500" height="126" viewBox="0 0 500 126"></app-svg-images>
|
||||
<img *ngIf="!officialMempoolSpace" src="/resources/mempool-logo.png" class="mempool-logo" [ngStyle]="{'opacity': connectionState.val === 2 ? 1 : 0.5 }" alt="The Mempool Open Source Project logo">
|
||||
<app-svg-images *ngIf="officialMempoolSpace" name="officialMempoolSpace" viewBox="0 0 500 126"></app-svg-images>
|
||||
<div class="connection-badge">
|
||||
<div class="badge badge-warning" *ngIf="connectionState.val === 0" i18n="master-page.offline">Offline</div>
|
||||
<div class="badge badge-warning" *ngIf="connectionState.val === 1" i18n="master-page.reconnecting">Reconnecting...</div>
|
||||
@@ -44,9 +49,6 @@
|
||||
<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>
|
||||
</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">
|
||||
<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>
|
||||
|
||||
@@ -15,6 +15,11 @@ li.nav-item {
|
||||
margin: auto 10px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
@media (max-width: 992px) {
|
||||
margin: auto 7px;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
@@ -68,10 +73,6 @@ li.nav-item {
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
.dropdown {
|
||||
.dropdown-toggle {
|
||||
@@ -80,10 +81,9 @@ li.nav-item {
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 576px) {
|
||||
.navbar-brand {
|
||||
width: 140px;
|
||||
}
|
||||
.navbar-brand {
|
||||
position: relative;
|
||||
height: 65px;
|
||||
}
|
||||
|
||||
nav {
|
||||
@@ -92,9 +92,8 @@ nav {
|
||||
|
||||
.connection-badge {
|
||||
position: absolute;
|
||||
top: 13px;
|
||||
left: 0px;
|
||||
width: 140px;
|
||||
top: 22px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@@ -145,3 +144,33 @@ nav {
|
||||
.navbar-dark .navbar-nav .nav-link {
|
||||
color: #f1f1f1;
|
||||
}
|
||||
|
||||
.subdomain_logo {
|
||||
max-height: 45px;
|
||||
max-width: 140px;
|
||||
margin: auto;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.subdomain_container {
|
||||
width: 140px;
|
||||
margin-right: 15px;
|
||||
text-align: center;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.logo-holder {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
flex-direction: row;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.mempool-logo, app-svg-images {
|
||||
align-self: center;
|
||||
width: 140px;
|
||||
height: 35px;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Component, Inject, OnInit } from '@angular/core';
|
||||
import { Env, StateService } from '../../services/state.service';
|
||||
import { Observable, merge, of } from 'rxjs';
|
||||
import { LanguageService } from 'src/app/services/language.service';
|
||||
import { EnterpriseService } from 'src/app/services/enterprise.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-master-page',
|
||||
@@ -16,10 +17,12 @@ export class MasterPageComponent implements OnInit {
|
||||
isMobile = window.innerWidth <= 767.98;
|
||||
officialMempoolSpace = this.stateService.env.OFFICIAL_MEMPOOL_SPACE;
|
||||
urlLanguage: string;
|
||||
subdomain = '';
|
||||
|
||||
constructor(
|
||||
public stateService: StateService,
|
||||
private languageService: LanguageService,
|
||||
private enterpriseService: EnterpriseService,
|
||||
) { }
|
||||
|
||||
ngOnInit() {
|
||||
@@ -27,6 +30,7 @@ export class MasterPageComponent implements OnInit {
|
||||
this.connectionState$ = this.stateService.connectionState$;
|
||||
this.network$ = merge(of(''), this.stateService.networkChanged$);
|
||||
this.urlLanguage = this.languageService.getLanguageForUrl();
|
||||
this.subdomain = this.enterpriseService.getSubdomain();
|
||||
}
|
||||
|
||||
collapse(): void {
|
||||
|
||||
@@ -32,10 +32,12 @@
|
||||
</div>
|
||||
|
||||
<div class="card-header" *ngIf="!widget">
|
||||
<span i18n="mining.pools">Pools Ranking</span>
|
||||
<button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
|
||||
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
|
||||
</button>
|
||||
<div class="d-flex d-md-block align-items-baseline">
|
||||
<span i18n="mining.pools">Pools Ranking</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>
|
||||
<form [formGroup]="radioGroupForm" class="formRadioGroup"
|
||||
*ngIf="!widget && (miningStatsObservable$ | async) as stats">
|
||||
<div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
|
||||
|
||||
@@ -27,15 +27,7 @@ $width: 500;
|
||||
$height: 500;
|
||||
|
||||
// Create the explosion...
|
||||
$box-shadow: ();
|
||||
$box-shadow2: ();
|
||||
@for $i from 0 through $particles {
|
||||
$box-shadow: $box-shadow,
|
||||
random($width) - math.div($width, 1.2) + px
|
||||
random($height) - math.div($height, 1.2) + px
|
||||
hsl(random(360), 100%, 50%);
|
||||
$box-shadow2: $box-shadow2, 0 0 #fff
|
||||
}
|
||||
|
||||
@mixin keyframes ($animationName) {
|
||||
@-webkit-keyframes #{$animationName} {
|
||||
@content;
|
||||
@@ -103,7 +95,6 @@ body {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
box-shadow: $box-shadow2;
|
||||
@include animation((1s bang ease-out infinite backwards, 1s gravity ease-in infinite backwards, 5s position linear infinite backwards));
|
||||
}
|
||||
|
||||
@@ -112,9 +103,9 @@ body {
|
||||
@include animation-duration((1.25s, 1.25s, 6.25s));
|
||||
}
|
||||
|
||||
@include keyframes(bang) {
|
||||
to {
|
||||
box-shadow:$box-shadow;
|
||||
@keyframes bang{
|
||||
to{
|
||||
box-shadow:-314.6666666667px -362.6666666667px red,-51.6666666667px 32.3333333333px #ff3700,-354.6666666667px -264.6666666667px #7b00ff,-319.6666666667px -73.6666666667px #00f7ff,-135.6666666667px -154.6666666667px #00ff48,57.3333333333px -402.6666666667px #0d00ff,-126.6666666667px -121.6666666667px #00ff7b,-335.6666666667px -5.6666666667px #00fff2,-291.6666666667px -.6666666667px #4f0,-126.6666666667px -187.6666666667px #7f0,-413.6666666667px -224.6666666667px #00ffbf,-283.6666666667px -391.6666666667px #00ff3c,-340.6666666667px -345.6666666667px #02f,-168.6666666667px -179.6666666667px #eaff00,7.3333333333px -153.6666666667px #26ff00,-175.6666666667px -234.6666666667px #8400ff,-324.6666666667px -254.6666666667px #0048ff,-335.6666666667px -9.6666666667px #00ff59,-304.6666666667px -8.6666666667px #001eff,-331.6666666667px -44.6666666667px #3f0,.3333333333px -49.6666666667px #0fc,-370.6666666667px -60.6666666667px #0015ff,29.3333333333px -13.6666666667px #8cff00,-168.6666666667px -281.6666666667px #f80,-48.6666666667px -61.6666666667px #f0b,33.3333333333px -113.6666666667px #ff00e1,-193.6666666667px -196.6666666667px #ff7b00,-14.6666666667px -24.6666666667px #ff0037,-149.6666666667px -273.6666666667px #0fa,-19.6666666667px -63.6666666667px #ff0004,13.3333333333px -227.6666666667px #7f0,-265.6666666667px -43.6666666667px #ff4800,-121.6666666667px -95.6666666667px #bfff00,-241.6666666667px -90.6666666667px #6200ff,-307.6666666667px -231.6666666667px #ff0062,78.3333333333px -128.6666666667px #ffbf00,27.3333333333px 44.3333333333px #95ff00,-81.6666666667px 6.3333333333px #ffc800,-343.6666666667px -247.6666666667px #2f0,-225.6666666667px -250.6666666667px #08f,-9.6666666667px -243.6666666667px #ff1a00,83.3333333333px -409.6666666667px #04f,-380.6666666667px -331.6666666667px #84ff00,-103.6666666667px -51.6666666667px #f02,-174.6666666667px -169.6666666667px #ffc800,20.3333333333px -191.6666666667px #ff0059,-40.6666666667px -55.6666666667px #0400ff,-199.6666666667px -66.6666666667px #ffd500,-358.6666666667px -5.6666666667px #0051ff,-84.6666666667px -289.6666666667px #f7ff00,-193.6666666667px -184.6666666667px #80f
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,14 +3,22 @@
|
||||
<div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<i class="fa fa-area-chart"></i>
|
||||
<span i18n="statistics.memory-by-vBytes">Mempool by vBytes (sat/vByte)</span>
|
||||
<button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart('mempool')">
|
||||
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
|
||||
</button>
|
||||
<div class="d-flex d-md-block align-items-baseline">
|
||||
<span i18n="statistics.memory-by-vBytes">Mempool by vBytes (sat/vByte)</span>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="radioGroupForm" class="formRadioGroup"
|
||||
[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" id="btn-tv">
|
||||
<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">
|
||||
<label ngbButtonLabel class="btn-primary btn-sm">
|
||||
<input ngbButton type="radio" [value]="'2h'" [routerLink]="['/graphs' | relativeUrl]" fragment="2h"> 2H
|
||||
@@ -84,11 +92,12 @@
|
||||
<div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<i class="fa fa-area-chart"></i>
|
||||
<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')">
|
||||
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
|
||||
</button>
|
||||
<div class="d-flex d-md-block align-items-baseline">
|
||||
<span i18n="statistics.transaction-vbytes-per-second">Transaction vBytes per second (vB/s)</span>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
|
||||
@@ -210,4 +210,8 @@ export class StatisticsComponent implements OnInit {
|
||||
this.incomingGraph.onSaveChart(this.timespan);
|
||||
}
|
||||
}
|
||||
|
||||
isMobile() {
|
||||
return (window.innerWidth <= 767.98);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
<span *ngIf="segwitGains.realizedGains && !segwitGains.potentialBech32Gains; else segwitTwo" class="badge badge-success mr-1" i18n-ngbTooltip="ngbTooltip about segwit gains" ngbTooltip="This transaction saved {{ segwitGains.realizedGains * 100 | number: '1.0-0' }}% on fees by using native SegWit-Bech32" placement="bottom" i18n="tx-features.tag.segwit|SegWit">SegWit</span>
|
||||
<span *ngIf="segwitGains.realizedSegwitGains && !segwitGains.potentialSegwitGains; else segwitTwo" class="badge badge-success mr-1" i18n-ngbTooltip="ngbTooltip about segwit gains" ngbTooltip="This transaction saved {{ segwitGains.realizedSegwitGains * 100 | number: '1.0-0' }}% on fees by using native SegWit" placement="bottom" i18n="tx-features.tag.segwit|SegWit">SegWit</span>
|
||||
<ng-template #segwitTwo>
|
||||
<span *ngIf="segwitGains.realizedGains && segwitGains.potentialBech32Gains else potentialP2shGains" class="badge badge-warning mr-1" i18n-ngbTooltip="ngbTooltip about double segwit gains" ngbTooltip="This transaction saved {{ segwitGains.realizedGains * 100 | number: '1.0-0' }}% on fees by using SegWit and could save {{ segwitGains.potentialBech32Gains * 100 | number : '1.0-0' }}% more by fully upgrading to native SegWit-Bech32" placement="bottom" i18n="tx-features.tag.segwit|SegWit">SegWit</span>
|
||||
<ng-template #potentialP2shGains>
|
||||
<span *ngIf="segwitGains.potentialP2shGains" class="badge badge-danger mr-1" i18n-ngbTooltip="ngbTooltip about missed out gains" ngbTooltip="This transaction could save {{ segwitGains.potentialBech32Gains * 100 | number : '1.0-0' }}% on fees by upgrading to native SegWit-Bech32 or {{ segwitGains.potentialP2shGains * 100 | number: '1.0-0' }}% by upgrading to SegWit-P2SH" placement="bottom"><del i18n="tx-features.tag.segwit|SegWit">SegWit</del></span>
|
||||
<span *ngIf="segwitGains.realizedSegwitGains && segwitGains.potentialSegwitGains; else potentialP2shSegwitGains" class="badge badge-warning mr-1" i18n-ngbTooltip="ngbTooltip about double segwit gains" ngbTooltip="This transaction saved {{ segwitGains.realizedSegwitGains * 100 | number: '1.0-0' }}% on fees by using SegWit and could save {{ segwitGains.potentialSegwitGains * 100 | number : '1.0-0' }}% more by fully upgrading to native SegWit" placement="bottom" i18n="tx-features.tag.segwit|SegWit">SegWit</span>
|
||||
<ng-template #potentialP2shSegwitGains>
|
||||
<span *ngIf="segwitGains.potentialP2shSegwitGains" class="badge badge-danger mr-1" i18n-ngbTooltip="ngbTooltip about missed out gains" ngbTooltip="This transaction could save {{ segwitGains.potentialSegwitGains * 100 | number : '1.0-0' }}% on fees by upgrading to native SegWit or {{ segwitGains.potentialP2shSegwitGains * 100 | number: '1.0-0' }}% by upgrading to SegWit-P2SH" placement="bottom"><del i18n="tx-features.tag.segwit|SegWit">SegWit</del></span>
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
<span *ngIf="isTaproot" class="badge badge-success mr-1" i18n-ngbTooltip="Taproot tooltip" ngbTooltip="This transaction uses Taproot" placement="bottom" i18n="tx-features.tag.taproot">Taproot</span>
|
||||
<span *ngIf="isRbfTransaction; else rbfDisabled" class="badge badge-success" i18n-ngbTooltip="RBF tooltip" ngbTooltip="This transaction support Replace-By-Fee (RBF) allowing fee bumping" placement="bottom" i18n="tx-features.tag.rbf|RBF">RBF</span>
|
||||
|
||||
<span *ngIf="segwitGains.realizedTaprootGains && !segwitGains.potentialTaprootGains; else notFullyTaproot" class="badge badge-success mr-1" i18n-ngbTooltip="Tooltip about privacy and fees saved with taproot" ngbTooltip="This transaction uses Taproot and thereby increased the user's privacy and saved at least {{ segwitGains.realizedTaprootGains * 100 | number: '1.0-0' }}% on fees" placement="bottom" i18n="tx-features.tag.taproot|Taproot">Taproot</span>
|
||||
<ng-template #notFullyTaproot>
|
||||
<span *ngIf="segwitGains.realizedTaprootGains && segwitGains.potentialTaprootGains; else noTaproot" class="badge badge-warning mr-1" i18n-ngbTooltip="Tooltip about privacy and more fees that could be saved with more taproot" ngbTooltip="This transaction uses Taproot and thereby increased the user's privacy and already saved at least {{ segwitGains.realizedTaprootGains * 100 | number: '1.0-0' }}% on fees, but could save an additional {{ segwitGains.potentialTaprootGains * 100 | number: '1.0-0' }}% by fully using Taproot" placement="bottom" i18n="tx-features.tag.taproot|Taproot">Taproot</span>
|
||||
<ng-template #noTaproot>
|
||||
<span *ngIf="segwitGains.potentialTaprootGains; else taprootButNoGains" class="badge badge-danger mr-1" i18n-ngbTooltip="Tooltip about privacy and fees that could be saved with taproot" ngbTooltip="This transaction could increase the user's privacy and save {{ segwitGains.potentialTaprootGains * 100 | number: '1.0-0' }}% on fees by using Taproot" placement="bottom"><del i18n="tx-features.tag.taproot|Taproot">Taproot</del></span>
|
||||
<ng-template #taprootButNoGains>
|
||||
<span *ngIf="isTaproot" class="badge badge-success mr-1" i18n-ngbTooltip="Tooltip about privacy with taproot" ngbTooltip="This transaction uses Taproot and thereby increased the user's privacy" placement="bottom" i18n="tx-features.tag.taproot|Taproot">Taproot</span>
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
|
||||
<span *ngIf="isRbfTransaction; else rbfDisabled" class="badge badge-success" i18n-ngbTooltip="RBF tooltip" ngbTooltip="This transaction supports Replace-By-Fee (RBF) allowing fee bumping" placement="bottom" i18n="tx-features.tag.rbf|RBF">RBF</span>
|
||||
<ng-template #rbfDisabled><span class="badge badge-danger mr-1" i18n-ngbTooltip="RBF disabled tooltip" ngbTooltip="This transaction does NOT support Replace-By-Fee (RBF) and cannot be fee bumped using this method" placement="bottom"><del i18n="tx-features.tag.rbf|RBF">RBF</del></span></ng-template>
|
||||
|
||||
@@ -12,9 +12,11 @@ export class TxFeaturesComponent implements OnChanges {
|
||||
@Input() tx: Transaction;
|
||||
|
||||
segwitGains = {
|
||||
realizedGains: 0,
|
||||
potentialBech32Gains: 0,
|
||||
potentialP2shGains: 0,
|
||||
realizedSegwitGains: 0,
|
||||
potentialSegwitGains: 0,
|
||||
potentialP2shSegwitGains: 0,
|
||||
potentialTaprootGains: 0,
|
||||
realizedTaprootGains: 0
|
||||
};
|
||||
isRbfTransaction: boolean;
|
||||
isTaproot: boolean;
|
||||
|
||||
@@ -20,6 +20,10 @@ import { TelevisionComponent } from '../components/television/television.compone
|
||||
import { DashboardComponent } from '../dashboard/dashboard.component';
|
||||
import { NodesNetworksChartComponent } from '../lightning/nodes-networks-chart/nodes-networks-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';
|
||||
import { NodesMap } from '../lightning/nodes-map/nodes-map.component';
|
||||
import { NodesChannelsMap } from '../lightning/nodes-channels-map/nodes-channels-map.component';
|
||||
|
||||
const browserWindow = window || {};
|
||||
// @ts-ignore
|
||||
@@ -99,6 +103,22 @@ const routes: Routes = [
|
||||
path: 'lightning/capacity',
|
||||
component: LightningStatisticsChartComponent,
|
||||
},
|
||||
{
|
||||
path: 'lightning/nodes-per-isp',
|
||||
component: NodesPerISPChartComponent,
|
||||
},
|
||||
{
|
||||
path: 'lightning/nodes-per-country',
|
||||
component: NodesPerCountryChartComponent,
|
||||
},
|
||||
{
|
||||
path: 'lightning/nodes-map',
|
||||
component: NodesMap,
|
||||
},
|
||||
{
|
||||
path: 'lightning/nodes-channels-map',
|
||||
component: NodesChannelsMap,
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'mempool',
|
||||
|
||||
@@ -128,11 +128,20 @@ export interface BlockExtended extends Block {
|
||||
extras?: BlockExtension;
|
||||
}
|
||||
|
||||
export interface BlockAudit extends BlockExtended {
|
||||
missingTxs: string[],
|
||||
addedTxs: string[],
|
||||
matchRate: number,
|
||||
template: TransactionStripped[],
|
||||
transactions: TransactionStripped[],
|
||||
}
|
||||
|
||||
export interface TransactionStripped {
|
||||
txid: string;
|
||||
fee: number;
|
||||
vsize: number;
|
||||
value: number;
|
||||
status?: 'found' | 'missing' | 'added';
|
||||
}
|
||||
|
||||
export interface RewardStats {
|
||||
|
||||
@@ -70,6 +70,7 @@ export interface TransactionStripped {
|
||||
fee: number;
|
||||
vsize: number;
|
||||
value: number;
|
||||
status?: 'found' | 'missing' | 'added';
|
||||
}
|
||||
|
||||
export interface IBackendInfo {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<div *ngIf="channels$ | async as response; else skeleton">
|
||||
<h2 class="float-left">Channels ({{ response.totalItems }})</h2>
|
||||
|
||||
<form [formGroup]="channelStatusForm" class="formRadioGroup float-right">
|
||||
<div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="status">
|
||||
<label ngbButtonLabel class="btn-primary btn-sm">
|
||||
@@ -12,7 +10,7 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<table class="table table-borderless">
|
||||
<table class="table table-borderless" *ngIf="response.channels.length > 1">
|
||||
<ng-container *ngTemplateOutlet="tableHeader"></ng-container>
|
||||
<tbody>
|
||||
<tr *ngFor="let channel of response.channels; let i = index;">
|
||||
@@ -21,7 +19,11 @@
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<ngb-pagination class="pagination-container float-right" [size]="paginationSize" [collectionSize]="response.totalItems" [rotate]="true" [pageSize]="itemsPerPage" [(page)]="page" (pageChange)="pageChange(page)" [maxSize]="paginationMaxSize" [boundaryLinks]="true" [ellipses]="false"></ngb-pagination>
|
||||
<ngb-pagination *ngIf="response.channels.length > 1" class="pagination-container float-right" [size]="paginationSize" [collectionSize]="response.totalItems" [rotate]="true" [pageSize]="itemsPerPage" [(page)]="page" (pageChange)="pageChange(page)" [maxSize]="paginationMaxSize" [boundaryLinks]="true" [ellipses]="false"></ngb-pagination>
|
||||
|
||||
<table class="table table-borderless" *ngIf="response.channels.length === 0">
|
||||
<div class="d-flex justify-content-center" i18n="lightning.empty-channels-list">No channels to display</div>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<ng-template #tableHeader>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChangeDetectionStrategy, Component, Input, OnChanges, OnInit } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core';
|
||||
import { FormBuilder, FormGroup } from '@angular/forms';
|
||||
import { BehaviorSubject, combineLatest, merge, Observable, of } from 'rxjs';
|
||||
import { map, startWith, switchMap } from 'rxjs/operators';
|
||||
@@ -12,6 +12,7 @@ import { LightningApiService } from '../lightning-api.service';
|
||||
})
|
||||
export class ChannelsListComponent implements OnInit, OnChanges {
|
||||
@Input() publicKey: string;
|
||||
@Output() channelsStatusChangedEvent = new EventEmitter<string>();
|
||||
channels$: Observable<any>;
|
||||
|
||||
// @ts-ignore
|
||||
@@ -41,13 +42,17 @@ export class ChannelsListComponent implements OnInit, OnChanges {
|
||||
|
||||
ngOnChanges(): void {
|
||||
this.channelStatusForm.get('status').setValue(this.defaultStatus, { emitEvent: false })
|
||||
this.channelsStatusChangedEvent.emit(this.defaultStatus);
|
||||
|
||||
this.channels$ = combineLatest([
|
||||
this.channelsPage$,
|
||||
this.channelStatusForm.get('status').valueChanges.pipe(startWith(this.defaultStatus))
|
||||
])
|
||||
.pipe(
|
||||
switchMap(([page, status]) =>this.lightningApiService.getChannelsByNodeId$(this.publicKey, (page -1) * this.itemsPerPage, status)),
|
||||
switchMap(([page, status]) => {
|
||||
this.channelsStatusChangedEvent.emit(status);
|
||||
return this.lightningApiService.getChannelsByNodeId$(this.publicKey, (page -1) * this.itemsPerPage, status);
|
||||
}),
|
||||
map((response) => {
|
||||
return {
|
||||
channels: response.body,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<app-nodes-channels-map [style]="'widget'"></app-nodes-channels-map>
|
||||
|
||||
<div class="container-xl dashboard-container">
|
||||
|
||||
<div class="row row-cols-1 row-cols-md-2">
|
||||
@@ -51,7 +53,7 @@
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Top Capacity Nodes</h5>
|
||||
<app-nodes-list [nodes$]="nodesByCapacity$"></app-nodes-list>
|
||||
<div><a [routerLink]="['/lightning/nodes' | relativeUrl]" i18n="dashboard.view-more">View more »</a></div>
|
||||
<!-- <div><a [routerLink]="['/lightning/nodes' | relativeUrl]" i18n="dashboard.view-more">View more »</a></div> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -61,7 +63,7 @@
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Most Connected Nodes</h5>
|
||||
<app-nodes-list [nodes$]="nodesByChannels$"></app-nodes-list>
|
||||
<div><a [routerLink]="['/lightning/nodes' | relativeUrl]" i18n="dashboard.view-more">View more »</a></div>
|
||||
<!-- <div><a [routerLink]="['/lightning/nodes' | relativeUrl]" i18n="dashboard.view-more">View more »</a></div> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,12 @@ import { NodeStatisticsChartComponent } from './node-statistics-chart/node-stati
|
||||
import { GraphsModule } from '../graphs/graphs.module';
|
||||
import { NodesNetworksChartComponent } from './nodes-networks-chart/nodes-networks-chart.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';
|
||||
import { NodesMap } from '../lightning/nodes-map/nodes-map.component';
|
||||
import { NodesChannelsMap } from '../lightning/nodes-channels-map/nodes-channels-map.component';
|
||||
@NgModule({
|
||||
declarations: [
|
||||
LightningDashboardComponent,
|
||||
@@ -33,6 +39,12 @@ import { ChannelsStatisticsComponent } from './channels-statistics/channels-stat
|
||||
LightningStatisticsChartComponent,
|
||||
NodesNetworksChartComponent,
|
||||
ChannelsStatisticsComponent,
|
||||
NodesPerISPChartComponent,
|
||||
NodesPerCountry,
|
||||
NodesPerISP,
|
||||
NodesPerCountryChartComponent,
|
||||
NodesMap,
|
||||
NodesChannelsMap,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
|
||||
@@ -4,6 +4,8 @@ import { LightningDashboardComponent } from './lightning-dashboard/lightning-das
|
||||
import { LightningWrapperComponent } from './lightning-wrapper/lightning-wrapper.component';
|
||||
import { NodeComponent } from './node/node.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 = [
|
||||
{
|
||||
@@ -22,6 +24,14 @@ const routes: Routes = [
|
||||
path: 'channel/:short_id',
|
||||
component: ChannelComponent,
|
||||
},
|
||||
{
|
||||
path: 'nodes/country/:country',
|
||||
component: NodesPerCountry,
|
||||
},
|
||||
{
|
||||
path: 'nodes/isp/:isp',
|
||||
component: NodesPerISP,
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
redirectTo: ''
|
||||
|
||||
@@ -169,9 +169,6 @@ export class NodeStatisticsChartComponent implements OnInit {
|
||||
},
|
||||
yAxis: data.channels.length === 0 ? undefined : [
|
||||
{
|
||||
min: (value) => {
|
||||
return value.min * 0.9;
|
||||
},
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
color: 'rgb(110, 112, 121)',
|
||||
@@ -188,9 +185,6 @@ export class NodeStatisticsChartComponent implements OnInit {
|
||||
},
|
||||
},
|
||||
{
|
||||
min: (value) => {
|
||||
return value.min * 0.9;
|
||||
},
|
||||
type: 'value',
|
||||
position: 'right',
|
||||
axisLabel: {
|
||||
@@ -225,15 +219,6 @@ export class NodeStatisticsChartComponent implements OnInit {
|
||||
opacity: 1,
|
||||
width: 1,
|
||||
},
|
||||
data: [{
|
||||
yAxis: 1,
|
||||
label: {
|
||||
position: 'end',
|
||||
show: true,
|
||||
color: '#ffffff',
|
||||
formatter: `1 MB`
|
||||
}
|
||||
}],
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
<div class="fee-text">
|
||||
<app-amount [satoshis]="statistics.latest?.total_capacity" digitsInfo="1.2-2"></app-amount>
|
||||
</div>
|
||||
<span class="fiat" *ngIf="statistics.previous">
|
||||
<app-change [current]="statistics.latest.total_capacity" [previous]="statistics.previous.total_capacity">
|
||||
<span class="fiat">
|
||||
<app-change [current]="statistics.latest?.total_capacity" [previous]="statistics.previous?.total_capacity">
|
||||
</app-change>
|
||||
</span>
|
||||
</div>
|
||||
@@ -20,8 +20,8 @@
|
||||
<div class="fee-text">
|
||||
{{ statistics.latest?.node_count || 0 | number }}
|
||||
</div>
|
||||
<span class="fiat" *ngIf="statistics.previous">
|
||||
<app-change [current]="statistics.latest.node_count" [previous]="statistics.previous.node_count"></app-change>
|
||||
<span class="fiat">
|
||||
<app-change [current]="statistics.latest?.node_count" [previous]="statistics.previous?.node_count"></app-change>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -32,8 +32,8 @@
|
||||
<div class="fee-text">
|
||||
{{ statistics.latest?.channel_count || 0 | number }}
|
||||
</div>
|
||||
<span class="fiat" *ngIf="statistics.previous">
|
||||
<app-change [current]="statistics.latest.channel_count" [previous]="statistics.previous.channel_count">
|
||||
<span class="fiat">
|
||||
<app-change [current]="statistics.latest?.channel_count" [previous]="statistics.previous?.channel_count">
|
||||
</app-change>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,114 +1,155 @@
|
||||
<div class="container-xl" *ngIf="(node$ | async) as node">
|
||||
<div class="title-container mb-2">
|
||||
<div class="title-container mb-2" *ngIf="!error">
|
||||
<h1 class="mb-0">{{ node.alias }}</h1>
|
||||
<span class="tx-link">
|
||||
<a [routerLink]="['/lightning/node' | relativeUrl, node.public_key]">{{ node.public_key | shortenString : 12 }}</a>
|
||||
<a [routerLink]="['/lightning/node' | relativeUrl, node.public_key]">{{ node.public_key | shortenString : 12
|
||||
}}</a>
|
||||
<app-clipboard [text]="node.public_key"></app-clipboard>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="clearfix"></div>
|
||||
|
||||
<div class="box">
|
||||
<div *ngIf="error" class="d-flex flex-column justify-content-around align-items-center mt-5 w-100" style="min-height: 100px">
|
||||
<span i18n="lightning.node-not-found">No node found for public key "{{ node.public_key | shortenString : 12}}"</span>
|
||||
<a [routerLink]="['/lightning' | relativeUrl]" i18n="lightning.back-to-lightning-dashboard">Back to the lightning dashboard</a>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md">
|
||||
<table class="table table-borderless table-striped">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td i18n="address.total-received">Total capacity</td>
|
||||
<td>
|
||||
<app-sats [satoshis]="node.capacity"></app-sats><app-fiat [value]="node.capacity" digitsInfo="1.0-0"></app-fiat>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td i18n="address.total-sent">Total channels</td>
|
||||
<td>
|
||||
{{ node.channel_count }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td i18n="address.total-received">Average channel size</td>
|
||||
<td>
|
||||
<app-sats [satoshis]="node.channels_capacity_avg"></app-sats><app-fiat [value]="node.channels_capacity_avg" digitsInfo="1.0-0"></app-fiat>
|
||||
</td>
|
||||
</tr>
|
||||
<tr *ngIf="node.country && node.city && node.subdivision">
|
||||
<td i18n="location">Location</td>
|
||||
<td>{{ node.city.en }}, {{ node.subdivision.en }}<br>{{ node.country.en }}</td>
|
||||
</tr>
|
||||
<tr *ngIf="node.country && !node.city">
|
||||
<td i18n="location">Location</td>
|
||||
<td>{{ node.country.en }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="w-100 d-block d-md-none"></div>
|
||||
<div class="col-md">
|
||||
<table class="table table-borderless table-striped">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td i18n="address.total-received">First seen</td>
|
||||
<td>
|
||||
<app-timestamp [dateString]="node.first_seen"></app-timestamp>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td i18n="address.total-sent">Last update</td>
|
||||
<td>
|
||||
<app-timestamp [dateString]="node.updated_at"></app-timestamp>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td i18n="address.balance">Color</td>
|
||||
<td><div [ngStyle]="{'color': node.color}">{{ node.color }}</div></td>
|
||||
</tr>
|
||||
<tr *ngIf="node.country">
|
||||
<td i18n="isp">ISP</td>
|
||||
<td>
|
||||
<div class="box" *ngIf="!error">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md">
|
||||
<table class="table table-borderless table-striped">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td i18n="address.total-received">Total capacity</td>
|
||||
<td>
|
||||
<app-sats [satoshis]="node.capacity"></app-sats>
|
||||
<app-fiat [value]="node.capacity" digitsInfo="1.0-0"></app-fiat>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td i18n="address.total-sent">Total channels</td>
|
||||
<td>
|
||||
{{ node.channel_active_count }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td i18n="address.total-received">Average channel size</td>
|
||||
<td>
|
||||
<app-sats [satoshis]="node.channels_capacity_avg"></app-sats>
|
||||
<app-fiat [value]="node.channels_capacity_avg" digitsInfo="1.0-0"></app-fiat>
|
||||
</td>
|
||||
</tr>
|
||||
<tr *ngIf="node.country && node.city && node.subdivision">
|
||||
<td i18n="location">Location</td>
|
||||
<td>
|
||||
<span>{{ node.city.en }}, {{ node.subdivision.en }}</span>
|
||||
<br>
|
||||
<a class="d-flex align-items-center" [routerLink]="['/lightning/nodes/country' | relativeUrl, node.iso_code]">
|
||||
<span class="link">{{ node.country.en }}</span>
|
||||
|
||||
<span class="flag">{{ node.flag }}</span>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr *ngIf="node.country && !node.city">
|
||||
<td i18n="location">Location</td>
|
||||
<td>
|
||||
<a [routerLink]="['/lightning/nodes/country' | relativeUrl, node.iso_code]">
|
||||
{{ node.country.en }} {{ node.flag }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="w-100 d-block d-md-none"></div>
|
||||
<div class="col-md">
|
||||
<table class="table table-borderless table-striped">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td i18n="address.total-received">First seen</td>
|
||||
<td>
|
||||
<app-timestamp [dateString]="node.first_seen"></app-timestamp>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td i18n="address.total-sent">Last update</td>
|
||||
<td>
|
||||
<app-timestamp [dateString]="node.updated_at"></app-timestamp>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td i18n="address.balance">Color</td>
|
||||
<td>
|
||||
<div [ngStyle]="{'color': node.color}">{{ node.color }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr *ngIf="node.country">
|
||||
<td i18n="isp">ISP</td>
|
||||
<td>
|
||||
<a [routerLink]="['/lightning/nodes/isp' | relativeUrl, node.as_number]">
|
||||
{{ node.as_organization }} [ASN {{node.as_number}}]
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<br>
|
||||
</div>
|
||||
|
||||
<div class="input-group mb-3" *ngIf="node.socketsObject.length">
|
||||
<div class="d-inline-block" ngbDropdown #myDrop="ngbDropdown" *ngIf="node.socketsObject.length > 1; else noDropdown">
|
||||
<button class="btn btn-secondary dropdown-toggle" type="button" aria-expanded="false" ngbDropdownAnchor (focus)="myDrop.open()"><div class="dropdownLabel">{{ node.socketsObject[selectedSocketIndex].label }}</div></button>
|
||||
<div ngbDropdownMenu aria-labelledby="dropdownManual">
|
||||
<button *ngFor="let socket of node.socketsObject; let i = index;" ngbDropdownItem (click)="changeSocket(i)">{{ socket.label }}</button>
|
||||
</div>
|
||||
<br>
|
||||
|
||||
<div class="input-group mb-3" *ngIf="!error && node.socketsObject.length">
|
||||
<div class="d-inline-block" ngbDropdown #myDrop="ngbDropdown"
|
||||
*ngIf="node.socketsObject.length > 1; else noDropdown">
|
||||
<button class="btn btn-secondary dropdown-toggle" type="button" aria-expanded="false" ngbDropdownAnchor
|
||||
(focus)="myDrop.open()">
|
||||
<div class="dropdownLabel">{{ node.socketsObject[selectedSocketIndex].label }}</div>
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="dropdownManual">
|
||||
<button *ngFor="let socket of node.socketsObject; let i = index;" ngbDropdownItem (click)="changeSocket(i)">{{
|
||||
socket.label }}</button>
|
||||
</div>
|
||||
<ng-template #noDropdown>
|
||||
<span class="input-group-text" id="basic-addon3">{{ node.socketsObject[selectedSocketIndex].label }}</span>
|
||||
</ng-template>
|
||||
<input type="text" class="form-control" aria-label="Text input with dropdown button" [value]="node.socketsObject[selectedSocketIndex].socket">
|
||||
<button class="btn btn-secondary ml-1" type="button" id="inputGroupFileAddon04" (mouseover)="qrCodeVisible = true" (mouseout)="qrCodeVisible = false">
|
||||
<fa-icon [icon]="['fas', 'qrcode']" [fixedWidth]="true"></fa-icon>
|
||||
<div class="qr-wrapper" [hidden]="!qrCodeVisible">
|
||||
<app-qrcode [size]="200" [data]="node.socketsObject[selectedSocketIndex].socket"></app-qrcode>
|
||||
</div>
|
||||
</button>
|
||||
<button class="btn btn-secondary ml-1" type="button" id="inputGroupFileAddon04">
|
||||
<app-clipboard [text]="node.socketsObject[selectedSocketIndex].socket"></app-clipboard>
|
||||
</button>
|
||||
</div>
|
||||
<ng-template #noDropdown>
|
||||
<span class="input-group-text" id="basic-addon3">{{ node.socketsObject[selectedSocketIndex].label }}</span>
|
||||
</ng-template>
|
||||
<input type="text" class="form-control" aria-label="Text input with dropdown button"
|
||||
[value]="node.socketsObject[selectedSocketIndex].socket">
|
||||
<button class="btn btn-secondary ml-1" type="button" id="inputGroupFileAddon04" (mouseover)="qrCodeVisible = true"
|
||||
(mouseout)="qrCodeVisible = false">
|
||||
<fa-icon [icon]="['fas', 'qrcode']" [fixedWidth]="true"></fa-icon>
|
||||
<div class="qr-wrapper" [hidden]="!qrCodeVisible">
|
||||
<app-qrcode [size]="200" [data]="node.socketsObject[selectedSocketIndex].socket"></app-qrcode>
|
||||
</div>
|
||||
</button>
|
||||
<button class="btn btn-secondary ml-1" type="button" id="inputGroupFileAddon04">
|
||||
<app-clipboard [text]="node.socketsObject[selectedSocketIndex].socket"></app-clipboard>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<br>
|
||||
<br>
|
||||
|
||||
<app-node-statistics-chart [publicKey]="node.public_key"></app-node-statistics-chart>
|
||||
<app-node-statistics-chart [publicKey]="node.public_key" *ngIf="!error"></app-node-statistics-chart>
|
||||
|
||||
<br>
|
||||
|
||||
<div class="d-flex justify-content-between" *ngIf="!error">
|
||||
<h2>Channels ({{ channelsListStatus === 'open' ? node.channel_active_count : node.channel_closed_count }})</h2>
|
||||
<div class="d-flex justify-content-end">
|
||||
<app-toggle [textLeft]="'List'" [textRight]="'Map'" (toggleStatusChanged)="channelsListModeChange($event)"></app-toggle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-nodes-channels-map *ngIf="channelsListMode === 'map' && !error" [style]="'nodepage'" [publicKey]="node.public_key">
|
||||
</app-nodes-channels-map>
|
||||
<app-channels-list *ngIf="channelsListMode === 'list' && !error" [publicKey]="node.public_key"
|
||||
(channelsStatusChangedEvent)="onChannelsListStatusChanged($event)"></app-channels-list>
|
||||
|
||||
<br>
|
||||
|
||||
<app-channels-list [publicKey]="node.public_key"></app-channels-list>
|
||||
|
||||
</div>
|
||||
|
||||
<br>
|
||||
<br>
|
||||
@@ -56,5 +56,4 @@ app-fiat {
|
||||
display: inline-block;
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute, ParamMap } from '@angular/router';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map, switchMap } from 'rxjs/operators';
|
||||
import { catchError, map, switchMap } from 'rxjs/operators';
|
||||
import { SeoService } from 'src/app/services/seo.service';
|
||||
import { getFlagEmoji } from 'src/app/shared/graphs.utils';
|
||||
import { LightningApiService } from '../lightning-api.service';
|
||||
|
||||
@Component({
|
||||
@@ -17,6 +18,10 @@ export class NodeComponent implements OnInit {
|
||||
publicKey$: Observable<string>;
|
||||
selectedSocketIndex = 0;
|
||||
qrCodeVisible = false;
|
||||
channelsListMode = 'list';
|
||||
channelsListStatus: string;
|
||||
error: Error;
|
||||
publicKey: string;
|
||||
|
||||
constructor(
|
||||
private lightningApiService: LightningApiService,
|
||||
@@ -28,6 +33,7 @@ export class NodeComponent implements OnInit {
|
||||
this.node$ = this.activatedRoute.paramMap
|
||||
.pipe(
|
||||
switchMap((params: ParamMap) => {
|
||||
this.publicKey = params.get('public_key');
|
||||
return this.lightningApiService.getNode$(params.get('public_key'));
|
||||
}),
|
||||
map((node) => {
|
||||
@@ -46,6 +52,7 @@ export class NodeComponent implements OnInit {
|
||||
} else if (socket.indexOf('onion') > -1) {
|
||||
label = 'Tor';
|
||||
}
|
||||
node.flag = getFlagEmoji(node.iso_code);
|
||||
socketsObject.push({
|
||||
label: label,
|
||||
socket: node.public_key + '@' + socket,
|
||||
@@ -54,6 +61,13 @@ export class NodeComponent implements OnInit {
|
||||
node.socketsObject = socketsObject;
|
||||
return node;
|
||||
}),
|
||||
catchError(err => {
|
||||
this.error = err;
|
||||
return [{
|
||||
alias: this.publicKey,
|
||||
public_key: this.publicKey,
|
||||
}];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,4 +75,15 @@ export class NodeComponent implements OnInit {
|
||||
this.selectedSocketIndex = index;
|
||||
}
|
||||
|
||||
channelsListModeChange(toggle) {
|
||||
if (toggle === true) {
|
||||
this.channelsListMode = 'map';
|
||||
} else {
|
||||
this.channelsListMode = 'list';
|
||||
}
|
||||
}
|
||||
|
||||
onChannelsListStatusChanged(e) {
|
||||
this.channelsListStatus = e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<div [class]="'full-container ' + style">
|
||||
|
||||
<div *ngIf="style === 'graph'" class="card-header">
|
||||
<div class="d-flex d-md-block align-items-baseline" style="margin-bottom: -5px">
|
||||
<span i18n="lightning.nodes-channels-world-map">Lightning nodes channels world map</span>
|
||||
<button class="btn p-0 pl-2" style="margin: 0 0 4px 0px">
|
||||
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true" (click)="onSaveChart()"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
<small style="color: #ffffff66" i18n="lightning.tor-nodes-excluded">(Tor nodes excluded)</small>
|
||||
</div>
|
||||
|
||||
<div *ngIf="observable$ | async" class="chart" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
|
||||
(chartInit)="onChartInit($event)">
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,62 @@
|
||||
.card-header {
|
||||
border-bottom: 0;
|
||||
font-size: 18px;
|
||||
|
||||
@media (min-width: 465px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.full-container {
|
||||
padding: 0px 15px;
|
||||
width: 100%;
|
||||
min-height: 500px;
|
||||
height: calc(100% - 150px);
|
||||
|
||||
@media (max-width: 992px) {
|
||||
height: 100%;
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.full-container.nodepage {
|
||||
margin-top: 50px;
|
||||
}
|
||||
|
||||
.full-container.widget {
|
||||
height: 250px;
|
||||
min-height: 250px;
|
||||
}
|
||||
|
||||
.widget {
|
||||
width: 99vw;
|
||||
height: 250px;
|
||||
-webkit-mask: linear-gradient(0deg, #11131f00 5%, #11131fff 25%);
|
||||
}
|
||||
|
||||
.widget > .chart {
|
||||
-webkit-mask: linear-gradient(180deg, #11131f00 0%, #11131fff 20%);
|
||||
min-height: 250px;
|
||||
}
|
||||
|
||||
.chart {
|
||||
min-height: 500px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding-right: 10px;
|
||||
@media (max-width: 992px) {
|
||||
padding-bottom: 25px;
|
||||
}
|
||||
@media (max-width: 829px) {
|
||||
padding-bottom: 50px;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
padding-bottom: 25px;
|
||||
}
|
||||
@media (max-width: 629px) {
|
||||
padding-bottom: 55px;
|
||||
}
|
||||
@media (max-width: 567px) {
|
||||
padding-bottom: 55px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { ChangeDetectionStrategy, Component, HostListener, Input, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core';
|
||||
import { SeoService } from 'src/app/services/seo.service';
|
||||
import { ApiService } from 'src/app/services/api.service';
|
||||
import { Observable, switchMap, tap, zip } from 'rxjs';
|
||||
import { AssetsService } from 'src/app/services/assets.service';
|
||||
import { download } from 'src/app/shared/graphs.utils';
|
||||
import { ActivatedRoute, ParamMap, Router } from '@angular/router';
|
||||
import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe';
|
||||
import { StateService } from 'src/app/services/state.service';
|
||||
import { EChartsOption, registerMap } from 'echarts';
|
||||
import 'echarts-gl';
|
||||
|
||||
@Component({
|
||||
selector: 'app-nodes-channels-map',
|
||||
templateUrl: './nodes-channels-map.component.html',
|
||||
styleUrls: ['./nodes-channels-map.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class NodesChannelsMap implements OnInit, OnDestroy {
|
||||
@Input() style: 'graph' | 'nodepage' | 'widget' = 'graph';
|
||||
@Input() publicKey: string | undefined;
|
||||
|
||||
observable$: Observable<any>;
|
||||
|
||||
chartInstance = undefined;
|
||||
chartOptions: EChartsOption = {};
|
||||
chartInitOptions = {
|
||||
renderer: 'canvas',
|
||||
};
|
||||
|
||||
constructor(
|
||||
private seoService: SeoService,
|
||||
private apiService: ApiService,
|
||||
private stateService: StateService,
|
||||
private assetsService: AssetsService,
|
||||
private router: Router,
|
||||
private zone: NgZone,
|
||||
private activatedRoute: ActivatedRoute,
|
||||
) {
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.style === 'graph') {
|
||||
this.seoService.setTitle($localize`Lightning nodes channels world map`);
|
||||
}
|
||||
|
||||
this.observable$ = this.activatedRoute.paramMap
|
||||
.pipe(
|
||||
switchMap((params: ParamMap) => {
|
||||
return zip(
|
||||
this.assetsService.getWorldMapJson$,
|
||||
this.apiService.getChannelsGeo$(params.get('public_key') ?? undefined),
|
||||
).pipe(tap((data) => {
|
||||
registerMap('world', data[0]);
|
||||
|
||||
const channelsLoc = [];
|
||||
const nodes = [];
|
||||
const nodesPubkeys = {};
|
||||
for (const channel of data[1]) {
|
||||
channelsLoc.push([[channel[2], channel[3]], [channel[6], channel[7]]]);
|
||||
if (!nodesPubkeys[channel[0]]) {
|
||||
nodes.push({
|
||||
publicKey: channel[0],
|
||||
name: channel[1],
|
||||
value: [channel[2], channel[3]],
|
||||
});
|
||||
nodesPubkeys[channel[0]] = true;
|
||||
}
|
||||
if (!nodesPubkeys[channel[4]]) {
|
||||
nodes.push({
|
||||
publicKey: channel[4],
|
||||
name: channel[5],
|
||||
value: [channel[6], channel[7]],
|
||||
});
|
||||
nodesPubkeys[channel[4]] = true;
|
||||
}
|
||||
}
|
||||
this.prepareChartOptions(nodes, channelsLoc);
|
||||
}));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
prepareChartOptions(nodes, channels) {
|
||||
let title: object;
|
||||
if (channels.length === 0) {
|
||||
title = {
|
||||
textStyle: {
|
||||
color: 'grey',
|
||||
fontSize: 15
|
||||
},
|
||||
text: $localize`No geolocation data available`,
|
||||
left: 'center',
|
||||
top: 'center'
|
||||
};
|
||||
}
|
||||
|
||||
this.chartOptions = {
|
||||
silent: true,
|
||||
title: title ?? undefined,
|
||||
geo3D: {
|
||||
map: 'world',
|
||||
shading: 'color',
|
||||
silent: true,
|
||||
postEffect: {
|
||||
enable: true,
|
||||
bloom: {
|
||||
intensity: 0.1,
|
||||
}
|
||||
},
|
||||
viewControl: {
|
||||
center: this.style === 'widget' ? [0, 0, -10] : undefined,
|
||||
minDistance: this.style === 'widget' ? 22 : 0.1,
|
||||
maxDistance: this.style === 'widget' ? 22 : 60,
|
||||
distance: this.style === 'widget' ? 22 : 60,
|
||||
alpha: 90,
|
||||
panMouseButton: 'left',
|
||||
rotateMouseButton: undefined,
|
||||
zoomSensivity: 0.5,
|
||||
},
|
||||
itemStyle: {
|
||||
color: 'white',
|
||||
opacity: 0.02,
|
||||
borderWidth: 1,
|
||||
borderColor: 'black',
|
||||
},
|
||||
regionHeight: 0.01,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
// @ts-ignore
|
||||
type: 'lines3D',
|
||||
coordinateSystem: 'geo3D',
|
||||
blendMode: 'lighter',
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
opacity: ['widget', 'graph'].includes(this.style) ? 0.025 : 1,
|
||||
},
|
||||
data: channels
|
||||
},
|
||||
{
|
||||
// @ts-ignore
|
||||
type: 'scatter3D',
|
||||
symbol: 'circle',
|
||||
blendMode: 'lighter',
|
||||
coordinateSystem: 'geo3D',
|
||||
symbolSize: 3,
|
||||
itemStyle: {
|
||||
color: '#BBFFFF',
|
||||
opacity: 1,
|
||||
borderColor: '#FFFFFF00',
|
||||
},
|
||||
data: nodes,
|
||||
emphasis: {
|
||||
label: {
|
||||
position: 'top',
|
||||
color: 'white',
|
||||
fontSize: 16,
|
||||
formatter: function(value) {
|
||||
return value.name;
|
||||
},
|
||||
show: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
@HostListener('window:wheel', ['$event'])
|
||||
onWindowScroll(e): void {
|
||||
// Not very smooth when using the mouse
|
||||
if (this.style === 'widget' && e.target.tagName === 'CANVAS') {
|
||||
window.scrollBy({left: 0, top: e.deltaY, behavior: 'auto'});
|
||||
}
|
||||
}
|
||||
|
||||
onChartInit(ec) {
|
||||
if (this.chartInstance !== undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.chartInstance = ec;
|
||||
|
||||
if (this.style === 'widget') {
|
||||
this.chartInstance.getZr().on('click', (e) => {
|
||||
this.zone.run(() => {
|
||||
const url = new RelativeUrlPipe(this.stateService).transform(`/graphs/lightning/nodes-channels-map`);
|
||||
this.router.navigate([url]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this.chartInstance.on('click', (e) => {
|
||||
if (e.data && e.data.publicKey) {
|
||||
this.zone.run(() => {
|
||||
const url = new RelativeUrlPipe(this.stateService).transform(`/lightning/node/${e.data.publicKey}`);
|
||||
this.router.navigate([url]);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onSaveChart() {
|
||||
// @ts-ignore
|
||||
const prevBottom = this.chartOptions.grid.bottom;
|
||||
const now = new Date();
|
||||
// @ts-ignore
|
||||
this.chartOptions.grid.bottom = 30;
|
||||
this.chartOptions.backgroundColor = '#11131f';
|
||||
this.chartInstance.setOption(this.chartOptions);
|
||||
download(this.chartInstance.getDataURL({
|
||||
pixelRatio: 2,
|
||||
excludeComponents: ['dataZoom'],
|
||||
}), `lightning-nodes-heatmap-clearnet-${Math.round(now.getTime() / 1000)}.svg`);
|
||||
// @ts-ignore
|
||||
this.chartOptions.grid.bottom = prevBottom;
|
||||
this.chartOptions.backgroundColor = 'none';
|
||||
this.chartInstance.setOption(this.chartOptions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<div class="full-container">
|
||||
|
||||
<div class="card-header">
|
||||
<div class="d-flex d-md-block align-items-baseline" style="margin-bottom: -5px">
|
||||
<span i18n="lightning.nodes-heatmap">Lightning nodes world heat map</span>
|
||||
<button class="btn p-0 pl-2" style="margin: 0 0 4px 0px">
|
||||
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true" (click)="onSaveChart()"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
<small style="color: #ffffff66" i18n="lightning.tor-nodes-excluded">(Tor nodes excluded)</small>
|
||||
</div>
|
||||
|
||||
<div *ngIf="observable$ | async" class="chart" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
|
||||
(chartInit)="onChartInit($event)">
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,40 @@
|
||||
.card-header {
|
||||
border-bottom: 0;
|
||||
font-size: 18px;
|
||||
@media (min-width: 465px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.full-container {
|
||||
padding: 0px 15px;
|
||||
width: 100%;
|
||||
min-height: 500px;
|
||||
height: calc(100% - 150px);
|
||||
@media (max-width: 992px) {
|
||||
height: 100%;
|
||||
padding-bottom: 100px;
|
||||
};
|
||||
}
|
||||
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding-bottom: 20px;
|
||||
padding-right: 10px;
|
||||
@media (max-width: 992px) {
|
||||
padding-bottom: 25px;
|
||||
}
|
||||
@media (max-width: 829px) {
|
||||
padding-bottom: 50px;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
padding-bottom: 25px;
|
||||
}
|
||||
@media (max-width: 629px) {
|
||||
padding-bottom: 55px;
|
||||
}
|
||||
@media (max-width: 567px) {
|
||||
padding-bottom: 55px;
|
||||
}
|
||||
}
|
||||
163
frontend/src/app/lightning/nodes-map/nodes-map.component.ts
Normal file
163
frontend/src/app/lightning/nodes-map/nodes-map.component.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { ChangeDetectionStrategy, Component, NgZone, OnDestroy, OnInit } from '@angular/core';
|
||||
import { mempoolFeeColors } from 'src/app/app.constants';
|
||||
import { SeoService } from 'src/app/services/seo.service';
|
||||
import { ApiService } from 'src/app/services/api.service';
|
||||
import { combineLatest, Observable, tap } from 'rxjs';
|
||||
import { AssetsService } from 'src/app/services/assets.service';
|
||||
import { EChartsOption, registerMap } from 'echarts';
|
||||
import { download } from 'src/app/shared/graphs.utils';
|
||||
import { Router } from '@angular/router';
|
||||
import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe';
|
||||
import { StateService } from 'src/app/services/state.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-nodes-map',
|
||||
templateUrl: './nodes-map.component.html',
|
||||
styleUrls: ['./nodes-map.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class NodesMap implements OnInit, OnDestroy {
|
||||
observable$: Observable<any>;
|
||||
|
||||
chartInstance = undefined;
|
||||
chartOptions: EChartsOption = {};
|
||||
chartInitOptions = {
|
||||
renderer: 'svg',
|
||||
};
|
||||
|
||||
constructor(
|
||||
private seoService: SeoService,
|
||||
private apiService: ApiService,
|
||||
private stateService: StateService,
|
||||
private assetsService: AssetsService,
|
||||
private router: Router,
|
||||
private zone: NgZone,
|
||||
) {
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.seoService.setTitle($localize`Lightning nodes world map`);
|
||||
|
||||
this.observable$ = combineLatest([
|
||||
this.assetsService.getWorldMapJson$,
|
||||
this.apiService.getNodesPerCountry()
|
||||
]).pipe(tap((data) => {
|
||||
registerMap('world', data[0]);
|
||||
|
||||
const countries = [];
|
||||
let max = 0;
|
||||
for (const country of data[1]) {
|
||||
countries.push({
|
||||
name: country.name.en,
|
||||
value: country.count,
|
||||
iso: country.iso.toLowerCase(),
|
||||
});
|
||||
max = Math.max(max, country.count);
|
||||
}
|
||||
|
||||
this.prepareChartOptions(countries, max);
|
||||
}));
|
||||
}
|
||||
|
||||
prepareChartOptions(countries, max) {
|
||||
let title: object;
|
||||
if (countries.length === 0) {
|
||||
title = {
|
||||
textStyle: {
|
||||
color: 'grey',
|
||||
fontSize: 15
|
||||
},
|
||||
text: $localize`No data to display yet`,
|
||||
left: 'center',
|
||||
top: 'center'
|
||||
};
|
||||
}
|
||||
|
||||
this.chartOptions = {
|
||||
title: countries.length === 0 ? title : undefined,
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(17, 19, 31, 1)',
|
||||
borderRadius: 4,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||
textStyle: {
|
||||
color: '#b1b1b1',
|
||||
},
|
||||
borderColor: '#000',
|
||||
formatter: function(country) {
|
||||
if (country.data === undefined) {
|
||||
return `<b style="color: white">${country.name}<br>0 nodes</b><br>`;
|
||||
} else {
|
||||
return `<b style="color: white">${country.data.name}<br>${country.data.value} nodes</b><br>`;
|
||||
}
|
||||
}
|
||||
},
|
||||
visualMap: {
|
||||
left: 'right',
|
||||
show: true,
|
||||
min: 1,
|
||||
max: max,
|
||||
text: ['High', 'Low'],
|
||||
calculable: true,
|
||||
textStyle: {
|
||||
color: 'white',
|
||||
},
|
||||
inRange: {
|
||||
color: mempoolFeeColors.map(color => `#${color}`),
|
||||
},
|
||||
},
|
||||
series: {
|
||||
type: 'map',
|
||||
map: 'world',
|
||||
emphasis: {
|
||||
label: {
|
||||
show: false,
|
||||
},
|
||||
itemStyle: {
|
||||
areaColor: '#FDD835',
|
||||
}
|
||||
},
|
||||
data: countries,
|
||||
itemStyle: {
|
||||
areaColor: '#5A6A6D'
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
onChartInit(ec) {
|
||||
if (this.chartInstance !== undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.chartInstance = ec;
|
||||
|
||||
this.chartInstance.on('click', (e) => {
|
||||
if (e.data && e.data.value > 0) {
|
||||
this.zone.run(() => {
|
||||
const url = new RelativeUrlPipe(this.stateService).transform(`/lightning/nodes/country/${e.data.iso}`);
|
||||
this.router.navigate([url]);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onSaveChart() {
|
||||
// @ts-ignore
|
||||
const prevBottom = this.chartOptions.grid.bottom;
|
||||
const now = new Date();
|
||||
// @ts-ignore
|
||||
this.chartOptions.grid.bottom = 30;
|
||||
this.chartOptions.backgroundColor = '#11131f';
|
||||
this.chartInstance.setOption(this.chartOptions);
|
||||
download(this.chartInstance.getDataURL({
|
||||
pixelRatio: 2,
|
||||
excludeComponents: ['dataZoom'],
|
||||
}), `lightning-nodes-heatmap-clearnet-${Math.round(now.getTime() / 1000)}.svg`);
|
||||
// @ts-ignore
|
||||
this.chartOptions.grid.bottom = prevBottom;
|
||||
this.chartOptions.backgroundColor = 'none';
|
||||
this.chartInstance.setOption(this.chartOptions);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
<div [class]="widget === false ? 'full-container' : ''">
|
||||
|
||||
<div class="card-header mb-0 mb-md-4" [style]="widget ? 'display:none' : ''">
|
||||
<span i18n="mining.nodes-networks">Nodes count by network</span>
|
||||
<button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
|
||||
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
|
||||
</button>
|
||||
<div class="d-flex d-md-block align-items-baseline">
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(nodesNetworkObservable$ | async) as stats">
|
||||
<div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan">
|
||||
|
||||
@@ -59,9 +59,9 @@ export class NodesNetworksChartComponent implements OnInit {
|
||||
let firstRun = true;
|
||||
|
||||
if (this.widget) {
|
||||
this.miningWindowPreference = '1y';
|
||||
this.miningWindowPreference = '3y';
|
||||
} else {
|
||||
this.seoService.setTitle($localize`Nodes per network`);
|
||||
this.seoService.setTitle($localize`Lightning nodes per network`);
|
||||
this.miningWindowPreference = this.miningService.getDefaultTimespan('all');
|
||||
}
|
||||
this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference });
|
||||
@@ -83,7 +83,6 @@ export class NodesNetworksChartComponent implements OnInit {
|
||||
tap((response) => {
|
||||
const data = response.body;
|
||||
this.prepareChartOptions({
|
||||
node_count: data.map(val => [val.added * 1000, val.node_count]),
|
||||
tor_nodes: data.map(val => [val.added * 1000, val.tor_nodes]),
|
||||
clearnet_nodes: data.map(val => [val.added * 1000, val.clearnet_nodes]),
|
||||
unannounced_nodes: data.map(val => [val.added * 1000, val.unannounced_nodes]),
|
||||
@@ -103,7 +102,7 @@ export class NodesNetworksChartComponent implements OnInit {
|
||||
|
||||
prepareChartOptions(data) {
|
||||
let title: object;
|
||||
if (data.node_count.length === 0) {
|
||||
if (data.tor_nodes.length === 0) {
|
||||
title = {
|
||||
textStyle: {
|
||||
color: 'grey',
|
||||
@@ -145,33 +144,34 @@ export class NodesNetworksChartComponent implements OnInit {
|
||||
},
|
||||
borderColor: '#000',
|
||||
formatter: (ticks) => {
|
||||
let total = 0;
|
||||
const date = new Date(ticks[0].data[0]).toLocaleDateString(this.locale, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
let tooltip = `<b style="color: white; margin-left: 2px">${date}</b><br>`;
|
||||
|
||||
for (const tick of ticks) {
|
||||
if (tick.seriesIndex === 0) { // Total
|
||||
for (const tick of ticks.reverse()) {
|
||||
if (tick.seriesIndex === 0) { // Tor
|
||||
tooltip += `${tick.marker} ${tick.seriesName}: ${formatNumber(tick.data[1], this.locale, '1.0-0')}`;
|
||||
} else if (tick.seriesIndex === 1) { // Tor
|
||||
} else if (tick.seriesIndex === 1) { // Clearnet
|
||||
tooltip += `${tick.marker} ${tick.seriesName}: ${formatNumber(tick.data[1], this.locale, '1.0-0')}`;
|
||||
} else if (tick.seriesIndex === 2) { // Clearnet
|
||||
tooltip += `${tick.marker} ${tick.seriesName}: ${formatNumber(tick.data[1], this.locale, '1.0-0')}`;
|
||||
} else if (tick.seriesIndex === 3) { // Unannounced
|
||||
} else if (tick.seriesIndex === 2) { // Unannounced
|
||||
tooltip += `${tick.marker} ${tick.seriesName}: ${formatNumber(tick.data[1], this.locale, '1.0-0')}`;
|
||||
}
|
||||
tooltip += `<br>`;
|
||||
total += tick.data[1];
|
||||
}
|
||||
tooltip += `<b>Total:</b> ${formatNumber(total, this.locale, '1.0-0')} nodes`;
|
||||
|
||||
return tooltip;
|
||||
}
|
||||
},
|
||||
xAxis: data.node_count.length === 0 ? undefined : {
|
||||
xAxis: data.tor_nodes.length === 0 ? undefined : {
|
||||
type: 'time',
|
||||
splitNumber: (this.isMobile() || this.widget) ? 5 : 10,
|
||||
axisLabel: {
|
||||
hideOverlap: true,
|
||||
}
|
||||
},
|
||||
legend: data.node_count.length === 0 ? undefined : {
|
||||
legend: data.tor_nodes.length === 0 ? undefined : {
|
||||
padding: 10,
|
||||
data: [
|
||||
{
|
||||
@@ -214,7 +214,7 @@ export class NodesNetworksChartComponent implements OnInit {
|
||||
'Unannounced': true,
|
||||
}
|
||||
},
|
||||
yAxis: data.node_count.length === 0 ? undefined : [
|
||||
yAxis: data.tor_nodes.length === 0 ? undefined : [
|
||||
{
|
||||
type: 'value',
|
||||
position: 'left',
|
||||
@@ -236,45 +236,23 @@ export class NodesNetworksChartComponent implements OnInit {
|
||||
},
|
||||
}
|
||||
],
|
||||
series: data.node_count.length === 0 ? [] : [
|
||||
{
|
||||
zlevel: 1,
|
||||
name: $localize`Total`,
|
||||
showSymbol: false,
|
||||
symbol: 'none',
|
||||
data: data.node_count,
|
||||
type: 'line',
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
},
|
||||
markLine: {
|
||||
silent: true,
|
||||
symbol: 'none',
|
||||
lineStyle: {
|
||||
type: 'solid',
|
||||
color: '#ffffff66',
|
||||
opacity: 1,
|
||||
width: 1,
|
||||
},
|
||||
},
|
||||
areaStyle: {
|
||||
opacity: 0.25,
|
||||
},
|
||||
},
|
||||
series: data.tor_nodes.length === 0 ? [] : [
|
||||
{
|
||||
zlevel: 1,
|
||||
yAxisIndex: 0,
|
||||
name: $localize`Tor`,
|
||||
name: $localize`Unannounced`,
|
||||
showSymbol: false,
|
||||
symbol: 'none',
|
||||
data: data.tor_nodes,
|
||||
data: data.unannounced_nodes,
|
||||
type: 'line',
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
},
|
||||
areaStyle: {
|
||||
opacity: 0.25,
|
||||
opacity: 0.5,
|
||||
},
|
||||
stack: 'Total',
|
||||
color: '#FDD835',
|
||||
},
|
||||
{
|
||||
zlevel: 1,
|
||||
@@ -288,24 +266,28 @@ export class NodesNetworksChartComponent implements OnInit {
|
||||
width: 2,
|
||||
},
|
||||
areaStyle: {
|
||||
opacity: 0.25,
|
||||
opacity: 0.5,
|
||||
},
|
||||
stack: 'Total',
|
||||
color: '#00ACC1',
|
||||
},
|
||||
{
|
||||
zlevel: 1,
|
||||
yAxisIndex: 0,
|
||||
name: $localize`Unannounced`,
|
||||
name: $localize`Tor`,
|
||||
showSymbol: false,
|
||||
symbol: 'none',
|
||||
data: data.unannounced_nodes,
|
||||
data: data.tor_nodes,
|
||||
type: 'line',
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
},
|
||||
areaStyle: {
|
||||
opacity: 0.25,
|
||||
opacity: 0.5,
|
||||
},
|
||||
}
|
||||
stack: 'Total',
|
||||
color: '#7D4698',
|
||||
},
|
||||
],
|
||||
dataZoom: this.widget ? null : [{
|
||||
type: 'inside',
|
||||
|
||||
@@ -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">
|
||||
<a class="d-flex align-items-center" [routerLink]="['/lightning/nodes/country' | relativeUrl, country.iso]">
|
||||
<span class="flag">{{ country.flag }}</span>
|
||||
|
||||
<span class="link">{{ country.name.en }}</span>
|
||||
</a>
|
||||
</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,93 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover .link {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.flag {
|
||||
font-size: 20px;
|
||||
}
|
||||
@@ -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,56 @@
|
||||
<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 class="d-block" style="color: #ffffff66; min-height: 25px" i18n="lightning.tor-nodes-excluded">
|
||||
<span *ngIf="!(showTorObservable$ | async)">(Tor nodes excluded)</span>
|
||||
</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>
|
||||
|
||||
<div class="d-flex toggle">
|
||||
<app-toggle [textLeft]="'Show Tor'" [textRight]="" (toggleStatusChanged)="onTorToggleStatusChanged($event)"></app-toggle>
|
||||
<app-toggle [textLeft]="'Nodes'" [textRight]="'Capacity'" (toggleStatusChanged)="onGroupToggleStatusChanged($event)"></app-toggle>
|
||||
</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">
|
||||
<a *ngIf="asEntry.ispId" [routerLink]="[('/lightning/nodes/isp/' + asEntry.ispId) | relativeUrl]">{{ asEntry.name }}</a>
|
||||
<span *ngIf="!asEntry.ispId">{{ asEntry.name }}</span>
|
||||
</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,85 @@
|
||||
.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: 15%;
|
||||
@media (max-width: 576px) {
|
||||
display: none
|
||||
}
|
||||
}
|
||||
|
||||
.name {
|
||||
width: 25%;
|
||||
@media (max-width: 576px) {
|
||||
width: 70%;
|
||||
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: 20%;
|
||||
max-width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.toggle {
|
||||
justify-content: space-between;
|
||||
padding-top: 15px;
|
||||
@media (min-width: 576px) {
|
||||
padding-bottom: 15px;
|
||||
padding-left: 105px;
|
||||
padding-right: 105px;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user