Merge branch 'master' into translations_frontend-src-locale-messages-xlf--master_pl
This commit is contained in:
		
						commit
						c509a69f1d
					
				
							
								
								
									
										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 }} | ||||
|  | ||||
							
								
								
									
										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 | ||||
|  | ||||
| @ -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,7 +4,7 @@ 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[] = []; | ||||
| @ -297,7 +297,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,30 @@ class ChannelsApi { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public async $getAllChannelsGeo(): Promise<any[]> { | ||||
|     try { | ||||
|       const 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 | ||||
|       `;
 | ||||
|       const [rows]: any = await DB.query(query); | ||||
|       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,7 @@ 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.$getChannelsGeo) | ||||
|     ; | ||||
|   } | ||||
| 
 | ||||
| @ -93,6 +94,15 @@ class ChannelsRoutes { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private async $getChannelsGeo(req: Request, res: Response) { | ||||
|     try { | ||||
|       const channels = await channelsApi.$getAllChannelsGeo(); | ||||
|       res.json(channels); | ||||
|     } catch (e) { | ||||
|       res.status(500).send(e instanceof Error ? e.message : e); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| export default new ChannelsRoutes(); | ||||
|  | ||||
| @ -93,6 +93,132 @@ class NodesApi { | ||||
|       throw e; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public async $getNodesISP() { | ||||
|     try { | ||||
|       let query = `SELECT nodes.as_number as ispId, geo_names.names as names, COUNT(DISTINCT nodes.public_key) as nodesCount, SUM(capacity) as capacity
 | ||||
|         FROM nodes | ||||
|         JOIN geo_names ON geo_names.id = nodes.as_number | ||||
|         JOIN channels ON channels.node1_public_key = nodes.public_key OR channels.node2_public_key = nodes.public_key | ||||
|         GROUP BY as_number | ||||
|         ORDER BY COUNT(DISTINCT nodes.public_key) DESC | ||||
|       `;
 | ||||
|       const [nodesCountPerAS]: any = await DB.query(query); | ||||
| 
 | ||||
|       query = `SELECT COUNT(*) as total FROM nodes WHERE as_number IS NOT NULL`; | ||||
|       const [nodesWithAS]: any = await DB.query(query); | ||||
| 
 | ||||
|       const nodesPerAs: any[] = []; | ||||
|       for (const as of nodesCountPerAS) { | ||||
|         nodesPerAs.push({ | ||||
|           ispId: as.ispId, | ||||
|           name: JSON.parse(as.names), | ||||
|           count: as.nodesCount, | ||||
|           share: Math.floor(as.nodesCount / nodesWithAS[0].total * 10000) / 100, | ||||
|           capacity: as.capacity, | ||||
|         }) | ||||
|       } | ||||
| 
 | ||||
|       return nodesPerAs; | ||||
|     } catch (e) { | ||||
|       logger.err(`Cannot get nodes grouped by AS. Reason: ${e instanceof Error ? e.message : e}`); | ||||
|       throw e; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public async $getNodesPerCountry(countryId: string) { | ||||
|     try { | ||||
|       const query = ` | ||||
|         SELECT node_stats.public_key, node_stats.capacity, node_stats.channels, nodes.alias, | ||||
|           UNIX_TIMESTAMP(nodes.first_seen) as first_seen, UNIX_TIMESTAMP(nodes.updated_at) as updated_at, | ||||
|           geo_names_city.names as city | ||||
|         FROM node_stats | ||||
|         JOIN ( | ||||
|           SELECT public_key, MAX(added) as last_added | ||||
|           FROM node_stats | ||||
|           GROUP BY public_key | ||||
|         ) as b ON b.public_key = node_stats.public_key AND b.last_added = node_stats.added | ||||
|         JOIN nodes ON nodes.public_key = node_stats.public_key | ||||
|         JOIN geo_names geo_names_country ON geo_names_country.id = nodes.country_id AND geo_names_country.type = 'country' | ||||
|         LEFT JOIN geo_names geo_names_city ON geo_names_city.id = nodes.city_id AND geo_names_city.type = 'city' | ||||
|         WHERE geo_names_country.id = ? | ||||
|         ORDER BY capacity DESC | ||||
|       `;
 | ||||
| 
 | ||||
|       const [rows]: any = await DB.query(query, [countryId]); | ||||
|       for (let i = 0; i < rows.length; ++i) { | ||||
|         rows[i].city = JSON.parse(rows[i].city); | ||||
|       } | ||||
|       return rows; | ||||
|     } catch (e) { | ||||
|       logger.err(`Cannot get nodes for country id ${countryId}. Reason: ${e instanceof Error ? e.message : e}`); | ||||
|       throw e; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public async $getNodesPerISP(ISPId: string) { | ||||
|     try { | ||||
|       const query = ` | ||||
|         SELECT node_stats.public_key, node_stats.capacity, node_stats.channels, nodes.alias, | ||||
|           UNIX_TIMESTAMP(nodes.first_seen) as first_seen, UNIX_TIMESTAMP(nodes.updated_at) as updated_at, | ||||
|           geo_names_city.names as city, geo_names_country.names as country | ||||
|         FROM node_stats | ||||
|         JOIN ( | ||||
|           SELECT public_key, MAX(added) as last_added | ||||
|           FROM node_stats | ||||
|           GROUP BY public_key | ||||
|         ) as b ON b.public_key = node_stats.public_key AND b.last_added = node_stats.added | ||||
|         JOIN nodes ON nodes.public_key = node_stats.public_key | ||||
|         JOIN geo_names geo_names_country ON geo_names_country.id = nodes.country_id AND geo_names_country.type = 'country' | ||||
|         LEFT JOIN geo_names geo_names_city ON geo_names_city.id = nodes.city_id AND geo_names_city.type = 'city' | ||||
|         WHERE nodes.as_number = ? | ||||
|         ORDER BY capacity DESC | ||||
|       `;
 | ||||
| 
 | ||||
|       const [rows]: any = await DB.query(query, [ISPId]); | ||||
|       for (let i = 0; i < rows.length; ++i) { | ||||
|         rows[i].country = JSON.parse(rows[i].country); | ||||
|         rows[i].city = JSON.parse(rows[i].city); | ||||
|       } | ||||
|       return rows; | ||||
|     } catch (e) { | ||||
|       logger.err(`Cannot get nodes for ISP id ${ISPId}. Reason: ${e instanceof Error ? e.message : e}`); | ||||
|       throw e; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public async $getNodesCountries() { | ||||
|     try { | ||||
|       let query = `SELECT geo_names.names as names, geo_names_iso.names as iso_code, COUNT(DISTINCT nodes.public_key) as nodesCount, SUM(capacity) as capacity
 | ||||
|         FROM nodes | ||||
|         JOIN geo_names ON geo_names.id = nodes.country_id AND geo_names.type = 'country' | ||||
|         JOIN geo_names geo_names_iso ON geo_names_iso.id = nodes.country_id AND geo_names_iso.type = 'country_iso_code' | ||||
|         JOIN channels ON channels.node1_public_key = nodes.public_key OR channels.node2_public_key = nodes.public_key | ||||
|         GROUP BY country_id | ||||
|         ORDER BY COUNT(DISTINCT nodes.public_key) DESC | ||||
|       `;
 | ||||
|       const [nodesCountPerCountry]: any = await DB.query(query); | ||||
| 
 | ||||
|       query = `SELECT COUNT(*) as total FROM nodes WHERE country_id IS NOT NULL`; | ||||
|       const [nodesWithAS]: any = await DB.query(query); | ||||
| 
 | ||||
|       const nodesPerCountry: any[] = []; | ||||
|       for (const country of nodesCountPerCountry) { | ||||
|         nodesPerCountry.push({ | ||||
|           name: JSON.parse(country.names), | ||||
|           iso: country.iso_code,  | ||||
|           count: country.nodesCount, | ||||
|           share: Math.floor(country.nodesCount / nodesWithAS[0].total * 10000) / 100, | ||||
|           capacity: country.capacity, | ||||
|         }) | ||||
|       } | ||||
| 
 | ||||
|       return nodesPerCountry; | ||||
|     } catch (e) { | ||||
|       logger.err(`Cannot get nodes grouped by AS. Reason: ${e instanceof Error ? e.message : e}`); | ||||
|       throw e; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export default new NodesApi(); | ||||
|  | ||||
| @ -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/isp/:isp', this.$getNodesPerISP) | ||||
|       .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', this.$getNodesISP) | ||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/countries', this.$getNodesCountries) | ||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/:public_key/statistics', this.$getHistoricalNodeStats) | ||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/:public_key', this.$getNode) | ||||
|     ; | ||||
| @ -56,6 +62,85 @@ class NodesRoutes { | ||||
|       res.status(500).send(e instanceof Error ? e.message : e); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private async $getNodesISP(req: Request, res: Response) { | ||||
|     try { | ||||
|       const nodesPerAs = await nodesApi.$getNodesISP(); | ||||
|       res.header('Pragma', 'public'); | ||||
|       res.header('Cache-control', 'public'); | ||||
|       res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString()); | ||||
|       res.json(nodesPerAs); | ||||
|     } catch (e) { | ||||
|       res.status(500).send(e instanceof Error ? e.message : e); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private async $getNodesPerCountry(req: Request, res: Response) { | ||||
|     try { | ||||
|       const [country]: any[] = await DB.query( | ||||
|         `SELECT geo_names.id, geo_names_country.names as country_names
 | ||||
|         FROM geo_names | ||||
|         JOIN geo_names geo_names_country on geo_names.id = geo_names_country.id AND geo_names_country.type = 'country' | ||||
|         WHERE geo_names.type = 'country_iso_code' AND geo_names.names = ?`,
 | ||||
|         [req.params.country] | ||||
|       ); | ||||
| 
 | ||||
|       if (country.length === 0) { | ||||
|         res.status(404).send(`This country does not exist or does not host any lightning nodes on clearnet`); | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       const nodes = await nodesApi.$getNodesPerCountry(country[0].id); | ||||
|       res.header('Pragma', 'public'); | ||||
|       res.header('Cache-control', 'public'); | ||||
|       res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); | ||||
|       res.json({ | ||||
|         country: JSON.parse(country[0].country_names), | ||||
|         nodes: nodes, | ||||
|       }); | ||||
|     } catch (e) { | ||||
|       res.status(500).send(e instanceof Error ? e.message : e); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private async $getNodesPerISP(req: Request, res: Response) { | ||||
|     try { | ||||
|       const [isp]: any[] = await DB.query( | ||||
|         `SELECT geo_names.names as isp_name
 | ||||
|         FROM geo_names | ||||
|         WHERE geo_names.type = 'as_organization' AND geo_names.id = ?`,
 | ||||
|         [req.params.isp] | ||||
|       ); | ||||
| 
 | ||||
|       if (isp.length === 0) { | ||||
|         res.status(404).send(`This ISP does not exist or does not host any lightning nodes on clearnet`); | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       const nodes = await nodesApi.$getNodesPerISP(req.params.isp); | ||||
|       res.header('Pragma', 'public'); | ||||
|       res.header('Cache-control', 'public'); | ||||
|       res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); | ||||
|       res.json({ | ||||
|         isp: JSON.parse(isp[0].isp_name), | ||||
|         nodes: nodes, | ||||
|       }); | ||||
|     } catch (e) { | ||||
|       res.status(500).send(e instanceof Error ? e.message : e); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private async $getNodesCountries(req: Request, res: Response) { | ||||
|     try { | ||||
|       const nodesPerAs = await nodesApi.$getNodesCountries(); | ||||
|       res.header('Pragma', 'public'); | ||||
|       res.header('Cache-control', 'public'); | ||||
|       res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString()); | ||||
|       res.json(nodesPerAs); | ||||
|     } catch (e) { | ||||
|       res.status(500).send(e instanceof Error ? e.message : e); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export default new NodesRoutes(); | ||||
|  | ||||
| @ -26,6 +26,7 @@ class MiningRoutes { | ||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/sizes-weights/:interval', this.$getHistoricalBlockSizeAndWeight) | ||||
|       .get(config.MEMPOOL.API_URL_PREFIX + 'mining/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) | ||||
|     ; | ||||
|   } | ||||
| 
 | ||||
| @ -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, | ||||
|  | ||||
| @ -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; | ||||
|       } | ||||
|     } | ||||
|  | ||||
| @ -39,6 +39,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( | ||||
|  | ||||
| @ -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]); | ||||
|     } | ||||
| 
 | ||||
|  | ||||
| @ -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,11 @@ 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 { 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'; | ||||
| @ -88,6 +91,15 @@ let routes: Routes = [ | ||||
|               }, | ||||
|             ], | ||||
|           }, | ||||
|           { | ||||
|             path: 'block-audit', | ||||
|             children: [ | ||||
|               { | ||||
|                 path: ':id', | ||||
|                 component: BlockAuditComponent, | ||||
|               }, | ||||
|             ], | ||||
|           }, | ||||
|           { | ||||
|             path: 'docs', | ||||
|             loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule) | ||||
| @ -182,6 +194,15 @@ let routes: Routes = [ | ||||
|               }, | ||||
|             ], | ||||
|           }, | ||||
|           { | ||||
|             path: 'block-audit', | ||||
|             children: [ | ||||
|               { | ||||
|                 path: ':id', | ||||
|                 component: BlockAuditComponent, | ||||
|               }, | ||||
|             ], | ||||
|           }, | ||||
|           { | ||||
|             path: 'docs', | ||||
|             loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule) | ||||
| @ -273,6 +294,15 @@ let routes: Routes = [ | ||||
|           }, | ||||
|         ], | ||||
|       }, | ||||
|       { | ||||
|         path: 'block-audit', | ||||
|         children: [ | ||||
|           { | ||||
|             path: ':id', | ||||
|             component: BlockAuditComponent | ||||
|           }, | ||||
|         ], | ||||
|       }, | ||||
|       { | ||||
|         path: 'docs', | ||||
|         loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule) | ||||
| @ -287,6 +317,16 @@ let routes: Routes = [ | ||||
|       }, | ||||
|     ], | ||||
|   }, | ||||
|   { | ||||
|     path: 'preview', | ||||
|     component: MasterPagePreviewComponent, | ||||
|     children: [ | ||||
|       { | ||||
|         path: 'block/:id', | ||||
|         component: BlockPreviewComponent | ||||
|       }, | ||||
|     ], | ||||
|   }, | ||||
|   { | ||||
|     path: 'status', | ||||
|     component: StatusViewComponent | ||||
| @ -548,4 +588,3 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') { | ||||
|   })], | ||||
| }) | ||||
| export class AppRoutingModule { } | ||||
| 
 | ||||
|  | ||||
| @ -6,6 +6,7 @@ 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'; | ||||
| @ -36,6 +37,7 @@ import { CapAddressPipe } from './shared/pipes/cap-address-pipe/cap-address-pipe | ||||
|     AudioService, | ||||
|     SeoService, | ||||
|     StorageService, | ||||
|     EnterpriseService, | ||||
|     LanguageService, | ||||
|     ShortenStringPipe, | ||||
|     FiatShortenerPipe, | ||||
|  | ||||
| @ -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"> | ||||
|     <div class="d-flex d-md-block align-items-baseline"> | ||||
|       <span i18n="mining.block-fee-rates">Block Fee Rates</span> | ||||
|     <button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()"> | ||||
|       <button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()"> | ||||
|         <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon> | ||||
|       </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"> | ||||
|     <div class="d-flex d-md-block align-items-baseline"> | ||||
|       <span i18n="mining.block-fees">Block Fees</span> | ||||
|       <button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()"> | ||||
|       <button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()"> | ||||
|         <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon> | ||||
|       </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"> | ||||
|     <div class="d-flex d-md-block align-items-baseline"> | ||||
|       <span i18n="mining.block-prediction-accuracy">Block Prediction Accuracy</span> | ||||
|       <button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()"> | ||||
|       <button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()"> | ||||
|         <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon> | ||||
|       </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"> | ||||
|     <div class="d-flex d-md-block align-items-baseline"> | ||||
|       <span i18n="mining.block-rewards">Block Rewards</span> | ||||
|     <button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()"> | ||||
|       <button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()"> | ||||
|         <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon> | ||||
|       </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"> | ||||
|     <div class="d-flex d-md-block align-items-baseline"> | ||||
|       <span i18n="mining.block-sizes-weights">Block Sizes and Weights</span> | ||||
|     <button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()"> | ||||
|       <button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()"> | ||||
|         <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon> | ||||
|       </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,92 @@ | ||||
| <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.size">Size</td> | ||||
|             <td [innerHTML]="'‎' + (block?.size | bytes: 2)"></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> | ||||
|               <tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'"> | ||||
|                 <td i18n="block.subsidy-and-fees|Total subsidy and fees in a block">Subsidy + fees:</td> | ||||
|                 <td> | ||||
|                   <app-amount [satoshis]="block?.extras.reward" digitsInfo="1.2-3" [noFiat]="true"></app-amount> | ||||
|                 </td> | ||||
|               </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,7 @@ | ||||
| .box { | ||||
|   padding: 2rem 6rem; | ||||
| } | ||||
| 
 | ||||
| .block-title { | ||||
|   margin-bottom: 0.5em; | ||||
| } | ||||
							
								
								
									
										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', | ||||
| @ -391,9 +392,3 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|     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-isp">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' : ''"> | ||||
|     <div class="d-flex d-md-block align-items-baseline"> | ||||
|       <span i18n="mining.hashrate-difficulty">Hashrate & Difficulty</span> | ||||
|     <button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()"> | ||||
|       <button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()"> | ||||
|         <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon> | ||||
|       </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 (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>`; | ||||
|               } | ||||
|             } else if (tick.seriesIndex === 2) { // Hashrate MA
 | ||||
|               let hashrate = tick.data[1]; | ||||
|               if (this.isMobile()) { | ||||
|  | ||||
| @ -3,10 +3,13 @@ | ||||
| <div class="full-container"> | ||||
| 
 | ||||
|   <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" style="margin: 0 0 4px 0px" (click)="onSaveChart()"> | ||||
|       <button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()"> | ||||
|         <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon> | ||||
|       </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,35 @@ | ||||
| .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; | ||||
|   } | ||||
| 
 | ||||
|   .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,7 +1,12 @@ | ||||
| <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> | ||||
| @ -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> | ||||
|  | ||||
| @ -68,10 +68,6 @@ li.nav-item { | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .navbar-brand { | ||||
|   width: 60%; | ||||
| } | ||||
| 
 | ||||
| .navbar { | ||||
|   .dropdown { | ||||
|     .dropdown-toggle { | ||||
| @ -80,10 +76,8 @@ li.nav-item { | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| @media (min-width: 576px) { | ||||
| .navbar-brand { | ||||
|     width: 140px; | ||||
|   } | ||||
|   position: relative; | ||||
| } | ||||
| 
 | ||||
| nav { | ||||
| @ -93,8 +87,7 @@ nav { | ||||
| .connection-badge { | ||||
|   position: absolute; | ||||
|   top: 13px; | ||||
|   left: 0px; | ||||
|   width: 140px; | ||||
|   width: 100%; | ||||
| } | ||||
| 
 | ||||
| .badge { | ||||
| @ -145,3 +138,26 @@ 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; | ||||
| } | ||||
| 
 | ||||
| .logo-holder { | ||||
|   display: flex; | ||||
|   flex-direction: row; | ||||
| } | ||||
| 
 | ||||
| .navbar-brand { | ||||
|   flex-direction: row; | ||||
|   display: flex; | ||||
| } | ||||
|  | ||||
| @ -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"> | ||||
|     <div class="d-flex d-md-block align-items-baseline"> | ||||
|       <span i18n="mining.pools">Pools Ranking</span> | ||||
|     <button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart()"> | ||||
|       <button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()"> | ||||
|         <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon> | ||||
|       </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) { | ||||
| @keyframes bang{ | ||||
|   to{ | ||||
|     box-shadow:$box-shadow; | ||||
|       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> | ||||
|           <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" style="margin: 0 0 4px 0px" (click)="onSaveChart('mempool')"> | ||||
|             <button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart('mempool')"> | ||||
|               <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon> | ||||
|             </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,12 +92,13 @@ | ||||
|     <div> | ||||
|       <div class="card mb-3"> | ||||
|         <div class="card-header"> | ||||
|           <i class="fa fa-area-chart"></i> | ||||
|           <div class="d-flex d-md-block align-items-baseline"> | ||||
|             <span i18n="statistics.transaction-vbytes-per-second">Transaction vBytes per second (vB/s)</span> | ||||
|           <button class="btn" style="margin: 0 0 4px 0px" (click)="onSaveChart('incoming')"> | ||||
|             <button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart('incoming')"> | ||||
|               <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon> | ||||
|             </button> | ||||
|           </div>   | ||||
|         </div> | ||||
| 
 | ||||
|         <div class="card-body"> | ||||
|           <div class="incoming-transactions-graph"> | ||||
|  | ||||
| @ -210,4 +210,8 @@ export class StatisticsComponent implements OnInit { | ||||
|       this.incomingGraph.onSaveChart(this.timespan); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   isMobile() { | ||||
|     return (window.innerWidth <= 767.98); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -5,6 +5,9 @@ | ||||
|     <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> | ||||
|   </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="isTaproot; else noTaproot" class="badge badge-success mr-1" i18n-ngbTooltip="Taproot tooltip" ngbTooltip="This transaction uses Taproot" placement="bottom" i18n="tx-features.tag.taproot">Taproot</span> | ||||
| <ng-template #noTaproot> | ||||
|   <span class="badge badge-danger mr-1" i18n-ngbTooltip="No Taproot tooltip" ngbTooltip="This transaction could save on fees and improve privacy by using Taproot" placement="bottom"><del i18n="tx-features.tag.taproot">Taproot</del></span> | ||||
| </ng-template> | ||||
| <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> | ||||
| <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> | ||||
|  | ||||
| @ -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 { | ||||
|  | ||||
| @ -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: '' | ||||
|  | ||||
| @ -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-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,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; | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,189 @@ | ||||
| import { ChangeDetectionStrategy, Component, NgZone, OnDestroy, OnInit } from '@angular/core'; | ||||
| import { SeoService } from 'src/app/services/seo.service'; | ||||
| import { ApiService } from 'src/app/services/api.service'; | ||||
| import { Observable, tap, zip } from 'rxjs'; | ||||
| import { AssetsService } from 'src/app/services/assets.service'; | ||||
| 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'; | ||||
| import { EChartsOption, registerMap } from 'echarts'; | ||||
| import 'echarts-gl'; | ||||
| import { SSL_OP_SSLEAY_080_CLIENT_DH_BUG } from 'constants'; | ||||
| 
 | ||||
| @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 { | ||||
|   observable$: Observable<any>; | ||||
| 
 | ||||
|   chartInstance = undefined; | ||||
|   chartOptions: EChartsOption = {color: 'dark'}; | ||||
|   chartInitOptions = { | ||||
|     renderer: 'canvas', | ||||
|   }; | ||||
| 
 | ||||
|   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 channels world map`); | ||||
| 
 | ||||
|     this.observable$ = zip( | ||||
|       this.assetsService.getWorldMapJson$, | ||||
|       this.apiService.getChannelsGeo$(), | ||||
|     ).pipe(tap((data) => { | ||||
|       registerMap('world', data[0]); | ||||
| 
 | ||||
|       const channelsLoc = []; | ||||
|       const nodes = []; | ||||
|       for (const channel of data[1]) { | ||||
|         channelsLoc.push([[channel[2], channel[3]], [channel[6], channel[7]]]); | ||||
|         nodes.push({ | ||||
|           publicKey: channel[0], | ||||
|           name: channel[1], | ||||
|           value: [channel[2], channel[3]], | ||||
|         }); | ||||
|         nodes.push({ | ||||
|           publicKey: channel[4], | ||||
|           name: channel[5], | ||||
|           value: [channel[6], channel[7]], | ||||
|         }); | ||||
|       } | ||||
| 
 | ||||
|       this.prepareChartOptions(nodes, channelsLoc); | ||||
|     })); | ||||
|   } | ||||
| 
 | ||||
|   prepareChartOptions(nodes, channels) { | ||||
|     let title: object; | ||||
|     if (channels.length === 0) { | ||||
|       title = { | ||||
|         textStyle: { | ||||
|           color: 'grey', | ||||
|           fontSize: 15 | ||||
|         }, | ||||
|         text: $localize`No data to display yet`, | ||||
|         left: 'center', | ||||
|         top: 'center' | ||||
|       }; | ||||
|     } | ||||
| 
 | ||||
|     this.chartOptions = { | ||||
|       geo3D: { | ||||
|         map: 'world', | ||||
|         shading: 'color', | ||||
|         silent: true, | ||||
|         postEffect: { | ||||
|           enable: true, | ||||
|           bloom: { | ||||
|             intensity: 0.01, | ||||
|           } | ||||
|         }, | ||||
|         viewControl: { | ||||
|           minDistance: 1, | ||||
|           distance: 60, | ||||
|           alpha: 89, | ||||
|           panMouseButton: 'left', | ||||
|           rotateMouseButton: 'right', | ||||
|           zoomSensivity: 0.5, | ||||
|         }, | ||||
|         itemStyle: { | ||||
|           color: '#FFFFFF', | ||||
|           opacity: 0.02, | ||||
|           borderWidth: 1, | ||||
|           borderColor: 'black', | ||||
|         }, | ||||
|         regionHeight: 0.01, | ||||
|       }, | ||||
|       series: [ | ||||
|         { | ||||
|           // @ts-ignore
 | ||||
|           type: 'lines3D', | ||||
|           coordinateSystem: 'geo3D', | ||||
|           blendMode: 'lighter', | ||||
|           lineStyle: { | ||||
|             width: 1, | ||||
|             opacity: 0.025, | ||||
|           }, | ||||
|           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', | ||||
|               // @ts-ignore
 | ||||
|               textStyle: { | ||||
|                 color: 'white', | ||||
|                 fontSize: 16, | ||||
|               }, | ||||
|               formatter: function(value) { | ||||
|                 return value.name; | ||||
|               }, | ||||
|               show: true, | ||||
|             } | ||||
|           } | ||||
|         }, | ||||
|       ] | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   onChartInit(ec) { | ||||
|     if (this.chartInstance !== undefined) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     this.chartInstance = ec; | ||||
| 
 | ||||
|     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()"> | ||||
|     <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"> | ||||
|  | ||||
| @ -61,7 +61,7 @@ export class NodesNetworksChartComponent implements OnInit { | ||||
|     if (this.widget) { | ||||
|       this.miningWindowPreference = '1y'; | ||||
|     } 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 }); | ||||
|  | ||||
| @ -0,0 +1,58 @@ | ||||
| <div class="full-container h-100"> | ||||
| 
 | ||||
|   <div class="card-header"> | ||||
|     <div class="d-flex d-md-block align-items-baseline" style="margin-bottom: -5px"> | ||||
|       <span i18n="lightning.nodes-per-country">Lightning nodes per country</span> | ||||
|       <button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()"> | ||||
|         <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon> | ||||
|       </button> | ||||
|     </div> | ||||
|     <small style="color: #ffffff66" i18n="lightning.tor-nodes-excluded">(Tor nodes excluded)</small> | ||||
|   </div> | ||||
| 
 | ||||
|   <div class="container pb-lg-0 bottom-padding"> | ||||
|     <div class="pb-lg-5"> | ||||
|       <div class="chart w-100" echarts [initOpts]="chartInitOptions" [options]="chartOptions" | ||||
|         (chartInit)="onChartInit($event)"> | ||||
|       </div> | ||||
|     </div> | ||||
| 
 | ||||
|     <div class="text-center loadingGraphs" *ngIf="isLoading"> | ||||
|       <div class="spinner-border text-light"></div> | ||||
|     </div> | ||||
| 
 | ||||
|     <table class="table table-borderless text-center m-auto" style="max-width: 900px"> | ||||
|       <thead> | ||||
|         <tr> | ||||
|           <th class="text-left rank" i18n="mining.rank">Rank</th> | ||||
|           <th class="text-left name" i18n="lightning.as-name">Name</th> | ||||
|           <th class="text-right share" i18n="lightning.share">Share</th> | ||||
|           <th class="text-right nodes" i18n="lightning.nodes-count">Nodes</th> | ||||
|           <th class="text-right capacity" i18n="lightning.capacity">Capacity</th> | ||||
|         </tr> | ||||
|       </thead> | ||||
|       <tbody [attr.data-cy]="'pools-table'" *ngIf="(nodesPerCountryObservable$ | async) as countries"> | ||||
|         <tr *ngFor="let country of countries"> | ||||
|           <td class="text-left rank">{{ country.rank }}</td> | ||||
|           <td class="text-left text-truncate name"> | ||||
|             <div class="d-flex"> | ||||
|               <span style="font-size: 20px">{{ country.flag }}</span> | ||||
|                 | ||||
|               <a class="mt-auto mb-auto" [routerLink]="['/lightning/nodes/country' | relativeUrl, country.iso]">{{ country.name.en }}</a> | ||||
|             </div> | ||||
|           </td> | ||||
|           <td class="text-right share">{{ country.share }}%</td> | ||||
|           <td class="text-right nodes">{{ country.count }}</td> | ||||
|           <td class="text-right capacity"> | ||||
|             <app-amount *ngIf="country.capacity > 100000000; else smallchannel" [satoshis]="country.capacity" [digitsInfo]="'1.2-2'" [noFiat]="true"></app-amount> | ||||
|             <ng-template #smallchannel> | ||||
|               {{ country.capacity | amountShortener: 1 }} | ||||
|               <span class="sats" i18n="shared.sats">sats</span> | ||||
|             </ng-template> | ||||
|           </td> | ||||
|         </tr> | ||||
|       </tbody> | ||||
|     </table> | ||||
|   </div> | ||||
| 
 | ||||
| </div> | ||||
| @ -0,0 +1,81 @@ | ||||
| .sats { | ||||
|   color: #ffffff66; | ||||
|   font-size: 12px; | ||||
|   top: 0px; | ||||
| } | ||||
| 
 | ||||
| .card-header { | ||||
|   border-bottom: 0; | ||||
|   font-size: 18px; | ||||
|   @media (min-width: 465px) { | ||||
|     font-size: 20px; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .full-container { | ||||
|   padding: 0px 15px; | ||||
|   width: 100%; | ||||
|   height: calc(100% - 140px); | ||||
|   @media (max-width: 992px) { | ||||
|     height: calc(100% - 190px); | ||||
|   }; | ||||
|   @media (max-width: 575px) { | ||||
|     height: calc(100% - 230px); | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| .chart { | ||||
|   max-height: 400px; | ||||
|   @media (max-width: 767.98px) { | ||||
|     max-height: 230px; | ||||
|     margin-top: -35px; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .bottom-padding { | ||||
|   @media (max-width: 992px) { | ||||
|     padding-bottom: 65px | ||||
|   }; | ||||
|   @media (max-width: 576px) { | ||||
|     padding-bottom: 65px | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| .rank { | ||||
|   width: 20%; | ||||
|   @media (max-width: 576px) { | ||||
|     display: none | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .name { | ||||
|   width: 20%; | ||||
|   @media (max-width: 576px) { | ||||
|     width: 80%; | ||||
|     max-width: 150px; | ||||
|     padding-left: 0; | ||||
|     padding-right: 0; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .share { | ||||
|   width: 20%; | ||||
|   @media (max-width: 576px) { | ||||
|     display: none | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .nodes { | ||||
|   width: 20%; | ||||
|   @media (max-width: 576px) { | ||||
|     width: 10%; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .capacity { | ||||
|   width: 20%; | ||||
|   @media (max-width: 576px) { | ||||
|     width: 10%; | ||||
|     max-width: 100px; | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,235 @@ | ||||
| import { ChangeDetectionStrategy, Component, OnInit, HostBinding, NgZone } from '@angular/core'; | ||||
| import { Router } from '@angular/router'; | ||||
| import { EChartsOption, PieSeriesOption } from 'echarts'; | ||||
| import { map, Observable, share, tap } from 'rxjs'; | ||||
| import { chartColors } from 'src/app/app.constants'; | ||||
| import { ApiService } from 'src/app/services/api.service'; | ||||
| import { SeoService } from 'src/app/services/seo.service'; | ||||
| import { StateService } from 'src/app/services/state.service'; | ||||
| import { download } from 'src/app/shared/graphs.utils'; | ||||
| import { AmountShortenerPipe } from 'src/app/shared/pipes/amount-shortener.pipe'; | ||||
| import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe'; | ||||
| import { getFlagEmoji } from 'src/app/shared/graphs.utils'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-nodes-per-country-chart', | ||||
|   templateUrl: './nodes-per-country-chart.component.html', | ||||
|   styleUrls: ['./nodes-per-country-chart.component.scss'], | ||||
|   changeDetection: ChangeDetectionStrategy.OnPush, | ||||
| }) | ||||
| export class NodesPerCountryChartComponent implements OnInit { | ||||
|   miningWindowPreference: string; | ||||
| 
 | ||||
|   isLoading = true; | ||||
|   chartOptions: EChartsOption = {}; | ||||
|   chartInitOptions = { | ||||
|     renderer: 'svg', | ||||
|   }; | ||||
|   timespan = ''; | ||||
|   chartInstance: any = undefined; | ||||
| 
 | ||||
|   @HostBinding('attr.dir') dir = 'ltr'; | ||||
| 
 | ||||
|   nodesPerCountryObservable$: Observable<any>; | ||||
| 
 | ||||
|   constructor( | ||||
|     private apiService: ApiService, | ||||
|     private seoService: SeoService, | ||||
|     private amountShortenerPipe: AmountShortenerPipe, | ||||
|     private zone: NgZone, | ||||
|     private stateService: StateService, | ||||
|     private router: Router, | ||||
|   ) { | ||||
|   } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|     this.seoService.setTitle($localize`Lightning nodes per country`); | ||||
| 
 | ||||
|     this.nodesPerCountryObservable$ = this.apiService.getNodesPerCountry() | ||||
|       .pipe( | ||||
|         map(data => { | ||||
|           for (let i = 0; i < data.length; ++i) { | ||||
|             data[i].rank = i + 1; | ||||
|             data[i].iso = data[i].iso.toLowerCase(); | ||||
|             data[i].flag = getFlagEmoji(data[i].iso); | ||||
|           } | ||||
|           return data.slice(0, 100); | ||||
|         }), | ||||
|         tap(data => { | ||||
|           this.isLoading = false; | ||||
|           this.prepareChartOptions(data); | ||||
|         }), | ||||
|         share() | ||||
|       ); | ||||
|   } | ||||
| 
 | ||||
|   generateChartSerieData(country) { | ||||
|     const shareThreshold = this.isMobile() ? 2 : 1; | ||||
|     const data: object[] = []; | ||||
|     let totalShareOther = 0; | ||||
|     let totalNodeOther = 0; | ||||
| 
 | ||||
|     let edgeDistance: string | number = '10%'; | ||||
|     if (this.isMobile()) { | ||||
|       edgeDistance = 0; | ||||
|     } | ||||
| 
 | ||||
|     country.forEach((country) => { | ||||
|       if (country.share < shareThreshold) { | ||||
|         totalShareOther += country.share; | ||||
|         totalNodeOther += country.count; | ||||
|         return; | ||||
|       } | ||||
|       data.push({ | ||||
|         value: country.share, | ||||
|         name: country.name.en + (this.isMobile() ? `` : ` (${country.share}%)`), | ||||
|         label: { | ||||
|           overflow: 'truncate', | ||||
|           color: '#b1b1b1', | ||||
|           alignTo: 'edge', | ||||
|           edgeDistance: edgeDistance, | ||||
|         }, | ||||
|         tooltip: { | ||||
|           show: !this.isMobile(), | ||||
|           backgroundColor: 'rgba(17, 19, 31, 1)', | ||||
|           borderRadius: 4, | ||||
|           shadowColor: 'rgba(0, 0, 0, 0.5)', | ||||
|           textStyle: { | ||||
|             color: '#b1b1b1', | ||||
|           }, | ||||
|           borderColor: '#000', | ||||
|           formatter: () => { | ||||
|             return `<b style="color: white">${country.name.en} (${country.share}%)</b><br>` + | ||||
|               $localize`${country.count.toString()} nodes<br>` + | ||||
|               $localize`${this.amountShortenerPipe.transform(country.capacity / 100000000, 2)} BTC capacity` | ||||
|             ; | ||||
|           } | ||||
|         }, | ||||
|         data: country.iso, | ||||
|       } as PieSeriesOption); | ||||
|     }); | ||||
| 
 | ||||
|     // 'Other'
 | ||||
|     data.push({ | ||||
|       itemStyle: { | ||||
|         color: 'grey', | ||||
|       }, | ||||
|       value: totalShareOther, | ||||
|       name: 'Other' + (this.isMobile() ? `` : ` (${totalShareOther.toFixed(2)}%)`), | ||||
|       label: { | ||||
|         overflow: 'truncate', | ||||
|         color: '#b1b1b1', | ||||
|         alignTo: 'edge', | ||||
|         edgeDistance: edgeDistance | ||||
|       }, | ||||
|       tooltip: { | ||||
|         backgroundColor: 'rgba(17, 19, 31, 1)', | ||||
|         borderRadius: 4, | ||||
|         shadowColor: 'rgba(0, 0, 0, 0.5)', | ||||
|         textStyle: { | ||||
|           color: '#b1b1b1', | ||||
|         }, | ||||
|         borderColor: '#000', | ||||
|         formatter: () => { | ||||
|           return `<b style="color: white">${'Other'} (${totalShareOther.toFixed(2)}%)</b><br>` + | ||||
|             totalNodeOther.toString() + ` nodes`; | ||||
|         }, | ||||
|       }, | ||||
|       data: 9999 as any | ||||
|     } as PieSeriesOption); | ||||
| 
 | ||||
|     return data; | ||||
|   } | ||||
| 
 | ||||
|   prepareChartOptions(country) { | ||||
|     let pieSize = ['20%', '80%']; // Desktop
 | ||||
|     if (this.isMobile()) { | ||||
|       pieSize = ['15%', '60%']; | ||||
|     } | ||||
| 
 | ||||
|     this.chartOptions = { | ||||
|       animation: false, | ||||
|       color: chartColors, | ||||
|       tooltip: { | ||||
|         trigger: 'item', | ||||
|         textStyle: { | ||||
|           align: 'left', | ||||
|         } | ||||
|       }, | ||||
|       series: [ | ||||
|         { | ||||
|           zlevel: 0, | ||||
|           minShowLabelAngle: 3.6, | ||||
|           name: 'Mining pool', | ||||
|           type: 'pie', | ||||
|           radius: pieSize, | ||||
|           data: this.generateChartSerieData(country), | ||||
|           labelLine: { | ||||
|             lineStyle: { | ||||
|               width: 2, | ||||
|             }, | ||||
|             length: this.isMobile() ? 1 : 20, | ||||
|             length2: this.isMobile() ? 1 : undefined, | ||||
|           }, | ||||
|           label: { | ||||
|             fontSize: 14, | ||||
|           }, | ||||
|           itemStyle: { | ||||
|             borderRadius: 1, | ||||
|             borderWidth: 1, | ||||
|             borderColor: '#000', | ||||
|           }, | ||||
|           emphasis: { | ||||
|             itemStyle: { | ||||
|               shadowBlur: 40, | ||||
|               shadowColor: 'rgba(0, 0, 0, 0.75)', | ||||
|             }, | ||||
|             labelLine: { | ||||
|               lineStyle: { | ||||
|                 width: 4, | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|       ], | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   isMobile() { | ||||
|     return (window.innerWidth <= 767.98); | ||||
|   } | ||||
| 
 | ||||
|   onChartInit(ec) { | ||||
|     if (this.chartInstance !== undefined) { | ||||
|       return; | ||||
|     } | ||||
|     this.chartInstance = ec; | ||||
| 
 | ||||
|     this.chartInstance.on('click', (e) => { | ||||
|       if (e.data.data === 9999) { // "Other"
 | ||||
|         return; | ||||
|       } | ||||
|       this.zone.run(() => { | ||||
|         const url = new RelativeUrlPipe(this.stateService).transform(`/lightning/nodes/country/${e.data.data}`); | ||||
|         this.router.navigate([url]); | ||||
|       }); | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   onSaveChart() { | ||||
|     const now = new Date(); | ||||
|     this.chartOptions.backgroundColor = '#11131f'; | ||||
|     this.chartInstance.setOption(this.chartOptions); | ||||
|     download(this.chartInstance.getDataURL({ | ||||
|       pixelRatio: 2, | ||||
|       excludeComponents: ['dataZoom'], | ||||
|     }), `lightning-nodes-per-country-${Math.round(now.getTime() / 1000)}.svg`); | ||||
|     this.chartOptions.backgroundColor = 'none'; | ||||
|     this.chartInstance.setOption(this.chartOptions); | ||||
|   } | ||||
| 
 | ||||
|   isEllipsisActive(e) { | ||||
|     return (e.offsetWidth < e.scrollWidth); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| @ -0,0 +1,45 @@ | ||||
| <div class="container-xl full-height" style="min-height: 335px"> | ||||
|   <h1 class="float-left" i18n="lightning.nodes-in-country"> | ||||
|     <span>Lightning nodes in {{ country?.name }}</span> | ||||
|     <span style="font-size: 50px; vertical-align:sub;"> {{ country?.flag }}</span> | ||||
|   </h1> | ||||
| 
 | ||||
|   <div style="min-height: 295px"> | ||||
|     <table class="table table-borderless"> | ||||
|       <thead> | ||||
|         <th class="alias text-left" i18n="lightning.alias">Alias</th> | ||||
|         <th class="timestamp-first text-left" i18n="lightning.first_seen">First seen</th> | ||||
|         <th class="timestamp-update text-left" i18n="lightning.last_update">Last update</th> | ||||
|         <th class="capacity text-right" i18n="lightning.capacity">Capacity</th> | ||||
|         <th class="channels text-right" i18n="lightning.channels">Channels</th> | ||||
|         <th class="city text-right" i18n="lightning.city">City</th> | ||||
|       </thead> | ||||
|       <tbody *ngIf="nodes$ | async as nodes"> | ||||
|         <tr *ngFor="let node of nodes; let i= index; trackBy: trackByPublicKey"> | ||||
|           <td class="alias text-left text-truncate"> | ||||
|             <a [routerLink]="['/lightning/node/' | relativeUrl, node.public_key]">{{ node.alias }}</a> | ||||
|           </td> | ||||
|           <td class="timestamp-first text-left"> | ||||
|             <app-timestamp [customFormat]="'yyyy-MM-dd'" [unixTime]="node.first_seen"></app-timestamp> | ||||
|           </td> | ||||
|           <td class="timestamp-update text-left"> | ||||
|             <app-timestamp [customFormat]="'yyyy-MM-dd'" [unixTime]="node.updated_at"></app-timestamp> | ||||
|           </td> | ||||
|           <td class="capacity text-right"> | ||||
|             <app-amount *ngIf="node.capacity > 100000000; else smallchannel" [satoshis]="node.capacity" [digitsInfo]="'1.2-2'" [noFiat]="true"></app-amount> | ||||
|             <ng-template #smallchannel> | ||||
|               {{ node.capacity | amountShortener: 1 }} | ||||
|               <span class="sats" i18n="shared.sats">sats</span> | ||||
|             </ng-template> | ||||
|           </td> | ||||
|           <td class="channels text-right"> | ||||
|             {{ node.channels }} | ||||
|           </td> | ||||
|           <td class="city text-right text-truncate"> | ||||
|             {{ node?.city?.en ?? '-' }} | ||||
|           </td> | ||||
|       </tbody> | ||||
|     </table> | ||||
|   </div> | ||||
| 
 | ||||
| </div> | ||||
| @ -0,0 +1,56 @@ | ||||
| .container-xl { | ||||
|   max-width: 1400px; | ||||
|   padding-bottom: 100px; | ||||
| } | ||||
| 
 | ||||
| .sats { | ||||
|   color: #ffffff66; | ||||
|   font-size: 12px; | ||||
|   top: 0px; | ||||
| } | ||||
| 
 | ||||
| .alias { | ||||
|   width: 30%; | ||||
|   max-width: 400px; | ||||
|   padding-right: 70px; | ||||
|   @media (max-width: 576px) { | ||||
|     width: 50%; | ||||
|     max-width: 150px; | ||||
|     padding-right: 0px; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .timestamp-first { | ||||
|   width: 20%; | ||||
|   @media (max-width: 576px) { | ||||
|     display: none | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .timestamp-update { | ||||
|   width: 16%; | ||||
|   @media (max-width: 576px) { | ||||
|     display: none | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .capacity { | ||||
|   width: 10%; | ||||
|   @media (max-width: 576px) { | ||||
|     width: 25%; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .channels { | ||||
|   width: 10%; | ||||
|   @media (max-width: 576px) { | ||||
|     width: 25%; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .city { | ||||
|   max-width: 150px; | ||||
|   @media (max-width: 576px) { | ||||
|     display: none | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,41 @@ | ||||
| import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; | ||||
| import { ActivatedRoute } from '@angular/router'; | ||||
| import { map, Observable } from 'rxjs'; | ||||
| import { ApiService } from 'src/app/services/api.service'; | ||||
| import { SeoService } from 'src/app/services/seo.service'; | ||||
| import { getFlagEmoji } from 'src/app/shared/graphs.utils'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-nodes-per-country', | ||||
|   templateUrl: './nodes-per-country.component.html', | ||||
|   styleUrls: ['./nodes-per-country.component.scss'], | ||||
|   changeDetection: ChangeDetectionStrategy.OnPush, | ||||
| }) | ||||
| export class NodesPerCountry implements OnInit { | ||||
|   nodes$: Observable<any>; | ||||
|   country: {name: string, flag: string}; | ||||
| 
 | ||||
|   constructor( | ||||
|     private apiService: ApiService, | ||||
|     private seoService: SeoService, | ||||
|     private route: ActivatedRoute, | ||||
|   ) { } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|     this.nodes$ = this.apiService.getNodeForCountry$(this.route.snapshot.params.country) | ||||
|       .pipe( | ||||
|         map(response => { | ||||
|           this.country = { | ||||
|             name: response.country.en, | ||||
|             flag: getFlagEmoji(this.route.snapshot.params.country) | ||||
|           }; | ||||
|           this.seoService.setTitle($localize`Lightning nodes in ${this.country.name}`); | ||||
|           return response.nodes; | ||||
|         }) | ||||
|       ); | ||||
|   } | ||||
| 
 | ||||
|   trackByPublicKey(index: number, node: any) { | ||||
|     return node.public_key; | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,48 @@ | ||||
| <div class="full-container h-100"> | ||||
| 
 | ||||
|   <div class="card-header"> | ||||
|     <div class="d-flex d-md-block align-items-baseline" style="margin-bottom: -5px"> | ||||
|       <span i18n="lightning.nodes-per-isp">Lightning nodes per ISP</span> | ||||
|       <button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()"> | ||||
|         <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon> | ||||
|       </button> | ||||
|     </div> | ||||
|     <small style="color: #ffffff66" i18n="lightning.tor-nodes-excluded">(Tor nodes excluded)</small> | ||||
|   </div> | ||||
| 
 | ||||
|   <div class="container pb-lg-0 bottom-padding"> | ||||
|     <div class="pb-lg-5" *ngIf="nodesPerAsObservable$ | async"> | ||||
|       <div class="chart w-100" echarts [initOpts]="chartInitOptions" [options]="chartOptions" | ||||
|         (chartInit)="onChartInit($event)"> | ||||
|       </div> | ||||
|     </div> | ||||
| 
 | ||||
|     <div class="text-center loadingGraphs" *ngIf="isLoading"> | ||||
|       <div class="spinner-border text-light"></div> | ||||
|     </div> | ||||
| 
 | ||||
|     <table class="table table-borderless text-center m-auto" style="max-width: 900px"> | ||||
|       <thead> | ||||
|         <tr> | ||||
|           <th class="rank text-left pl-0" i18n="mining.rank">Rank</th> | ||||
|           <th class="name text-left" i18n="lightning.isp">ISP</th> | ||||
|           <th class="share text-right" i18n="lightning.share">Share</th> | ||||
|           <th class="nodes text-right" i18n="lightning.nodes-count">Nodes</th> | ||||
|           <th class="capacity text-right pr-0" i18n="lightning.capacity">Capacity</th> | ||||
|         </tr> | ||||
|       </thead> | ||||
|       <tbody [attr.data-cy]="'pools-table'" *ngIf="(nodesPerAsObservable$ | async) as asList"> | ||||
|         <tr *ngFor="let asEntry of asList"> | ||||
|           <td class="rank text-left pl-0">{{ asEntry.rank }}</td> | ||||
|           <td class="name text-left text-truncate"  style="max-width: 100px"> | ||||
|           <a [routerLink]="[('/lightning/nodes/isp/' + asEntry.ispId) | relativeUrl]">{{ asEntry.name }}</a> | ||||
|           </td> | ||||
|           <td class="share text-right">{{ asEntry.share }}%</td> | ||||
|           <td class="nodes text-right">{{ asEntry.count }}</td> | ||||
|           <td class="capacity text-right pr-0"><app-amount [satoshis]="asEntry.capacity" [digitsInfo]="'1.2-2'" [noFiat]="true"></app-amount></td> | ||||
|         </tr> | ||||
|       </tbody> | ||||
|     </table> | ||||
|   </div> | ||||
| 
 | ||||
| </div> | ||||
| @ -0,0 +1,75 @@ | ||||
| .card-header { | ||||
|   border-bottom: 0; | ||||
|   font-size: 18px; | ||||
|   @media (min-width: 465px) { | ||||
|     font-size: 20px; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .full-container { | ||||
|   padding: 0px 15px; | ||||
|   width: 100%; | ||||
|   height: calc(100% - 140px); | ||||
|   @media (max-width: 992px) { | ||||
|     height: calc(100% - 190px); | ||||
|   }; | ||||
|   @media (max-width: 575px) { | ||||
|     height: calc(100% - 230px); | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| .chart { | ||||
|   max-height: 400px; | ||||
|   @media (max-width: 767.98px) { | ||||
|     max-height: 230px; | ||||
|     margin-top: -35px; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .bottom-padding { | ||||
|   @media (max-width: 992px) { | ||||
|     padding-bottom: 65px | ||||
|   }; | ||||
|   @media (max-width: 576px) { | ||||
|     padding-bottom: 65px | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| .rank { | ||||
|   width: 20%; | ||||
|   @media (max-width: 576px) { | ||||
|     display: none | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .name { | ||||
|   width: 20%; | ||||
|   @media (max-width: 576px) { | ||||
|     width: 80%; | ||||
|     max-width: 150px; | ||||
|     padding-left: 0; | ||||
|     padding-right: 0; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .share { | ||||
|   width: 20%; | ||||
|   @media (max-width: 576px) { | ||||
|     display: none | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .nodes { | ||||
|   width: 20%; | ||||
|   @media (max-width: 576px) { | ||||
|     width: 10%; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .capacity { | ||||
|   width: 20%; | ||||
|   @media (max-width: 576px) { | ||||
|     width: 10%; | ||||
|     max-width: 100px; | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,231 @@ | ||||
| import { ChangeDetectionStrategy, Component, OnInit, HostBinding, NgZone } from '@angular/core'; | ||||
| import { Router } from '@angular/router'; | ||||
| import { EChartsOption, PieSeriesOption } from 'echarts'; | ||||
| import { map, Observable, share, tap } from 'rxjs'; | ||||
| import { chartColors } from 'src/app/app.constants'; | ||||
| import { ApiService } from 'src/app/services/api.service'; | ||||
| import { SeoService } from 'src/app/services/seo.service'; | ||||
| import { StateService } from 'src/app/services/state.service'; | ||||
| import { download } from 'src/app/shared/graphs.utils'; | ||||
| import { AmountShortenerPipe } from 'src/app/shared/pipes/amount-shortener.pipe'; | ||||
| import { RelativeUrlPipe } from 'src/app/shared/pipes/relative-url/relative-url.pipe'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-nodes-per-isp-chart', | ||||
|   templateUrl: './nodes-per-isp-chart.component.html', | ||||
|   styleUrls: ['./nodes-per-isp-chart.component.scss'], | ||||
|   changeDetection: ChangeDetectionStrategy.OnPush, | ||||
| }) | ||||
| export class NodesPerISPChartComponent implements OnInit { | ||||
|   miningWindowPreference: string; | ||||
| 
 | ||||
|   isLoading = true; | ||||
|   chartOptions: EChartsOption = {}; | ||||
|   chartInitOptions = { | ||||
|     renderer: 'svg', | ||||
|   }; | ||||
|   timespan = ''; | ||||
|   chartInstance: any = undefined; | ||||
| 
 | ||||
|   @HostBinding('attr.dir') dir = 'ltr'; | ||||
| 
 | ||||
|   nodesPerAsObservable$: Observable<any>; | ||||
| 
 | ||||
|   constructor( | ||||
|     private apiService: ApiService, | ||||
|     private seoService: SeoService, | ||||
|     private amountShortenerPipe: AmountShortenerPipe, | ||||
|     private router: Router, | ||||
|     private zone: NgZone, | ||||
|     private stateService: StateService, | ||||
|   ) { | ||||
|   } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|     this.seoService.setTitle($localize`Lightning nodes per ISP`); | ||||
| 
 | ||||
|     this.nodesPerAsObservable$ = this.apiService.getNodesPerAs() | ||||
|       .pipe( | ||||
|         tap(data => { | ||||
|           this.isLoading = false; | ||||
|           this.prepareChartOptions(data); | ||||
|         }), | ||||
|         map(data => { | ||||
|           for (let i = 0; i < data.length; ++i) { | ||||
|             data[i].rank = i + 1; | ||||
|           } | ||||
|           return data.slice(0, 100); | ||||
|         }), | ||||
|         share() | ||||
|       ); | ||||
|   } | ||||
| 
 | ||||
|   generateChartSerieData(as) { | ||||
|     const shareThreshold = this.isMobile() ? 2 : 1; | ||||
|     const data: object[] = []; | ||||
|     let totalShareOther = 0; | ||||
|     let totalNodeOther = 0; | ||||
| 
 | ||||
|     let edgeDistance: string | number = '10%'; | ||||
|     if (this.isMobile()) { | ||||
|       edgeDistance = 0; | ||||
|     } | ||||
| 
 | ||||
|     as.forEach((as) => { | ||||
|       if (as.share < shareThreshold) { | ||||
|         totalShareOther += as.share; | ||||
|         totalNodeOther += as.count; | ||||
|         return; | ||||
|       } | ||||
|       data.push({ | ||||
|         value: as.share, | ||||
|         name: as.name + (this.isMobile() ? `` : ` (${as.share}%)`), | ||||
|         label: { | ||||
|           overflow: 'truncate', | ||||
|           color: '#b1b1b1', | ||||
|           alignTo: 'edge', | ||||
|           edgeDistance: edgeDistance, | ||||
|         }, | ||||
|         tooltip: { | ||||
|           show: !this.isMobile(), | ||||
|           backgroundColor: 'rgba(17, 19, 31, 1)', | ||||
|           borderRadius: 4, | ||||
|           shadowColor: 'rgba(0, 0, 0, 0.5)', | ||||
|           textStyle: { | ||||
|             color: '#b1b1b1', | ||||
|           }, | ||||
|           borderColor: '#000', | ||||
|           formatter: () => { | ||||
|             return `<b style="color: white">${as.name} (${as.share}%)</b><br>` + | ||||
|               $localize`${as.count.toString()} nodes<br>` + | ||||
|               $localize`${this.amountShortenerPipe.transform(as.capacity / 100000000, 2)} BTC capacity` | ||||
|             ; | ||||
|           } | ||||
|         }, | ||||
|         data: as.ispId, | ||||
|       } as PieSeriesOption); | ||||
|     }); | ||||
| 
 | ||||
|     // 'Other'
 | ||||
|     data.push({ | ||||
|       itemStyle: { | ||||
|         color: 'grey', | ||||
|       }, | ||||
|       value: totalShareOther, | ||||
|       name: 'Other' + (this.isMobile() ? `` : ` (${totalShareOther.toFixed(2)}%)`), | ||||
|       label: { | ||||
|         overflow: 'truncate', | ||||
|         color: '#b1b1b1', | ||||
|         alignTo: 'edge', | ||||
|         edgeDistance: edgeDistance | ||||
|       }, | ||||
|       tooltip: { | ||||
|         backgroundColor: 'rgba(17, 19, 31, 1)', | ||||
|         borderRadius: 4, | ||||
|         shadowColor: 'rgba(0, 0, 0, 0.5)', | ||||
|         textStyle: { | ||||
|           color: '#b1b1b1', | ||||
|         }, | ||||
|         borderColor: '#000', | ||||
|         formatter: () => { | ||||
|           return `<b style="color: white">${'Other'} (${totalShareOther.toFixed(2)}%)</b><br>` + | ||||
|             totalNodeOther.toString() + ` nodes`; | ||||
|         } | ||||
|       }, | ||||
|       data: 9999 as any, | ||||
|     } as PieSeriesOption); | ||||
| 
 | ||||
|     return data; | ||||
|   } | ||||
| 
 | ||||
|   prepareChartOptions(as) { | ||||
|     let pieSize = ['20%', '80%']; // Desktop
 | ||||
|     if (this.isMobile()) { | ||||
|       pieSize = ['15%', '60%']; | ||||
|     } | ||||
| 
 | ||||
|     this.chartOptions = { | ||||
|       color: chartColors, | ||||
|       tooltip: { | ||||
|         trigger: 'item', | ||||
|         textStyle: { | ||||
|           align: 'left', | ||||
|         } | ||||
|       }, | ||||
|       series: [ | ||||
|         { | ||||
|           zlevel: 0, | ||||
|           minShowLabelAngle: 3.6, | ||||
|           name: 'Lightning nodes', | ||||
|           type: 'pie', | ||||
|           radius: pieSize, | ||||
|           data: this.generateChartSerieData(as), | ||||
|           labelLine: { | ||||
|             lineStyle: { | ||||
|               width: 2, | ||||
|             }, | ||||
|             length: this.isMobile() ? 1 : 20, | ||||
|             length2: this.isMobile() ? 1 : undefined, | ||||
|           }, | ||||
|           label: { | ||||
|             fontSize: 14, | ||||
|           }, | ||||
|           itemStyle: { | ||||
|             borderRadius: 1, | ||||
|             borderWidth: 1, | ||||
|             borderColor: '#000', | ||||
|           }, | ||||
|           emphasis: { | ||||
|             itemStyle: { | ||||
|               shadowBlur: 40, | ||||
|               shadowColor: 'rgba(0, 0, 0, 0.75)', | ||||
|             }, | ||||
|             labelLine: { | ||||
|               lineStyle: { | ||||
|                 width: 4, | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|       ], | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   isMobile() { | ||||
|     return (window.innerWidth <= 767.98); | ||||
|   } | ||||
| 
 | ||||
|   onChartInit(ec) { | ||||
|     if (this.chartInstance !== undefined) { | ||||
|       return; | ||||
|     } | ||||
|     this.chartInstance = ec; | ||||
| 
 | ||||
|     this.chartInstance.on('click', (e) => { | ||||
|       if (e.data.data === 9999) { // "Other"
 | ||||
|         return; | ||||
|       } | ||||
|       this.zone.run(() => { | ||||
|         const url = new RelativeUrlPipe(this.stateService).transform(`/lightning/nodes/isp/${e.data.data}`); | ||||
|         this.router.navigate([url]); | ||||
|       }); | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   onSaveChart() { | ||||
|     const now = new Date(); | ||||
|     this.chartOptions.backgroundColor = '#11131f'; | ||||
|     this.chartInstance.setOption(this.chartOptions); | ||||
|     download(this.chartInstance.getDataURL({ | ||||
|       pixelRatio: 2, | ||||
|       excludeComponents: ['dataZoom'], | ||||
|     }), `ln-nodes-per-as-${this.timespan}-${Math.round(now.getTime() / 1000)}.svg`); | ||||
|     this.chartOptions.backgroundColor = 'none'; | ||||
|     this.chartInstance.setOption(this.chartOptions); | ||||
|   } | ||||
| 
 | ||||
|   isEllipsisActive(e) { | ||||
|     return (e.offsetWidth < e.scrollWidth); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| @ -0,0 +1,42 @@ | ||||
| <div class="container-xl full-height" style="min-height: 335px"> | ||||
|   <h1 class="float-left" i18n="lightning.nodes-for-isp">Lightning nodes on ISP: {{ isp?.name }} [AS {{isp?.id}}]</h1> | ||||
| 
 | ||||
|   <div style="min-height: 295px"> | ||||
|     <table class="table table-borderless"> | ||||
|       <thead> | ||||
|         <th class="alias text-left" i18n="lightning.alias">Alias</th> | ||||
|         <th class="timestamp-first text-left" i18n="lightning.first_seen">First seen</th> | ||||
|         <th class="timestamp-update text-left" i18n="lightning.last_update">Last update</th> | ||||
|         <th class="capacity text-right" i18n="lightning.capacity">Capacity</th> | ||||
|         <th class="channels text-right" i18n="lightning.channels">Channels</th> | ||||
|         <th class="city text-right" i18n="lightning.city">City</th> | ||||
|       </thead> | ||||
|       <tbody *ngIf="nodes$ | async as nodes"> | ||||
|         <tr *ngFor="let node of nodes; let i= index; trackBy: trackByPublicKey"> | ||||
|           <td class="alias text-left text-truncate"> | ||||
|             <a [routerLink]="['/lightning/node/' | relativeUrl, node.public_key]">{{ node.alias }}</a> | ||||
|           </td> | ||||
|           <td class="timestamp-first text-left"> | ||||
|             <app-timestamp [customFormat]="'yyyy-MM-dd'" [unixTime]="node.first_seen"></app-timestamp> | ||||
|           </td> | ||||
|           <td class="timestamp-update text-left"> | ||||
|             <app-timestamp [customFormat]="'yyyy-MM-dd'" [unixTime]="node.updated_at"></app-timestamp> | ||||
|           </td> | ||||
|           <td class="capacity text-right"> | ||||
|             <app-amount *ngIf="node.capacity > 100000000; else smallchannel" [satoshis]="node.capacity" [digitsInfo]="'1.2-2'" [noFiat]="true"></app-amount> | ||||
|             <ng-template #smallchannel> | ||||
|               {{ node.capacity | amountShortener: 1 }} | ||||
|               <span class="sats" i18n="shared.sats">sats</span> | ||||
|             </ng-template> | ||||
|           </td> | ||||
|           <td class="channels text-right"> | ||||
|             {{ node.channels }} | ||||
|           </td> | ||||
|           <td class="city text-right text-truncate"> | ||||
|             {{ node?.city?.en ?? '-' }} | ||||
|           </td> | ||||
|       </tbody> | ||||
|     </table> | ||||
|   </div> | ||||
| 
 | ||||
| </div> | ||||
| @ -0,0 +1,62 @@ | ||||
| .container-xl { | ||||
|   max-width: 1400px; | ||||
|   padding-bottom: 100px; | ||||
| } | ||||
| 
 | ||||
| .sats { | ||||
|   color: #ffffff66; | ||||
|   font-size: 12px; | ||||
|   top: 0px; | ||||
| } | ||||
| 
 | ||||
| .alias { | ||||
|   width: 30%; | ||||
|   max-width: 400px; | ||||
|   padding-right: 70px; | ||||
| 
 | ||||
|   @media (max-width: 576px) { | ||||
|     width: 50%; | ||||
|     max-width: 150px; | ||||
|     padding-right: 0px; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .timestamp-first { | ||||
|   width: 20%; | ||||
| 
 | ||||
|   @media (max-width: 576px) { | ||||
|     display: none | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .timestamp-update { | ||||
|   width: 16%; | ||||
| 
 | ||||
|   @media (max-width: 576px) { | ||||
|     display: none | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .capacity { | ||||
|   width: 10%; | ||||
| 
 | ||||
|   @media (max-width: 576px) { | ||||
|     width: 25%; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .channels { | ||||
|   width: 10%; | ||||
| 
 | ||||
|   @media (max-width: 576px) { | ||||
|     width: 25%; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .city { | ||||
|   max-width: 150px; | ||||
| 
 | ||||
|   @media (max-width: 576px) { | ||||
|     display: none | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,40 @@ | ||||
| import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; | ||||
| import { ActivatedRoute } from '@angular/router'; | ||||
| import { map, Observable } from 'rxjs'; | ||||
| import { ApiService } from 'src/app/services/api.service'; | ||||
| import { SeoService } from 'src/app/services/seo.service'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-nodes-per-isp', | ||||
|   templateUrl: './nodes-per-isp.component.html', | ||||
|   styleUrls: ['./nodes-per-isp.component.scss'], | ||||
|   changeDetection: ChangeDetectionStrategy.OnPush, | ||||
| }) | ||||
| export class NodesPerISP implements OnInit { | ||||
|   nodes$: Observable<any>; | ||||
|   isp: {name: string, id: number}; | ||||
| 
 | ||||
|   constructor( | ||||
|     private apiService: ApiService, | ||||
|     private seoService: SeoService, | ||||
|     private route: ActivatedRoute, | ||||
|   ) { } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|     this.nodes$ = this.apiService.getNodeForISP$(this.route.snapshot.params.isp) | ||||
|       .pipe( | ||||
|         map(response => { | ||||
|           this.isp = { | ||||
|             name: response.isp, | ||||
|             id: this.route.snapshot.params.isp | ||||
|           }; | ||||
|           this.seoService.setTitle($localize`Lightning nodes on ISP: ${response.isp} [AS${this.route.snapshot.params.isp}]`); | ||||
|           return response.nodes; | ||||
|         }) | ||||
|       ); | ||||
|   } | ||||
| 
 | ||||
|   trackByPublicKey(index: number, node: any) { | ||||
|     return node.public_key; | ||||
|   } | ||||
| } | ||||
| @ -1,10 +1,12 @@ | ||||
| <div [class]="widget === false ? 'full-container' : ''"> | ||||
| 
 | ||||
|   <div class="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()"> | ||||
|     <div class="d-flex d-md-block align-items-baseline"> | ||||
|       <span i18n="mining.channels-and-capacity">Channels & Capacity</span> | ||||
|       <button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()"> | ||||
|         <fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon> | ||||
|       </button> | ||||
|     </div> | ||||
| 
 | ||||
|     <form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(capacityObservable$ | async) as stats"> | ||||
|       <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="dateSpan"> | ||||
|  | ||||
| @ -228,10 +228,20 @@ export class ApiService { | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   getBlockAudit$(hash: string) : Observable<any> { | ||||
|     return this.httpClient.get<any>( | ||||
|       this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/blocks/audit/` + hash, { observe: 'response' } | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   getRewardStats$(blockCount: number = 144): Observable<RewardStats> { | ||||
|     return this.httpClient.get<RewardStats>(this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/reward-stats/${blockCount}`); | ||||
|   } | ||||
| 
 | ||||
|   getEnterpriseInfo$(name: string): Observable<any> { | ||||
|     return this.httpClient.get<any>(this.apiBaseUrl + this.apiBasePath + `/api/v1/enterprise/info/` + name); | ||||
|   } | ||||
| 
 | ||||
|   getChannelByTxIds$(txIds: string[]): Observable<{ inputs: any[], outputs: any[] }> { | ||||
|     let params = new HttpParams(); | ||||
|     txIds.forEach((txId: string) => { | ||||
| @ -245,4 +255,23 @@ export class ApiService { | ||||
|     return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/search', { params }); | ||||
|   } | ||||
| 
 | ||||
|   getNodesPerAs(): Observable<any> { | ||||
|     return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/isp'); | ||||
|   } | ||||
| 
 | ||||
|   getNodeForCountry$(country: string): Observable<any> { | ||||
|     return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/country/' + country); | ||||
|   } | ||||
| 
 | ||||
|   getNodeForISP$(isp: string): Observable<any> { | ||||
|     return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/isp/' + isp); | ||||
|   } | ||||
| 
 | ||||
|   getNodesPerCountry(): Observable<any> { | ||||
|     return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/nodes/countries'); | ||||
|   } | ||||
| 
 | ||||
|   getChannelsGeo$(): Observable<any> { | ||||
|     return this.httpClient.get<any[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/lightning/channels-geo'); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -14,6 +14,7 @@ export class AssetsService { | ||||
| 
 | ||||
|   getAssetsJson$: Observable<{ array: AssetExtended[]; objects: any}>; | ||||
|   getAssetsMinimalJson$: Observable<any>; | ||||
|   getWorldMapJson$: Observable<any>; | ||||
| 
 | ||||
|   constructor( | ||||
|     private httpClient: HttpClient, | ||||
| @ -65,5 +66,7 @@ export class AssetsService { | ||||
|       }), | ||||
|       shareReplay(1), | ||||
|     ); | ||||
| 
 | ||||
|     this.getWorldMapJson$ = this.httpClient.get(apiBaseUrl + '/resources/worldmap.json').pipe(shareReplay()); | ||||
|   } | ||||
| } | ||||
|  | ||||
							
								
								
									
										79
									
								
								frontend/src/app/services/enterprise.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								frontend/src/app/services/enterprise.service.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,79 @@ | ||||
| import { DOCUMENT } from '@angular/common'; | ||||
| import { Inject, Injectable } from '@angular/core'; | ||||
| import { ApiService } from './api.service'; | ||||
| import { SeoService } from './seo.service'; | ||||
| import { StateService } from './state.service'; | ||||
| 
 | ||||
| @Injectable({ | ||||
|   providedIn: 'root' | ||||
| }) | ||||
| export class EnterpriseService { | ||||
|   exclusiveHostName = '.mempool.space'; | ||||
|   subdomain: string | null = null; | ||||
|   info: object = {}; | ||||
| 
 | ||||
|   constructor( | ||||
|     @Inject(DOCUMENT) private document: Document, | ||||
|     private apiService: ApiService, | ||||
|     private seoService: SeoService, | ||||
|     private stateService: StateService, | ||||
|   ) { | ||||
|     const subdomain = this.document.location.hostname.indexOf(this.exclusiveHostName) > -1 | ||||
|       && this.document.location.hostname.split(this.exclusiveHostName)[0] || false; | ||||
|     if (subdomain && subdomain.match(/^[A-z0-9-_]+$/)) { | ||||
|       this.subdomain = subdomain; | ||||
|       this.fetchSubdomainInfo(); | ||||
|       this.disableSubnetworks(); | ||||
|     } else { | ||||
|       this.insertMatomo(); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   getSubdomain() { | ||||
|     return this.subdomain; | ||||
|   } | ||||
| 
 | ||||
|   disableSubnetworks() { | ||||
|     this.stateService.env.TESTNET_ENABLED = false; | ||||
|     this.stateService.env.LIQUID_ENABLED = false; | ||||
|     this.stateService.env.LIQUID_TESTNET_ENABLED = false; | ||||
|     this.stateService.env.SIGNET_ENABLED = false; | ||||
|     this.stateService.env.BISQ_ENABLED = false; | ||||
|   } | ||||
| 
 | ||||
|   fetchSubdomainInfo() { | ||||
|     this.apiService.getEnterpriseInfo$(this.subdomain).subscribe((info) => { | ||||
|       this.info = info; | ||||
|       this.insertMatomo(info.site_id); | ||||
|       this.seoService.setEnterpriseTitle(info.title); | ||||
|     }, | ||||
|     (error) => { | ||||
|       if (error.status === 404) { | ||||
|         window.location.href = 'https://mempool.space'; | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   insertMatomo(siteId = 5) { | ||||
|     let statsUrl = '//stats.mempool.space/'; | ||||
|     if (this.document.location.hostname === 'liquid.network') { | ||||
|       statsUrl = '//stats.liquid.network/'; | ||||
|       siteId = 8; | ||||
|     } else if (this.document.location.hostname === 'bisq.markets') { | ||||
|       statsUrl = '//stats.bisq.markets/'; | ||||
|       siteId = 7; | ||||
|     } | ||||
| 
 | ||||
|     // @ts-ignore
 | ||||
|     const _paq = window._paq = window._paq || []; | ||||
|     _paq.push(['disableCookies']); | ||||
|     _paq.push(['trackPageView']); | ||||
|     _paq.push(['enableLinkTracking']); | ||||
|     (function() { | ||||
|       _paq.push(['setTrackerUrl', statsUrl+'m.php']); | ||||
|       _paq.push(['setSiteId', siteId.toString()]); | ||||
|       const d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; | ||||
|       g.type='text/javascript'; g.async=true; g.src=statsUrl+'m.js'; s.parentNode.insertBefore(g,s); | ||||
|     })(); | ||||
|   } | ||||
| } | ||||
| @ -7,6 +7,7 @@ import { StateService } from './state.service'; | ||||
| }) | ||||
| export class SeoService { | ||||
|   network = ''; | ||||
|   baseTitle = 'mempool'; | ||||
| 
 | ||||
|   constructor( | ||||
|     private titleService: Title, | ||||
| @ -26,18 +27,23 @@ export class SeoService { | ||||
|     this.metaService.updateTag({ property: 'og:title', content: this.getTitle()}); | ||||
|   } | ||||
| 
 | ||||
|   setEnterpriseTitle(title: string) { | ||||
|     this.baseTitle = title + ' - ' + this.baseTitle; | ||||
|     this.resetTitle(); | ||||
|   } | ||||
| 
 | ||||
|   getTitle(): string { | ||||
|     if (this.network === 'testnet') | ||||
|       return 'mempool - Bitcoin Testnet'; | ||||
|       return this.baseTitle + ' - Bitcoin Testnet'; | ||||
|     if (this.network === 'signet') | ||||
|       return 'mempool - Bitcoin Signet'; | ||||
|       return this.baseTitle + ' - Bitcoin Signet'; | ||||
|     if (this.network === 'liquid') | ||||
|       return 'mempool - Liquid Network'; | ||||
|       return this.baseTitle + ' - Liquid Network'; | ||||
|     if (this.network === 'liquidtestnet') | ||||
|       return 'mempool - Liquid Testnet'; | ||||
|       return this.baseTitle + ' - Liquid Testnet'; | ||||
|     if (this.network === 'bisq') | ||||
|       return 'mempool - Bisq Markets'; | ||||
|     return 'mempool - ' + (this.network ? this.ucfirst(this.network) : 'Bitcoin') + ' Explorer'; | ||||
|       return this.baseTitle + ' - Bisq Markets'; | ||||
|     return this.baseTitle + ' - ' + (this.network ? this.ucfirst(this.network) : 'Bitcoin') + ' Explorer'; | ||||
|   } | ||||
| 
 | ||||
|   ucfirst(str: string) { | ||||
|  | ||||
| @ -1,4 +1,4 @@ | ||||
| ‎{{ seconds * 1000 | date:'yyyy-MM-dd HH:mm' }} | ||||
| ‎{{ seconds * 1000 | date: customFormat ?? 'yyyy-MM-dd HH:mm' }} | ||||
| <div class="lg-inline"> | ||||
|   <i class="symbol">(<app-time-since [time]="seconds" [fastRender]="true"></app-time-since>)</i> | ||||
| </div> | ||||
|  | ||||
| @ -9,6 +9,7 @@ import { ChangeDetectionStrategy, Component, Input, OnChanges } from '@angular/c | ||||
| export class TimestampComponent implements OnChanges { | ||||
|   @Input() unixTime: number; | ||||
|   @Input() dateString: string; | ||||
|   @Input() customFormat: string; | ||||
| 
 | ||||
|   seconds: number; | ||||
| 
 | ||||
|  | ||||
| @ -84,3 +84,17 @@ export const download = (href, name) => { | ||||
|   a.click(); | ||||
|   document.body.removeChild(a); | ||||
| }; | ||||
| 
 | ||||
| export function detectWebGL() { | ||||
|   const canvas = document.createElement('canvas'); | ||||
|   const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl'); | ||||
|   return (gl && gl instanceof WebGLRenderingContext); | ||||
| } | ||||
| 
 | ||||
| export function getFlagEmoji(countryCode) { | ||||
|   const codePoints = countryCode | ||||
|     .toUpperCase() | ||||
|     .split('') | ||||
|     .map(char =>  127397 + char.charCodeAt()); | ||||
|   return String.fromCodePoint(...codePoints); | ||||
| } | ||||
|  | ||||
| @ -7,6 +7,7 @@ import { faFilter, faAngleDown, faAngleUp, faAngleRight, faAngleLeft, faBolt, fa | ||||
|   faFileAlt, faRedoAlt, faArrowAltCircleRight, faExternalLinkAlt, faBook, faListUl, faDownload, faQrcode } from '@fortawesome/free-solid-svg-icons'; | ||||
| import { InfiniteScrollModule } from 'ngx-infinite-scroll'; | ||||
| import { MasterPageComponent } from '../components/master-page/master-page.component'; | ||||
| import { MasterPagePreviewComponent } from '../components/master-page-preview/master-page-preview.component'; | ||||
| import { BisqMasterPageComponent } from '../components/bisq-master-page/bisq-master-page.component'; | ||||
| import { LiquidMasterPageComponent } from '../components/liquid-master-page/liquid-master-page.component'; | ||||
| import { AboutComponent } from '../components/about/about.component'; | ||||
| @ -44,6 +45,8 @@ import { StartComponent } from '../components/start/start.component'; | ||||
| import { TransactionComponent } from '../components/transaction/transaction.component'; | ||||
| import { TransactionsListComponent } from '../components/transactions-list/transactions-list.component'; | ||||
| import { BlockComponent } from '../components/block/block.component'; | ||||
| import { BlockPreviewComponent } from '../components/block/block-preview.component'; | ||||
| import { BlockAuditComponent } from '../components/block-audit/block-audit.component'; | ||||
| import { BlockOverviewGraphComponent } from '../components/block-overview-graph/block-overview-graph.component'; | ||||
| import { BlockOverviewTooltipComponent } from '../components/block-overview-tooltip/block-overview-tooltip.component'; | ||||
| import { AddressComponent } from '../components/address/address.component'; | ||||
| @ -109,11 +112,14 @@ import { TimestampComponent } from './components/timestamp/timestamp.component'; | ||||
|     AmountComponent, | ||||
|     AboutComponent, | ||||
|     MasterPageComponent, | ||||
|     MasterPagePreviewComponent, | ||||
|     BisqMasterPageComponent, | ||||
|     LiquidMasterPageComponent, | ||||
|     StartComponent, | ||||
|     TransactionComponent, | ||||
|     BlockComponent, | ||||
|     BlockPreviewComponent, | ||||
|     BlockAuditComponent, | ||||
|     BlockOverviewGraphComponent, | ||||
|     BlockOverviewTooltipComponent, | ||||
|     TransactionsListComponent, | ||||
| @ -213,6 +219,8 @@ import { TimestampComponent } from './components/timestamp/timestamp.component'; | ||||
|     StartComponent, | ||||
|     TransactionComponent, | ||||
|     BlockComponent, | ||||
|     BlockPreviewComponent, | ||||
|     BlockAuditComponent, | ||||
|     BlockOverviewGraphComponent, | ||||
|     BlockOverviewTooltipComponent, | ||||
|     TransactionsListComponent, | ||||
|  | ||||
| @ -37,21 +37,5 @@ | ||||
| </head> | ||||
| <body> | ||||
|   <app-root></app-root> | ||||
|   <script type="text/javascript"> | ||||
|     if (document.location.hostname === "bisq.markets") | ||||
|     { | ||||
|       var _paq = window._paq = window._paq || []; | ||||
|       _paq.push(['disableCookies']); | ||||
|       _paq.push(['trackPageView']); | ||||
|       _paq.push(['enableLinkTracking']); | ||||
|       (function() { | ||||
|         var u="//stats.bisq.markets/"; | ||||
|         _paq.push(['setTrackerUrl', u+'m.php']); | ||||
|         _paq.push(['setSiteId', '7']); | ||||
|         var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; | ||||
|         g.type='text/javascript'; g.async=true; g.src=u+'m.js'; s.parentNode.insertBefore(g,s); | ||||
|       })(); | ||||
|     } | ||||
|   </script> | ||||
| </body> | ||||
| </html> | ||||
|  | ||||
| @ -35,21 +35,5 @@ | ||||
| </head> | ||||
| <body> | ||||
|   <app-root></app-root> | ||||
|   <script type="text/javascript"> | ||||
|     if (document.location.hostname === "liquid.network") | ||||
|     { | ||||
|       var _paq = window._paq = window._paq || []; | ||||
|       _paq.push(['disableCookies']); | ||||
|       _paq.push(['trackPageView']); | ||||
|       _paq.push(['enableLinkTracking']); | ||||
|       (function() { | ||||
|         var u="//stats.liquid.network/"; | ||||
|         _paq.push(['setTrackerUrl', u+'m.php']); | ||||
|         _paq.push(['setSiteId', '8']); | ||||
|         var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; | ||||
|         g.type='text/javascript'; g.async=true; g.src=u+'m.js'; s.parentNode.insertBefore(g,s); | ||||
|       })(); | ||||
|     } | ||||
|   </script> | ||||
| </body> | ||||
| </html> | ||||
|  | ||||
| @ -34,21 +34,5 @@ | ||||
| </head> | ||||
| <body> | ||||
|   <app-root></app-root> | ||||
|   <script type="text/javascript"> | ||||
|     if (document.location.hostname === "mempool.space") | ||||
|     { | ||||
|       var _paq = window._paq = window._paq || []; | ||||
|       _paq.push(['disableCookies']); | ||||
|       _paq.push(['trackPageView']); | ||||
|       _paq.push(['enableLinkTracking']); | ||||
|       (function() { | ||||
|         var u="//stats.mempool.space/"; | ||||
|         _paq.push(['setTrackerUrl', u+'m.php']); | ||||
|         _paq.push(['setSiteId', '5']); | ||||
|         var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; | ||||
|         g.type='text/javascript'; g.async=true; g.src=u+'m.js'; s.parentNode.insertBefore(g,s); | ||||
|       })(); | ||||
|     } | ||||
|   </script> | ||||
| </body> | ||||
| </html> | ||||
|  | ||||
							
								
								
									
										1
									
								
								frontend/src/resources/worldmap.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								frontend/src/resources/worldmap.json
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| @ -70,6 +70,30 @@ location /api/v1/translators { | ||||
| 	proxy_hide_header content-security-policy; | ||||
| 	proxy_hide_header x-frame-options; | ||||
| } | ||||
| location /api/v1/enterprise/images { | ||||
| 	proxy_pass $mempoolSpaceServices; | ||||
| 	proxy_cache services; | ||||
| 	proxy_cache_background_update on; | ||||
| 	proxy_cache_use_stale updating; | ||||
| 	proxy_cache_valid 200 10m; | ||||
| 	expires 10m; | ||||
| 	proxy_hide_header onion-location; | ||||
| 	proxy_hide_header strict-transport-security; | ||||
| 	proxy_hide_header content-security-policy; | ||||
| 	proxy_hide_header x-frame-options; | ||||
| } | ||||
| location /api/v1/enterprise { | ||||
| 	proxy_pass $mempoolSpaceServices; | ||||
| 	proxy_cache services; | ||||
| 	proxy_cache_background_update on; | ||||
| 	proxy_cache_use_stale updating; | ||||
| 	proxy_cache_valid 200 5m; | ||||
| 	expires 5m; | ||||
| 	proxy_hide_header onion-location; | ||||
| 	proxy_hide_header strict-transport-security; | ||||
| 	proxy_hide_header content-security-policy; | ||||
| 	proxy_hide_header x-frame-options; | ||||
| } | ||||
| location /api/v1/assets { | ||||
| 	proxy_pass $mempoolSpaceServices; | ||||
| 	proxy_cache services; | ||||
|  | ||||
| @ -46,13 +46,13 @@ add_header Vary Cookie; | ||||
| # https://stackoverflow.com/questions/5238377/nginx-location-priority | ||||
| 
 | ||||
| # for exact / requests, redirect based on $lang | ||||
| # cache redirect for 10 minutes | ||||
| # cache redirect for 5 minutes | ||||
| location = / { | ||||
| 	if ($lang != '') { | ||||
| 		return 302 $scheme://$host/$lang/; | ||||
| 	} | ||||
| 	try_files /en-US/index.html =404; | ||||
| 	expires 10m; | ||||
| 	expires 5m; | ||||
| } | ||||
| 
 | ||||
| # used to rewrite resources from /<lang>/ to /en-US/ | ||||
| @ -66,14 +66,14 @@ location ~ ^/([a-z][a-z])/(.+\..+\.(js|css)) { | ||||
| 	try_files $uri =404; | ||||
| 	expires 1y; | ||||
| } | ||||
| # cache everything else for 10 minutes | ||||
| # cache everything else for 5 minutes | ||||
| location ~ ^/([a-z][a-z])$ { | ||||
| 	try_files $uri /$1/index.html /en-US/index.html =404; | ||||
|         expires 10m; | ||||
|         expires 5m; | ||||
| } | ||||
| location ~ ^/([a-z][a-z])/ { | ||||
| 	try_files $uri /$1/index.html /en-US/index.html =404; | ||||
|         expires 10m; | ||||
|         expires 5m; | ||||
| } | ||||
| 
 | ||||
| # cache /resources/** for 1 week since they don't change often | ||||
| @ -87,8 +87,8 @@ location ~* ^/.+\..+\.(js|css) { | ||||
| 	expires 1y; | ||||
| } | ||||
| # catch-all for all URLs i.e. /address/foo /tx/foo /block/000 | ||||
| # cache 10 minutes since they change frequently | ||||
| # cache 5 minutes since they change frequently | ||||
| location / { | ||||
| 	try_files /$lang/$uri $uri /en-US/$uri /en-US/index.html =404; | ||||
| 	expires 10m; | ||||
| 	expires 5m; | ||||
| } | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user