Commit 67a0d0e9 authored by Igor Stuev's avatar Igor Stuev Committed by GitHub

Unify stats across relevant pages (#2561)

* Unify stats across relevant pages

* add op stats to tx page and change stats titles

* demo config

* aboba or cat fix

* chart tooltip fix

* tests

* revert config
parent 06e5313b
...@@ -726,12 +726,34 @@ const schema = yup ...@@ -726,12 +726,34 @@ const schema = yup
.array() .array()
.transform(replaceQuotes) .transform(replaceQuotes)
.json() .json()
.of(yup.string<ChainIndicatorId>().oneOf(CHAIN_INDICATOR_IDS)), .of(yup.string<ChainIndicatorId>().oneOf(CHAIN_INDICATOR_IDS))
.test(
'stats-api-required',
'NEXT_PUBLIC_STATS_API_HOST is required when daily_operational_txs is enabled in NEXT_PUBLIC_HOMEPAGE_CHARTS',
function(value) {
// daily_operational_txs is presented only in stats microservice
if (value?.includes('daily_operational_txs')) {
return Boolean(this.parent.NEXT_PUBLIC_STATS_API_HOST);
}
return true;
}
),
NEXT_PUBLIC_HOMEPAGE_STATS: yup NEXT_PUBLIC_HOMEPAGE_STATS: yup
.array() .array()
.transform(replaceQuotes) .transform(replaceQuotes)
.json() .json()
.of(yup.string<HomeStatsWidgetId>().oneOf(HOME_STATS_WIDGET_IDS)), .of(yup.string<HomeStatsWidgetId>().oneOf(HOME_STATS_WIDGET_IDS))
.test(
'stats-api-required',
'NEXT_PUBLIC_STATS_API_HOST is required when total_operational_txs is enabled in NEXT_PUBLIC_HOMEPAGE_STATS',
function(value) {
// total_operational_txs is presented only in stats microservice
if (value?.includes('total_operational_txs')) {
return Boolean(this.parent.NEXT_PUBLIC_STATS_API_HOST);
}
return true;
}
),
NEXT_PUBLIC_HOMEPAGE_PLATE_TEXT_COLOR: yup.string(), NEXT_PUBLIC_HOMEPAGE_PLATE_TEXT_COLOR: yup.string(),
NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND: yup.string(), NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND: yup.string(),
NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG: yup NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG: yup
......
...@@ -126,8 +126,8 @@ Please be aware that all environment variables prefixed with `NEXT_PUBLIC_` will ...@@ -126,8 +126,8 @@ Please be aware that all environment variables prefixed with `NEXT_PUBLIC_` will
| Variable | Type| Description | Compulsoriness | Default value | Example value | Version | | Variable | Type| Description | Compulsoriness | Default value | Example value | Version |
| --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_HOMEPAGE_CHARTS | `Array<'daily_txs' \| 'coin_price' \| 'secondary_coin_price' \| 'market_cap' \| 'tvl'>` | List of charts displayed on the home page | - | - | `['daily_txs','coin_price','market_cap']` | v1.0.x+ | | NEXT_PUBLIC_HOMEPAGE_CHARTS | `Array<'daily_txs' \| 'daily_operational_txs' \| 'coin_price' \| 'secondary_coin_price' \| 'market_cap' \| 'tvl'>` | List of charts displayed on the home page | - | - | `['daily_txs','coin_price','market_cap']` | v1.0.x+ |
| NEXT_PUBLIC_HOMEPAGE_STATS | `Array<'latest_batch' \| 'total_blocks' \| 'average_block_time' \| 'total_txs' \| 'latest_l1_state_batch' \| 'wallet_addresses' \| 'gas_tracker' \| 'btc_locked' \| 'current_epoch'>` | List of stats widgets displayed on the home page | - | For zkSync, zkEvm and Arbitrum rollups: `['latest_batch','average_block_time','total_txs','wallet_addresses','gas_tracker']`, for other cases: `['total_blocks','average_block_time','total_txs','wallet_addresses','gas_tracker']` | `['total_blocks','total_txs','wallet_addresses']` | v1.35.x+ | | NEXT_PUBLIC_HOMEPAGE_STATS | `Array<'latest_batch' \| 'total_blocks' \| 'average_block_time' \| 'total_txs' \| 'total_operational_txs' \| 'latest_l1_state_batch' \| 'wallet_addresses' \| 'gas_tracker' \| 'btc_locked' \| 'current_epoch'>` | List of stats widgets displayed on the home page | - | For zkSync, zkEvm and Arbitrum rollups: `['latest_batch','average_block_time','total_txs','wallet_addresses','gas_tracker']`, for other cases: `['total_blocks','average_block_time','total_txs','wallet_addresses','gas_tracker']` | `['total_blocks','total_txs','wallet_addresses']` | v1.35.x+ |
| NEXT_PUBLIC_HOMEPAGE_PLATE_TEXT_COLOR | `string` | Text color of the hero plate on the homepage (escape "#" symbol if you use HEX color codes or use rgba-value instead). **DEPRECATED** _Use `NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG` instead_ | - | `white` | `\#DCFE76` | v1.0.x+ | | NEXT_PUBLIC_HOMEPAGE_PLATE_TEXT_COLOR | `string` | Text color of the hero plate on the homepage (escape "#" symbol if you use HEX color codes or use rgba-value instead). **DEPRECATED** _Use `NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG` instead_ | - | `white` | `\#DCFE76` | v1.0.x+ |
| NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND | `string` | Background css value for hero plate on the homepage (escape "#" symbol if you use HEX color codes or use rgba-value instead). **DEPRECATED** _Use `NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG` instead_ | - | `radial-gradient(103.03% 103.03% at 0% 0%, rgba(183, 148, 244, 0.8) 0%, rgba(0, 163, 196, 0.8) 100%), var(--chakra-colors-blue-400)` | `radial-gradient(at 15% 86%, hsla(350,65%,70%,1) 0px, transparent 50%)` \| `no-repeat bottom 20% right 0px/100% url(https://placekitten/1400/200)` | v1.1.0+ | | NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND | `string` | Background css value for hero plate on the homepage (escape "#" symbol if you use HEX color codes or use rgba-value instead). **DEPRECATED** _Use `NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG` instead_ | - | `radial-gradient(103.03% 103.03% at 0% 0%, rgba(183, 148, 244, 0.8) 0%, rgba(0, 163, 196, 0.8) 100%), var(--chakra-colors-blue-400)` | `radial-gradient(at 15% 86%, hsla(350,65%,70%,1) 0px, transparent 50%)` \| `no-repeat bottom 20% right 0px/100% url(https://placekitten/1400/200)` | v1.1.0+ |
| NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG | `HeroBannerConfig`, see details [below](#hero-banner-configuration-properties) | Configuration of hero banner appearance. | - | - | See [below](#hero-banner-configuration-properties) | v1.35.0+ | | NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG | `HeroBannerConfig`, see details [below](#hero-banner-configuration-properties) | Configuration of hero banner appearance. | - | - | See [below](#hero-banner-configuration-properties) | v1.35.0+ |
......
...@@ -290,6 +290,21 @@ export const RESOURCES = { ...@@ -290,6 +290,21 @@ export const RESOURCES = {
endpoint: getFeaturePayload(config.features.stats)?.api.endpoint, endpoint: getFeaturePayload(config.features.stats)?.api.endpoint,
basePath: getFeaturePayload(config.features.stats)?.api.basePath, basePath: getFeaturePayload(config.features.stats)?.api.basePath,
}, },
stats_main: {
path: '/api/v1/pages/main',
endpoint: getFeaturePayload(config.features.stats)?.api.endpoint,
basePath: getFeaturePayload(config.features.stats)?.api.basePath,
},
stats_transactions: {
path: '/api/v1/pages/transactions',
endpoint: getFeaturePayload(config.features.stats)?.api.endpoint,
basePath: getFeaturePayload(config.features.stats)?.api.basePath,
},
stats_contracts: {
path: '/api/v1/pages/contracts',
endpoint: getFeaturePayload(config.features.stats)?.api.endpoint,
basePath: getFeaturePayload(config.features.stats)?.api.basePath,
},
// NAME SERVICE // NAME SERVICE
addresses_lookup: { addresses_lookup: {
...@@ -1481,6 +1496,9 @@ Q extends 'advanced_filter' ? AdvancedFilterResponse : ...@@ -1481,6 +1496,9 @@ Q extends 'advanced_filter' ? AdvancedFilterResponse :
Q extends 'advanced_filter_methods' ? AdvancedFilterMethodsResponse : Q extends 'advanced_filter_methods' ? AdvancedFilterMethodsResponse :
Q extends 'pools' ? PoolsResponse : Q extends 'pools' ? PoolsResponse :
Q extends 'pool' ? Pool : Q extends 'pool' ? Pool :
Q extends 'stats_main' ? stats.MainPageStats :
Q extends 'stats_transactions' ? stats.TransactionsPageStats :
Q extends 'stats_contracts' ? stats.ContractsPageStats :
never; never;
/* eslint-enable @stylistic/indent */ /* eslint-enable @stylistic/indent */
......
import type * as stats from '@blockscout/stats-types';
import { averageGasPrice } from './line';
export const base: stats.MainPageStats = {
average_block_time: {
id: 'averageBlockTime',
value: '14.909090909090908',
title: 'Average block time',
units: 's',
description: 'Average time taken in seconds for a block to be included in the blockchain',
},
total_addresses: {
id: 'totalAddresses',
value: '113606435',
title: 'Total addresses',
description: 'Number of addresses that participated in the blockchain',
},
total_blocks: {
id: 'totalBlocks',
value: '7660515',
title: 'Total blocks',
description: 'Number of blocks over all time',
},
total_transactions: {
id: 'totalTxns',
value: '411264599',
title: 'Total txns',
description: 'All transactions including pending, dropped, replaced, failed transactions',
},
yesterday_transactions: {
id: 'yesterdayTxns',
value: '213019',
title: 'Yesterday txns',
description: 'Number of transactions yesterday (0:00 - 23:59 UTC)',
},
total_operational_transactions: {
id: 'totalOperationalTxns',
value: '403598877',
title: 'Total operational txns',
description: '\'Total txns\' without block creation transactions',
},
yesterday_operational_transactions: {
id: 'yesterdayOperationalTxns',
value: '210852',
title: 'Yesterday operational txns',
description: 'Number of transactions yesterday (0:00 - 23:59 UTC) without block creation transactions',
},
daily_new_transactions: {
chart: averageGasPrice.chart,
info: {
id: 'newTxnsWindow',
title: 'Daily transactions',
description: 'The chart displays daily transactions for the past 30 days',
resolutions: [
'DAY',
],
},
},
daily_new_operational_transactions: {
chart: averageGasPrice.chart,
info: {
id: 'newOperationalTxnsWindow',
title: 'Daily operational transactions',
description: 'The chart displays daily transactions for the past 30 days (without block creation transactions)',
resolutions: [
'DAY',
],
},
},
};
import type * as stats from '@blockscout/stats-types';
import type { SmartContract, SmartContractMudSystemsResponse } from 'types/api/contract'; import type { SmartContract, SmartContractMudSystemsResponse } from 'types/api/contract';
import type { VerifiedContract, VerifiedContractsCounters } from 'types/api/contracts'; import type { VerifiedContract, VerifiedContractsCounters } from 'types/api/contracts';
import type { SolidityScanReport } from 'lib/solidityScan/schema'; import type { SolidityScanReport } from 'lib/solidityScan/schema';
import { ADDRESS_PARAMS, ADDRESS_HASH } from './addressParams'; import { ADDRESS_PARAMS, ADDRESS_HASH } from './addressParams';
import { STATS_COUNTER } from './stats';
export const CONTRACT_CODE_UNVERIFIED = { export const CONTRACT_CODE_UNVERIFIED = {
creation_bytecode: '0x60806040526e', creation_bytecode: '0x60806040526e',
...@@ -81,6 +83,13 @@ export const VERIFIED_CONTRACTS_COUNTERS: VerifiedContractsCounters = { ...@@ -81,6 +83,13 @@ export const VERIFIED_CONTRACTS_COUNTERS: VerifiedContractsCounters = {
new_verified_smart_contracts_24h: '1234', new_verified_smart_contracts_24h: '1234',
}; };
export const VERIFIED_CONTRACTS_COUNTERS_MICROSERVICE: stats.ContractsPageStats = {
total_contracts: STATS_COUNTER,
new_contracts_24h: STATS_COUNTER,
total_verified_contracts: STATS_COUNTER,
new_verified_contracts_24h: STATS_COUNTER,
};
export const SOLIDITY_SCAN_REPORT: SolidityScanReport = { export const SOLIDITY_SCAN_REPORT: SolidityScanReport = {
scan_report: { scan_report: {
contractname: 'BullRunners', contractname: 'BullRunners',
......
...@@ -42,17 +42,19 @@ export const HOMEPAGE_STATS: HomeStats = { ...@@ -42,17 +42,19 @@ export const HOMEPAGE_STATS: HomeStats = {
tvl: '1767425.102766552', tvl: '1767425.102766552',
}; };
const STATS_CHART_INFO: stats.LineChartInfo = {
id: 'chart_0',
title: 'Average transaction fee',
description: 'The average amount in ETH spent per transaction',
units: 'ETH',
resolutions: [ 'DAY', 'MONTH' ],
};
export const STATS_CHARTS_SECTION: stats.LineChartSection = { export const STATS_CHARTS_SECTION: stats.LineChartSection = {
id: 'placeholder', id: 'placeholder',
title: 'Placeholder', title: 'Placeholder',
charts: [ charts: [
{ STATS_CHART_INFO,
id: 'chart_0',
title: 'Average transaction fee',
description: 'The average amount in ETH spent per transaction',
units: 'ETH',
resolutions: [ 'DAY', 'MONTH' ],
},
{ {
id: 'chart_1', id: 'chart_1',
title: 'Transactions fees', title: 'Transactions fees',
...@@ -88,3 +90,21 @@ export const STATS_COUNTER: stats.Counter = { ...@@ -88,3 +90,21 @@ export const STATS_COUNTER: stats.Counter = {
description: 'Placeholder description', description: 'Placeholder description',
units: '', units: '',
}; };
export const HOMEPAGE_STATS_MICROSERVICE: stats.MainPageStats = {
average_block_time: STATS_COUNTER,
total_addresses: STATS_COUNTER,
total_blocks: STATS_COUNTER,
total_transactions: STATS_COUNTER,
yesterday_transactions: STATS_COUNTER,
total_operational_transactions: STATS_COUNTER,
yesterday_operational_transactions: STATS_COUNTER,
daily_new_transactions: {
chart: [],
info: STATS_CHART_INFO,
},
daily_new_operational_transactions: {
chart: [],
info: STATS_CHART_INFO,
},
};
import type * as stats from '@blockscout/stats-types';
import type { RawTracesResponse } from 'types/api/rawTrace'; import type { RawTracesResponse } from 'types/api/rawTrace';
import type { Transaction, TransactionsStats } from 'types/api/transaction'; import type { Transaction, TransactionsStats } from 'types/api/transaction';
import { ADDRESS_PARAMS } from './addressParams'; import { ADDRESS_PARAMS } from './addressParams';
import { STATS_COUNTER } from './stats';
export const TX_HASH = '0x3ed9d81e7c1001bdda1caa1dc62c0acbbe3d2c671cdc20dc1e65efdaa4186967'; export const TX_HASH = '0x3ed9d81e7c1001bdda1caa1dc62c0acbbe3d2c671cdc20dc1e65efdaa4186967';
...@@ -66,3 +68,11 @@ export const TXS_STATS: TransactionsStats = { ...@@ -66,3 +68,11 @@ export const TXS_STATS: TransactionsStats = {
transaction_fees_sum_24h: '22184012506492688277', transaction_fees_sum_24h: '22184012506492688277',
transactions_count_24h: '992890', transactions_count_24h: '992890',
}; };
export const TXS_STATS_MICROSERVICE: stats.TransactionsPageStats = {
pending_transactions_30m: STATS_COUNTER,
transactions_24h: STATS_COUNTER,
operational_transactions_24h: STATS_COUNTER,
transactions_fee_24h: STATS_COUNTER,
average_transactions_fee_24h: STATS_COUNTER,
};
export const CHAIN_INDICATOR_IDS = [ 'daily_txs', 'coin_price', 'secondary_coin_price', 'market_cap', 'tvl' ] as const; export const CHAIN_INDICATOR_IDS = [ 'daily_txs', 'daily_operational_txs', 'coin_price', 'secondary_coin_price', 'market_cap', 'tvl' ] as const;
export type ChainIndicatorId = typeof CHAIN_INDICATOR_IDS[number]; export type ChainIndicatorId = typeof CHAIN_INDICATOR_IDS[number];
export const HOME_STATS_WIDGET_IDS = [ export const HOME_STATS_WIDGET_IDS = [
...@@ -6,6 +6,7 @@ export const HOME_STATS_WIDGET_IDS = [ ...@@ -6,6 +6,7 @@ export const HOME_STATS_WIDGET_IDS = [
'total_blocks', 'total_blocks',
'average_block_time', 'average_block_time',
'total_txs', 'total_txs',
'total_operational_txs',
'latest_l1_state_batch', 'latest_l1_state_batch',
'wallet_addresses', 'wallet_addresses',
'gas_tracker', 'gas_tracker',
......
...@@ -12,6 +12,7 @@ test.describe('all items', () => { ...@@ -12,6 +12,7 @@ test.describe('all items', () => {
test.beforeEach(async({ render, mockApiResponse, mockEnvs }) => { test.beforeEach(async({ render, mockApiResponse, mockEnvs }) => {
await mockEnvs([ await mockEnvs([
[ 'NEXT_PUBLIC_HOMEPAGE_STATS', '["total_blocks","average_block_time","total_txs","wallet_addresses","gas_tracker","btc_locked"]' ], [ 'NEXT_PUBLIC_HOMEPAGE_STATS', '["total_blocks","average_block_time","total_txs","wallet_addresses","gas_tracker","btc_locked"]' ],
[ 'NEXT_PUBLIC_STATS_API_HOST', '' ],
]); ]);
await mockApiResponse('stats', statsMock.withBtcLocked); await mockApiResponse('stats', statsMock.withBtcLocked);
component = await render(<Stats/>); component = await render(<Stats/>);
...@@ -22,7 +23,10 @@ test.describe('all items', () => { ...@@ -22,7 +23,10 @@ test.describe('all items', () => {
}); });
}); });
test('no gas info', async({ render, mockApiResponse }) => { test('no gas info', async({ render, mockApiResponse, mockEnvs }) => {
await mockEnvs([
[ 'NEXT_PUBLIC_STATS_API_HOST', '' ],
]);
await mockApiResponse('stats', statsMock.withoutGasInfo); await mockApiResponse('stats', statsMock.withoutGasInfo);
const component = await render(<Stats/>); const component = await render(<Stats/>);
...@@ -32,6 +36,7 @@ test('no gas info', async({ render, mockApiResponse }) => { ...@@ -32,6 +36,7 @@ test('no gas info', async({ render, mockApiResponse }) => {
test('4 items default view +@mobile -@default', async({ render, mockApiResponse, mockEnvs }) => { test('4 items default view +@mobile -@default', async({ render, mockApiResponse, mockEnvs }) => {
await mockEnvs([ await mockEnvs([
[ 'NEXT_PUBLIC_HOMEPAGE_STATS', '["total_txs","gas_tracker","wallet_addresses","total_blocks"]' ], [ 'NEXT_PUBLIC_HOMEPAGE_STATS', '["total_txs","gas_tracker","wallet_addresses","total_blocks"]' ],
[ 'NEXT_PUBLIC_STATS_API_HOST', '' ],
]); ]);
await mockApiResponse('stats', statsMock.base); await mockApiResponse('stats', statsMock.base);
const component = await render(<Stats/>); const component = await render(<Stats/>);
...@@ -41,6 +46,7 @@ test('4 items default view +@mobile -@default', async({ render, mockApiResponse, ...@@ -41,6 +46,7 @@ test('4 items default view +@mobile -@default', async({ render, mockApiResponse,
test('3 items default view +@mobile -@default', async({ render, mockApiResponse, mockEnvs }) => { test('3 items default view +@mobile -@default', async({ render, mockApiResponse, mockEnvs }) => {
await mockEnvs([ await mockEnvs([
[ 'NEXT_PUBLIC_HOMEPAGE_STATS', '["total_txs","wallet_addresses","total_blocks"]' ], [ 'NEXT_PUBLIC_HOMEPAGE_STATS', '["total_txs","wallet_addresses","total_blocks"]' ],
[ 'NEXT_PUBLIC_STATS_API_HOST', '' ],
]); ]);
await mockApiResponse('stats', statsMock.base); await mockApiResponse('stats', statsMock.base);
const component = await render(<Stats/>); const component = await render(<Stats/>);
......
...@@ -7,7 +7,7 @@ import type { HomeStatsWidgetId } from 'types/homepage'; ...@@ -7,7 +7,7 @@ import type { HomeStatsWidgetId } from 'types/homepage';
import config from 'configs/app'; import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import { WEI } from 'lib/consts'; import { WEI } from 'lib/consts';
import { HOMEPAGE_STATS } from 'stubs/stats'; import { HOMEPAGE_STATS, HOMEPAGE_STATS_MICROSERVICE } from 'stubs/stats';
import GasInfoTooltip from 'ui/shared/gas/GasInfoTooltip'; import GasInfoTooltip from 'ui/shared/gas/GasInfoTooltip';
import GasPrice from 'ui/shared/gas/GasPrice'; import GasPrice from 'ui/shared/gas/GasPrice';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
...@@ -15,18 +15,31 @@ import type { Props as StatsWidgetProps } from 'ui/shared/stats/StatsWidget'; ...@@ -15,18 +15,31 @@ import type { Props as StatsWidgetProps } from 'ui/shared/stats/StatsWidget';
import StatsWidget from 'ui/shared/stats/StatsWidget'; import StatsWidget from 'ui/shared/stats/StatsWidget';
const rollupFeature = config.features.rollup; const rollupFeature = config.features.rollup;
const isStatsFeatureEnabled = config.features.stats.isEnabled;
const Stats = () => { const Stats = () => {
const [ hasGasTracker, setHasGasTracker ] = React.useState(config.features.gasTracker.isEnabled); const [ hasGasTracker, setHasGasTracker ] = React.useState(config.features.gasTracker.isEnabled);
const { data, isPlaceholderData, isError, dataUpdatedAt } = useApiQuery('stats', {
// data from stats microservice is prioritized over data from stats api
const statsQuery = useApiQuery('stats_main', {
queryOptions: {
refetchOnMount: false,
placeholderData: isStatsFeatureEnabled ? HOMEPAGE_STATS_MICROSERVICE : undefined,
enabled: isStatsFeatureEnabled,
},
});
const apiQuery = useApiQuery('stats', {
queryOptions: { queryOptions: {
refetchOnMount: false, refetchOnMount: false,
placeholderData: HOMEPAGE_STATS, placeholderData: HOMEPAGE_STATS,
}, },
}); });
const isPlaceholderData = statsQuery.isPlaceholderData || apiQuery.isPlaceholderData;
React.useEffect(() => { React.useEffect(() => {
if (!isPlaceholderData && !data?.gas_prices?.average) { if (!isPlaceholderData && !apiQuery.data?.gas_prices?.average) {
setHasGasTracker(false); setHasGasTracker(false);
} }
// should run only after initial fetch // should run only after initial fetch
...@@ -69,7 +82,7 @@ const Stats = () => { ...@@ -69,7 +82,7 @@ const Stats = () => {
} }
})(); })();
if (isError || latestBatchQuery?.isError) { if (apiQuery.isError || statsQuery.isError || latestBatchQuery?.isError) {
return null; return null;
} }
...@@ -79,13 +92,16 @@ const Stats = () => { ...@@ -79,13 +92,16 @@ const Stats = () => {
id: HomeStatsWidgetId; id: HomeStatsWidgetId;
} }
const apiData = apiQuery.data;
const statsData = statsQuery.data;
const items: Array<Item> = (() => { const items: Array<Item> = (() => {
if (!data) { if (!statsData && !apiData) {
return []; return [];
} }
const gasInfoTooltip = hasGasTracker && data.gas_prices && data.gas_prices.average ? ( const gasInfoTooltip = hasGasTracker && apiData?.gas_prices && apiData.gas_prices.average ? (
<GasInfoTooltip data={ data } dataUpdatedAt={ dataUpdatedAt }> <GasInfoTooltip data={ apiData } dataUpdatedAt={ apiQuery.dataUpdatedAt }>
<IconSvg <IconSvg
isLoading={ isLoading } isLoading={ isLoading }
name="info" name="info"
...@@ -107,64 +123,76 @@ const Stats = () => { ...@@ -107,64 +123,76 @@ const Stats = () => {
href: { pathname: '/batches' as const }, href: { pathname: '/batches' as const },
isLoading, isLoading,
}, },
{ (statsData?.total_blocks?.value || apiData?.total_blocks) && {
id: 'total_blocks' as const, id: 'total_blocks' as const,
icon: 'block_slim' as const, icon: 'block_slim' as const,
label: 'Total blocks', label: statsData?.total_blocks?.title || 'Total blocks',
value: Number(data.total_blocks).toLocaleString(), value: Number(statsData?.total_blocks?.value || apiData?.total_blocks).toLocaleString(),
href: { pathname: '/blocks' as const }, href: { pathname: '/blocks' as const },
isLoading, isLoading,
}, },
{ (statsData?.average_block_time?.value || apiData?.average_block_time) && {
id: 'average_block_time' as const, id: 'average_block_time' as const,
icon: 'clock-light' as const, icon: 'clock-light' as const,
label: 'Average block time', label: statsData?.average_block_time?.title || 'Average block time',
value: `${ (data.average_block_time / 1000).toFixed(1) }s`, value: `${
statsData?.average_block_time?.value ?
Number(statsData.average_block_time.value).toFixed(1) :
(apiData!.average_block_time / 1000).toFixed(1)
}s`,
isLoading, isLoading,
}, },
{ (statsData?.total_transactions?.value || apiData?.total_transactions) && {
id: 'total_txs' as const, id: 'total_txs' as const,
icon: 'transactions_slim' as const, icon: 'transactions_slim' as const,
label: 'Total transactions', label: statsData?.total_transactions?.title || 'Total transactions',
value: Number(data.total_transactions).toLocaleString(), value: Number(statsData?.total_transactions?.value || apiData?.total_transactions).toLocaleString(),
href: { pathname: '/txs' as const },
isLoading,
},
statsData?.total_operational_transactions?.value && {
id: 'total_operational_txs' as const,
icon: 'transactions_slim' as const,
label: statsData?.total_operational_transactions?.title || 'Total operational transactions',
value: Number(statsData?.total_operational_transactions?.value).toLocaleString(),
href: { pathname: '/txs' as const }, href: { pathname: '/txs' as const },
isLoading, isLoading,
}, },
data.last_output_root_size && { apiData?.last_output_root_size && {
id: 'latest_l1_state_batch' as const, id: 'latest_l1_state_batch' as const,
icon: 'txn_batches_slim' as const, icon: 'txn_batches_slim' as const,
label: 'Latest L1 state batch', label: 'Latest L1 state batch',
value: data.last_output_root_size, value: apiData?.last_output_root_size,
href: { pathname: '/batches' as const }, href: { pathname: '/batches' as const },
isLoading, isLoading,
}, },
{ (statsData?.total_addresses?.value || apiData?.total_addresses) && {
id: 'wallet_addresses' as const, id: 'wallet_addresses' as const,
icon: 'wallet' as const, icon: 'wallet' as const,
label: 'Wallet addresses', label: statsData?.total_addresses?.title || 'Wallet addresses',
value: Number(data.total_addresses).toLocaleString(), value: Number(statsData?.total_addresses?.value || apiData?.total_addresses).toLocaleString(),
isLoading, isLoading,
}, },
hasGasTracker && data.gas_prices && { hasGasTracker && apiData?.gas_prices && {
id: 'gas_tracker' as const, id: 'gas_tracker' as const,
icon: 'gas' as const, icon: 'gas' as const,
label: 'Gas tracker', label: 'Gas tracker',
value: data.gas_prices.average ? <GasPrice data={ data.gas_prices.average }/> : 'N/A', value: apiData.gas_prices.average ? <GasPrice data={ apiData.gas_prices.average }/> : 'N/A',
hint: gasInfoTooltip, hint: gasInfoTooltip,
isLoading, isLoading,
}, },
data.rootstock_locked_btc && { apiData?.rootstock_locked_btc && {
id: 'btc_locked' as const, id: 'btc_locked' as const,
icon: 'coins/bitcoin' as const, icon: 'coins/bitcoin' as const,
label: 'BTC Locked in 2WP', label: 'BTC Locked in 2WP',
value: `${ BigNumber(data.rootstock_locked_btc).div(WEI).dp(0).toFormat() } RBTC`, value: `${ BigNumber(apiData.rootstock_locked_btc).div(WEI).dp(0).toFormat() } RBTC`,
isLoading, isLoading,
}, },
data.celo && { apiData?.celo && {
id: 'current_epoch' as const, id: 'current_epoch' as const,
icon: 'hourglass' as const, icon: 'hourglass' as const,
label: 'Current epoch', label: 'Current epoch',
value: `#${ data.celo.epoch_number }`, value: `#${ apiData.celo.epoch_number }`,
isLoading, isLoading,
}, },
] ]
......
import { chakra, Flex, Box } from '@chakra-ui/react'; import { chakra, Box } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react'; import React from 'react';
import type { TimeChartData } from 'ui/shared/chart/types'; import type { TimeChartData } from 'ui/shared/chart/types';
...@@ -7,33 +6,33 @@ import type { TimeChartData } from 'ui/shared/chart/types'; ...@@ -7,33 +6,33 @@ import type { TimeChartData } from 'ui/shared/chart/types';
import ContentLoader from 'ui/shared/ContentLoader'; import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert'; import DataFetchAlert from 'ui/shared/DataFetchAlert';
import ChainIndicatorChart from './ChainIndicatorChart'; import ChainIndicatorChartContent from './ChainIndicatorChartContent';
type Props = UseQueryResult<TimeChartData>; type Props = {
data: TimeChartData;
isError: boolean;
isPending: boolean;
};
const ChainIndicatorChartContainer = ({ data, isError, isPending }: Props) => { const ChainIndicatorChartContainer = ({ data, isError, isPending }: Props) => {
const content = (() => { if (isPending) {
if (isPending) { return <ContentLoader mt="auto" fontSize="xs"/>;
return <ContentLoader mt="auto" fontSize="xs"/>; }
}
if (isError) {
return <DataFetchAlert fontSize="xs" p={ 3 }/>;
}
if (data[0].items.length === 0) { if (isError) {
return <chakra.span fontSize="xs">no data</chakra.span>; return <DataFetchAlert fontSize="xs" p={ 3 }/>;
} }
return ( if (data[0].items.length === 0) {
<Box mx="-10px" my="-5px" h="calc(100% + 10px)" w="calc(100% + 20px)"> return <chakra.span fontSize="xs">no data</chakra.span>;
<ChainIndicatorChart data={ data }/> }
</Box>
);
})();
return <Flex h={{ base: '80px', lg: '110px' }} alignItems="flex-start" flexGrow={ 1 }>{ content }</Flex>; return (
<Box mx="-10px" my="-5px" h="calc(100% + 10px)" w="calc(100% + 20px)">
<ChainIndicatorChartContent data={ data }/>
</Box>
);
}; };
export default React.memo(ChainIndicatorChartContainer); export default React.memo(ChainIndicatorChartContainer);
...@@ -16,7 +16,7 @@ interface Props { ...@@ -16,7 +16,7 @@ interface Props {
const CHART_MARGIN = { bottom: 5, left: 10, right: 10, top: 5 }; const CHART_MARGIN = { bottom: 5, left: 10, right: 10, top: 5 };
const ChainIndicatorChart = ({ data }: Props) => { const ChainIndicatorChartContent = ({ data }: Props) => {
const overlayRef = React.useRef<SVGRectElement>(null); const overlayRef = React.useRef<SVGRectElement>(null);
const lineColor = useToken('colors', 'blue.500'); const lineColor = useToken('colors', 'blue.500');
...@@ -64,4 +64,4 @@ const ChainIndicatorChart = ({ data }: Props) => { ...@@ -64,4 +64,4 @@ const ChainIndicatorChart = ({ data }: Props) => {
); );
}; };
export default React.memo(ChainIndicatorChart); export default React.memo(ChainIndicatorChartContent);
import { Text, Flex, Box, useColorModeValue } from '@chakra-ui/react'; import { Text, Flex, Box, useColorModeValue } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react'; import React from 'react';
import type { HomeStats } from 'types/api/stats';
import type { ChainIndicatorId } from 'types/homepage'; import type { ChainIndicatorId } from 'types/homepage';
import type { ResourceError } from 'lib/api/resources';
import Skeleton from 'ui/shared/chakra/Skeleton'; import Skeleton from 'ui/shared/chakra/Skeleton';
interface Props { interface Props {
id: ChainIndicatorId; id: ChainIndicatorId;
title: string; title: string;
value: (stats: HomeStats) => string; value?: string;
valueDiff?: (stats?: HomeStats) => number | null | undefined; valueDiff?: number | null | undefined;
icon: React.ReactNode; icon: React.ReactNode;
isSelected: boolean; isSelected: boolean;
onClick: (id: ChainIndicatorId) => void; onClick: (id: ChainIndicatorId) => void;
stats: UseQueryResult<HomeStats, ResourceError<unknown>>; isLoading: boolean;
hasData: boolean;
} }
const ChainIndicatorItem = ({ id, title, value, valueDiff, icon, isSelected, onClick, stats }: Props) => { const ChainIndicatorItem = ({ id, title, value, valueDiff, icon, isSelected, onClick, isLoading, hasData }: Props) => {
const activeColor = useColorModeValue('gray.500', 'gray.400'); const activeColor = useColorModeValue('gray.500', 'gray.400');
const activeBgColor = useColorModeValue('white', 'black'); const activeBgColor = useColorModeValue('white', 'black');
...@@ -28,32 +26,28 @@ const ChainIndicatorItem = ({ id, title, value, valueDiff, icon, isSelected, onC ...@@ -28,32 +26,28 @@ const ChainIndicatorItem = ({ id, title, value, valueDiff, icon, isSelected, onC
}, [ id, onClick ]); }, [ id, onClick ]);
const valueContent = (() => { const valueContent = (() => {
if (!stats.data) { if (!hasData) {
return <Text variant="secondary" fontWeight={ 400 }>no data</Text>; return <Text variant="secondary" fontWeight={ 400 }>no data</Text>;
} }
return ( return (
<Skeleton isLoaded={ !stats.isPlaceholderData } variant="secondary" fontWeight={ 600 } minW="30px"> <Skeleton isLoaded={ !isLoading } variant="secondary" fontWeight={ 600 } minW="30px">
{ value(stats.data) } { value }
</Skeleton> </Skeleton>
); );
})(); })();
const valueDiffContent = (() => { const valueDiffContent = (() => {
if (!valueDiff) { if (valueDiff === undefined || valueDiff === null) {
return null;
}
const diff = valueDiff(stats.data);
if (diff === undefined || diff === null) {
return null; return null;
} }
const diffColor = diff >= 0 ? 'green.500' : 'red.500'; const diffColor = valueDiff >= 0 ? 'green.500' : 'red.500';
return ( return (
<Skeleton isLoaded={ !stats.isPlaceholderData } ml={ 1 } display="flex" alignItems="center" color={ diffColor }> <Skeleton isLoaded={ !isLoading } ml={ 1 } display="flex" alignItems="center" color={ diffColor }>
<span>{ diff >= 0 ? '+' : '-' }</span> <span>{ valueDiff >= 0 ? '+' : '-' }</span>
<Text color={ diffColor } fontWeight={ 600 }>{ Math.abs(diff) }%</Text> <Text color={ diffColor } fontWeight={ 600 }>{ Math.abs(valueDiff) }%</Text>
</Skeleton> </Skeleton>
); );
})(); })();
......
...@@ -11,6 +11,7 @@ test.beforeEach(async({ mockEnvs }) => { ...@@ -11,6 +11,7 @@ test.beforeEach(async({ mockEnvs }) => {
await mockEnvs([ await mockEnvs([
[ 'NEXT_PUBLIC_HOMEPAGE_CHARTS', '["daily_txs","coin_price","secondary_coin_price","market_cap","tvl"]' ], [ 'NEXT_PUBLIC_HOMEPAGE_CHARTS', '["daily_txs","coin_price","secondary_coin_price","market_cap","tvl"]' ],
[ 'NEXT_PUBLIC_NETWORK_SECONDARY_COIN_SYMBOL', 'DUCK' ], [ 'NEXT_PUBLIC_NETWORK_SECONDARY_COIN_SYMBOL', 'DUCK' ],
[ 'NEXT_PUBLIC_STATS_API_HOST', '' ],
]); ]);
}); });
......
import { Flex, Text, useColorModeValue } from '@chakra-ui/react'; import { Flex, Text, useColorModeValue } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { TChainIndicator } from './types';
import type { ChainIndicatorId } from 'types/homepage';
import config from 'configs/app'; import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import { HOMEPAGE_STATS } from 'stubs/stats'; import { HOMEPAGE_STATS, HOMEPAGE_STATS_MICROSERVICE } from 'stubs/stats';
import Skeleton from 'ui/shared/chakra/Skeleton'; import Skeleton from 'ui/shared/chakra/Skeleton';
import Hint from 'ui/shared/Hint'; import Hint from 'ui/shared/Hint';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import ChainIndicatorChartContainer from './ChainIndicatorChartContainer'; import ChainIndicatorChartContainer from './ChainIndicatorChartContainer';
import ChainIndicatorItem from './ChainIndicatorItem'; import ChainIndicatorItem from './ChainIndicatorItem';
import useFetchChartData from './useFetchChartData'; import useChartDataQuery from './useChartDataQuery';
import getIndicatorValues from './utils/getIndicatorValues';
import INDICATORS from './utils/indicators'; import INDICATORS from './utils/indicators';
const isStatsFeatureEnabled = config.features.stats.isEnabled;
const indicators = INDICATORS const indicators = INDICATORS
.filter(({ id }) => config.UI.homepage.charts.includes(id)) .filter(({ id }) => config.UI.homepage.charts.includes(id))
.sort((a, b) => { .sort((a, b) => {
...@@ -29,10 +35,19 @@ const indicators = INDICATORS ...@@ -29,10 +35,19 @@ const indicators = INDICATORS
const ChainIndicators = () => { const ChainIndicators = () => {
const [ selectedIndicator, selectIndicator ] = React.useState(indicators[0]?.id); const [ selectedIndicator, selectIndicator ] = React.useState(indicators[0]?.id);
const indicator = indicators.find(({ id }) => id === selectedIndicator); const selectedIndicatorData = indicators.find(({ id }) => id === selectedIndicator);
const queryResult = useChartDataQuery(selectedIndicatorData?.id as ChainIndicatorId);
const queryResult = useFetchChartData(indicator); const statsMicroserviceQueryResult = useApiQuery('stats_main', {
const statsQueryResult = useApiQuery('stats', { queryOptions: {
refetchOnMount: false,
enabled: isStatsFeatureEnabled,
placeholderData: HOMEPAGE_STATS_MICROSERVICE,
},
});
const statsApiQueryResult = useApiQuery('stats', {
queryOptions: { queryOptions: {
refetchOnMount: false, refetchOnMount: false,
placeholderData: HOMEPAGE_STATS, placeholderData: HOMEPAGE_STATS,
...@@ -45,38 +60,57 @@ const ChainIndicators = () => { ...@@ -45,38 +60,57 @@ const ChainIndicators = () => {
return null; return null;
} }
const isPlaceholderData = (isStatsFeatureEnabled && statsMicroserviceQueryResult.isPlaceholderData) || statsApiQueryResult.isPlaceholderData;
const hasData = Boolean(statsApiQueryResult?.data || statsMicroserviceQueryResult?.data);
const { value: indicatorValue, valueDiff: indicatorValueDiff } =
getIndicatorValues(selectedIndicatorData as TChainIndicator, statsMicroserviceQueryResult?.data, statsApiQueryResult?.data);
const title = (() => {
let title: string | undefined;
if (isStatsFeatureEnabled && selectedIndicatorData?.titleMicroservice && statsMicroserviceQueryResult?.data) {
title = selectedIndicatorData.titleMicroservice(statsMicroserviceQueryResult.data);
}
return title || selectedIndicatorData?.title;
})();
const hint = (() => {
let hint: string | undefined;
if (isStatsFeatureEnabled && selectedIndicatorData?.hintMicroservice && statsMicroserviceQueryResult?.data) {
hint = selectedIndicatorData.hintMicroservice(statsMicroserviceQueryResult.data);
}
return hint || selectedIndicatorData?.hint;
})();
const valueTitle = (() => { const valueTitle = (() => {
if (statsQueryResult.isPlaceholderData) { if (isPlaceholderData) {
return <Skeleton h="36px" w="215px"/>; return <Skeleton h="36px" w="215px"/>;
} }
if (!statsQueryResult.data) { if (!hasData) {
return <Text fontSize="xs">There is no data</Text>; return <Text fontSize="xs">There is no data</Text>;
} }
return ( return (
<Text fontWeight={ 700 } fontSize="30px" lineHeight="36px"> <Text fontWeight={ 700 } fontSize="30px" lineHeight="36px">
{ indicator?.value(statsQueryResult.data) } { indicatorValue }
</Text> </Text>
); );
})(); })();
const valueDiff = (() => { const valueDiff = (() => {
if (!statsQueryResult.data || !indicator?.valueDiff) { if (indicatorValueDiff === undefined || indicatorValueDiff === null) {
return null; return null;
} }
const diff = indicator.valueDiff(statsQueryResult.data); const diffColor = indicatorValueDiff >= 0 ? 'green.500' : 'red.500';
if (diff === undefined || diff === null) {
return null;
}
const diffColor = diff >= 0 ? 'green.500' : 'red.500';
return ( return (
<Skeleton isLoaded={ !statsQueryResult.isPlaceholderData } display="flex" alignItems="center" color={ diffColor } ml={ 2 }> <Skeleton isLoaded={ !statsApiQueryResult.isPlaceholderData } display="flex" alignItems="center" color={ diffColor } ml={ 2 }>
<IconSvg name="arrows/up-head" boxSize={ 5 } mr={ 1 } transform={ diff < 0 ? 'rotate(180deg)' : 'rotate(0)' }/> <IconSvg name="arrows/up-head" boxSize={ 5 } mr={ 1 } transform={ indicatorValueDiff < 0 ? 'rotate(180deg)' : 'rotate(0)' }/>
<Text color={ diffColor } fontWeight={ 600 }>{ diff }%</Text> <Text color={ diffColor } fontWeight={ 600 }>{ indicatorValueDiff }%</Text>
</Skeleton> </Skeleton>
); );
})(); })();
...@@ -95,14 +129,16 @@ const ChainIndicators = () => { ...@@ -95,14 +129,16 @@ const ChainIndicators = () => {
> >
<Flex flexGrow={ 1 } flexDir="column"> <Flex flexGrow={ 1 } flexDir="column">
<Flex alignItems="center"> <Flex alignItems="center">
<Text fontWeight={ 500 }>{ indicator?.title }</Text> <Text fontWeight={ 500 }>{ title }</Text>
{ indicator?.hint && <Hint label={ indicator.hint } ml={ 1 }/> } { hint && <Hint label={ hint } ml={ 1 }/> }
</Flex> </Flex>
<Flex mb={{ base: 0, lg: 2 }} mt={ 1 } alignItems="end"> <Flex mb={{ base: 0, lg: 2 }} mt={ 1 } alignItems="end">
{ valueTitle } { valueTitle }
{ valueDiff } { valueDiff }
</Flex> </Flex>
<ChainIndicatorChartContainer { ...queryResult }/> <Flex h={{ base: '80px', lg: '110px' }} alignItems="flex-start" flexGrow={ 1 }>
<ChainIndicatorChartContainer { ...queryResult }/>
</Flex>
</Flex> </Flex>
{ indicators.length > 1 && ( { indicators.length > 1 && (
<Flex <Flex
...@@ -116,10 +152,14 @@ const ChainIndicators = () => { ...@@ -116,10 +152,14 @@ const ChainIndicators = () => {
{ indicators.map((indicator) => ( { indicators.map((indicator) => (
<ChainIndicatorItem <ChainIndicatorItem
key={ indicator.id } key={ indicator.id }
{ ...indicator } id={ indicator.id }
title={ indicator.title }
icon={ indicator.icon }
isSelected={ selectedIndicator === indicator.id } isSelected={ selectedIndicator === indicator.id }
onClick={ selectIndicator } onClick={ selectIndicator }
stats={ statsQueryResult } { ...getIndicatorValues(indicator, statsMicroserviceQueryResult?.data, statsApiQueryResult?.data) }
isLoading={ isPlaceholderData }
hasData={ hasData }
/> />
)) } )) }
</Flex> </Flex>
......
import type React from 'react'; import type React from 'react';
import type { MainPageStats } from '@blockscout/stats-types';
import type { HomeStats } from 'types/api/stats'; import type { HomeStats } from 'types/api/stats';
import type { ChainIndicatorId } from 'types/homepage'; import type { ChainIndicatorId } from 'types/homepage';
import type { TimeChartData } from 'ui/shared/chart/types';
import type { ResourcePayload } from 'lib/api/resources'; export interface TChainIndicator {
export type ChartsResources = 'stats_charts_txs' | 'stats_charts_market' | 'stats_charts_secondary_coin_price';
export interface TChainIndicator<R extends ChartsResources> {
id: ChainIndicatorId; id: ChainIndicatorId;
titleMicroservice?: (stats: MainPageStats) => string | undefined;
title: string; title: string;
value: (stats: HomeStats) => string; value: (stats: HomeStats) => string;
valueMicroservice?: (stats: MainPageStats) => string | undefined;
valueDiff?: (stats?: HomeStats) => number | null | undefined; valueDiff?: (stats?: HomeStats) => number | null | undefined;
icon: React.ReactNode; icon: React.ReactNode;
hint?: string; hint?: string;
api: { hintMicroservice?: (stats: MainPageStats) => string | undefined;
resourceName: R;
dataFn: (response: ResourcePayload<R>) => TimeChartData;
};
} }
import type { ChainIndicatorId } from 'types/homepage';
import type { TimeChartData, TimeChartDataItem, TimeChartItemRaw } from 'ui/shared/chart/types';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import prepareChartItems from './utils/prepareChartItems';
const CHART_ITEMS: Record<ChainIndicatorId, Pick<TimeChartDataItem, 'name' | 'valueFormatter'>> = {
daily_txs: {
name: 'Tx/day',
valueFormatter: (x: number) => x.toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }),
},
daily_operational_txs: {
name: 'Tx/day',
valueFormatter: (x: number) => x.toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }),
},
coin_price: {
name: `${ config.chain.currency.symbol } price`,
valueFormatter: (x: number) => '$' + x.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 }),
},
secondary_coin_price: {
name: `${ config.chain.currency.symbol } price`,
valueFormatter: (x: number) => '$' + x.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 }),
},
market_cap: {
name: 'Market cap',
valueFormatter: (x: number) => '$' + x.toLocaleString(undefined, { maximumFractionDigits: 2 }),
},
tvl: {
name: 'TVL',
valueFormatter: (x: number) => '$' + x.toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }),
},
};
const isStatsFeatureEnabled = config.features.stats.isEnabled;
type UseFetchChartDataResult = {
isError: boolean;
isPending: boolean;
data: TimeChartData;
};
function getChartData(indicatorId: ChainIndicatorId, data: Array<TimeChartItemRaw>): TimeChartData {
return [ {
items: prepareChartItems(data),
name: CHART_ITEMS[indicatorId].name,
valueFormatter: CHART_ITEMS[indicatorId].valueFormatter,
} ];
}
export default function useChartDataQuery(indicatorId: ChainIndicatorId): UseFetchChartDataResult {
const statsDailyTxsQuery = useApiQuery('stats_main', {
queryOptions: {
refetchOnMount: false,
enabled: isStatsFeatureEnabled && indicatorId === 'daily_txs',
select: (data) => data.daily_new_transactions?.chart.map((item) => ({ date: new Date(item.date), value: Number(item.value) })) || [],
},
});
const statsDailyOperationalTxsQuery = useApiQuery('stats_main', {
queryOptions: {
refetchOnMount: false,
enabled: isStatsFeatureEnabled && indicatorId === 'daily_operational_txs',
select: (data) => data.daily_new_operational_transactions?.chart.map((item) => ({ date: new Date(item.date), value: Number(item.value) })) || [],
},
});
const apiDailyTxsQuery = useApiQuery('stats_charts_txs', {
queryOptions: {
refetchOnMount: false,
enabled: !isStatsFeatureEnabled && indicatorId === 'daily_txs',
select: (data) => data.chart_data.map((item) => ({ date: new Date(item.date), value: item.transaction_count })),
},
});
const coinPriceQuery = useApiQuery('stats_charts_market', {
queryOptions: {
refetchOnMount: false,
enabled: indicatorId === 'coin_price',
select: (data) => data.chart_data.map((item) => ({ date: new Date(item.date), value: item.closing_price })),
},
});
const secondaryCoinPriceQuery = useApiQuery('stats_charts_secondary_coin_price', {
queryOptions: {
refetchOnMount: false,
enabled: indicatorId === 'secondary_coin_price',
select: (data) => data.chart_data.map((item) => ({ date: new Date(item.date), value: item.closing_price })),
},
});
const marketCapQuery = useApiQuery('stats_charts_market', {
queryOptions: {
refetchOnMount: false,
enabled: indicatorId === 'market_cap',
select: (data) => data.chart_data.map((item) => (
{
date: new Date(item.date),
value: (() => {
if (item.market_cap !== undefined) {
return item.market_cap;
}
if (item.closing_price === null) {
return null;
}
return Number(item.closing_price) * Number(data.available_supply);
})(),
})),
},
});
const tvlQuery = useApiQuery('stats_charts_market', {
queryOptions: {
refetchOnMount: false,
enabled: indicatorId === 'tvl',
select: (data) => data.chart_data.map((item) => (
{
date: new Date(item.date),
value: item.tvl !== undefined ? item.tvl : 0,
})),
},
});
switch (indicatorId) {
case 'daily_txs': {
const query = isStatsFeatureEnabled ? statsDailyTxsQuery : apiDailyTxsQuery;
return {
data: getChartData(indicatorId, query.data || []),
isError: query.isError,
isPending: query.isPending,
};
}
case 'daily_operational_txs': {
return {
data: getChartData(indicatorId, statsDailyOperationalTxsQuery.data || []),
isError: statsDailyOperationalTxsQuery.isError,
isPending: statsDailyOperationalTxsQuery.isPending,
};
}
case 'coin_price': {
return {
data: getChartData(indicatorId, coinPriceQuery.data || []),
isError: coinPriceQuery.isError,
isPending: coinPriceQuery.isPending,
};
}
case 'secondary_coin_price': {
return {
data: getChartData(indicatorId, secondaryCoinPriceQuery.data || []),
isError: secondaryCoinPriceQuery.isError,
isPending: secondaryCoinPriceQuery.isPending,
};
}
case 'market_cap': {
return {
data: getChartData(indicatorId, marketCapQuery.data || []),
isError: marketCapQuery.isError,
isPending: marketCapQuery.isPending,
};
}
case 'tvl': {
return {
data: getChartData(indicatorId, tvlQuery.data || []),
isError: tvlQuery.isError,
isPending: tvlQuery.isPending,
};
}
}
}
import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react';
import type { TChainIndicator, ChartsResources } from './types';
import type { TimeChartData } from 'ui/shared/chart/types';
import type { ResourcePayload } from 'lib/api/resources';
import useApiQuery from 'lib/api/useApiQuery';
export default function useFetchChartData<R extends ChartsResources>(indicator: TChainIndicator<R> | undefined): UseQueryResult<TimeChartData> {
const queryResult = useApiQuery(indicator?.api.resourceName || 'stats_charts_txs', {
queryOptions: { enabled: Boolean(indicator) },
});
return React.useMemo(() => {
return {
...queryResult,
data: queryResult.data && indicator ? indicator.api.dataFn(queryResult.data as ResourcePayload<R>) : queryResult.data,
} as UseQueryResult<TimeChartData>;
}, [ indicator, queryResult ]);
}
import type { TChainIndicator } from '../types';
import type * as stats from '@blockscout/stats-types';
import type { HomeStats } from 'types/api/stats';
import config from 'configs/app';
export default function getIndicatorValues(indicator: TChainIndicator, statsData?: stats.MainPageStats, statsApiData?: HomeStats) {
const value = (() => {
if (config.features.stats.isEnabled && indicator?.valueMicroservice && statsData) {
return indicator.valueMicroservice(statsData);
}
if (statsApiData) {
return indicator?.value(statsApiData);
}
return 'N/A';
})();
// we have diffs only for coin and second coin price charts that get data from stats api
// so we don't check microservice data here, but may require to add it in the future
const valueDiff = indicator?.valueDiff ? indicator.valueDiff(statsApiData) : undefined;
return {
value,
valueDiff,
};
}
This diff is collapsed.
import type { TimeChartItem, TimeChartItemRaw } from 'ui/shared/chart/types';
import { sortByDateDesc } from 'ui/shared/chart/utils/sorts';
const nonNullTailReducer = (result: Array<TimeChartItemRaw>, item: TimeChartItemRaw) => {
if (item.value === null && result.length === 0) {
return result;
}
result.unshift(item);
return result;
};
const mapNullToZero: (item: TimeChartItemRaw) => TimeChartItem = (item) => ({ ...item, value: Number(item.value) });
export default function prepareChartItems(items: Array<TimeChartItemRaw>) {
return items
.sort(sortByDateDesc)
.reduceRight(nonNullTailReducer, [] as Array<TimeChartItemRaw>)
.map(mapNullToZero);
}
...@@ -4,6 +4,7 @@ import React from 'react'; ...@@ -4,6 +4,7 @@ import React from 'react';
import * as blockMock from 'mocks/blocks/block'; import * as blockMock from 'mocks/blocks/block';
import * as dailyTxsMock from 'mocks/stats/daily_txs'; import * as dailyTxsMock from 'mocks/stats/daily_txs';
import * as statsMock from 'mocks/stats/index'; import * as statsMock from 'mocks/stats/index';
import * as statsMainMock from 'mocks/stats/main';
import * as txMock from 'mocks/txs/tx'; import * as txMock from 'mocks/txs/tx';
import { test, expect, devices } from 'playwright/lib'; import { test, expect, devices } from 'playwright/lib';
import * as pwConfig from 'playwright/utils/config'; import * as pwConfig from 'playwright/utils/config';
...@@ -15,6 +16,7 @@ test.describe('default view', () => { ...@@ -15,6 +16,7 @@ test.describe('default view', () => {
test.beforeEach(async({ render, mockApiResponse, mockAssetResponse }) => { test.beforeEach(async({ render, mockApiResponse, mockAssetResponse }) => {
await mockAssetResponse(statsMock.base.coin_image as string, './playwright/mocks/image_s.jpg'); await mockAssetResponse(statsMock.base.coin_image as string, './playwright/mocks/image_s.jpg');
await mockApiResponse('stats_main', statsMainMock.base);
await mockApiResponse('stats', statsMock.base); await mockApiResponse('stats', statsMock.base);
await mockApiResponse('homepage_blocks', [ await mockApiResponse('homepage_blocks', [
blockMock.base, blockMock.base,
...@@ -55,6 +57,7 @@ test.describe('mobile', () => { ...@@ -55,6 +57,7 @@ test.describe('mobile', () => {
test('base view', async({ render, page, mockAssetResponse, mockApiResponse }) => { test('base view', async({ render, page, mockAssetResponse, mockApiResponse }) => {
await mockAssetResponse(statsMock.base.coin_image as string, './playwright/mocks/image_s.jpg'); await mockAssetResponse(statsMock.base.coin_image as string, './playwright/mocks/image_s.jpg');
await mockApiResponse('stats_main', statsMainMock.base);
await mockApiResponse('stats', statsMock.base); await mockApiResponse('stats', statsMock.base);
await mockApiResponse('homepage_blocks', [ await mockApiResponse('homepage_blocks', [
blockMock.base, blockMock.base,
......
...@@ -6,7 +6,8 @@ import { test, expect } from 'playwright/lib'; ...@@ -6,7 +6,8 @@ import { test, expect } from 'playwright/lib';
import VerifiedContracts from './VerifiedContracts'; import VerifiedContracts from './VerifiedContracts';
test('base view +@mobile', async({ render, mockTextAd, mockApiResponse }) => { test('base view +@mobile', async({ render, mockTextAd, mockApiResponse, mockEnvs }) => {
await mockEnvs([ [ 'NEXT_PUBLIC_STATS_API_HOST', '' ] ]);
await mockTextAd(); await mockTextAd();
await mockApiResponse('verified_contracts', verifiedContractsMock.baseResponse); await mockApiResponse('verified_contracts', verifiedContractsMock.baseResponse);
await mockApiResponse('verified_contracts_counters', verifiedContractsCountersMock); await mockApiResponse('verified_contracts_counters', verifiedContractsCountersMock);
......
...@@ -20,7 +20,7 @@ export type Props = { ...@@ -20,7 +20,7 @@ export type Props = {
diff?: string | number; diff?: string | number;
diffFormatted?: string; diffFormatted?: string;
diffPeriod?: '24h'; diffPeriod?: '24h';
period?: '1h' | '24h'; period?: '1h' | '24h' | '30min';
href?: Route; href?: Route;
icon?: IconName; icon?: IconName;
}; };
......
...@@ -6,7 +6,8 @@ import { test, expect } from 'playwright/lib'; ...@@ -6,7 +6,8 @@ import { test, expect } from 'playwright/lib';
import TxsStats from './TxsStats'; import TxsStats from './TxsStats';
test('base view +@mobile', async({ render, mockApiResponse }) => { test('base view +@mobile', async({ render, mockApiResponse, mockEnvs }) => {
await mockEnvs([ [ 'NEXT_PUBLIC_STATS_API_HOST', '' ] ]);
await mockApiResponse('stats', statsMock.base); await mockApiResponse('stats', statsMock.base);
await mockApiResponse('txs_stats', txsStatsMock.base); await mockApiResponse('txs_stats', txsStatsMock.base);
const component = await render(<TxsStats/>); const component = await render(<TxsStats/>);
......
...@@ -6,13 +6,23 @@ import useApiQuery from 'lib/api/useApiQuery'; ...@@ -6,13 +6,23 @@ import useApiQuery from 'lib/api/useApiQuery';
import getCurrencyValue from 'lib/getCurrencyValue'; import getCurrencyValue from 'lib/getCurrencyValue';
import { thinsp } from 'lib/html-entities'; import { thinsp } from 'lib/html-entities';
import { HOMEPAGE_STATS } from 'stubs/stats'; import { HOMEPAGE_STATS } from 'stubs/stats';
import { TXS_STATS } from 'stubs/tx'; import { TXS_STATS, TXS_STATS_MICROSERVICE } from 'stubs/tx';
import StatsWidget from 'ui/shared/stats/StatsWidget'; import StatsWidget from 'ui/shared/stats/StatsWidget';
const isStatsFeatureEnabled = config.features.stats.isEnabled;
const TxsStats = () => { const TxsStats = () => {
const txsStatsQuery = useApiQuery('txs_stats', { const txsStatsQuery = useApiQuery('stats_transactions', {
queryOptions: {
enabled: isStatsFeatureEnabled,
placeholderData: isStatsFeatureEnabled ? TXS_STATS_MICROSERVICE : undefined,
},
});
const txsStatsApiQuery = useApiQuery('txs_stats', {
queryOptions: { queryOptions: {
placeholderData: TXS_STATS, enabled: !isStatsFeatureEnabled,
placeholderData: !isStatsFeatureEnabled ? TXS_STATS : undefined,
}, },
}); });
...@@ -22,58 +32,94 @@ const TxsStats = () => { ...@@ -22,58 +32,94 @@ const TxsStats = () => {
}, },
}); });
if (!txsStatsQuery.data) { if ((isStatsFeatureEnabled && !txsStatsQuery.data) || (!isStatsFeatureEnabled && !txsStatsApiQuery.data)) {
return null; return null;
} }
const txFeeAvg = getCurrencyValue({ const isLoading = isStatsFeatureEnabled ? txsStatsQuery.isPlaceholderData : txsStatsApiQuery.isPlaceholderData;
value: txsStatsQuery.data.transaction_fees_avg_24h,
const txCount24h = isStatsFeatureEnabled ? txsStatsQuery.data?.transactions_24h?.value : txsStatsApiQuery.data?.transactions_count_24h;
const operationalTxns24h = isStatsFeatureEnabled ? txsStatsQuery.data?.operational_transactions_24h?.value : null;
const pendingTxns = isStatsFeatureEnabled ? txsStatsQuery.data?.pending_transactions_30m?.value : txsStatsApiQuery.data?.pending_transactions_count;
// in microservice data, fee values are already divided by 10^decimals
const txFeeSum24h = isStatsFeatureEnabled ?
Number(txsStatsQuery.data?.transactions_fee_24h?.value) :
Number(txsStatsApiQuery.data?.transaction_fees_sum_24h) / (10 ** config.chain.currency.decimals);
const avgFee = isStatsFeatureEnabled ? txsStatsQuery.data?.average_transactions_fee_24h?.value : txsStatsApiQuery.data?.transaction_fees_avg_24h;
const txFeeAvg = avgFee ? getCurrencyValue({
value: avgFee,
exchangeRate: statsQuery.data?.coin_price, exchangeRate: statsQuery.data?.coin_price,
decimals: String(config.chain.currency.decimals), // in microservice data, fee values are already divided by 10^decimals
decimals: isStatsFeatureEnabled ? '0' : String(config.chain.currency.decimals),
accuracyUsd: 2, accuracyUsd: 2,
}); }) : null;
const itemsCount = [
txCount24h,
operationalTxns24h,
pendingTxns,
txFeeSum24h,
txFeeAvg,
].filter(Boolean).length;
return ( return (
<Box <Box
display="grid" display="grid"
gridTemplateColumns={{ base: '1fr', lg: 'repeat(4, calc(25% - 9px))' }} gridTemplateColumns={{ base: '1fr', lg: `repeat(${ itemsCount }, calc(${ 100 / itemsCount }% - 9px))` }}
rowGap={ 3 } rowGap={ 3 }
columnGap={ 3 } columnGap={ 3 }
mb={ 6 } mb={ 6 }
> >
<StatsWidget { txCount24h && (
label="Transactions" <StatsWidget
value={ Number(txsStatsQuery.data?.transactions_count_24h).toLocaleString() } label={ txsStatsQuery.data?.transactions_24h?.title || 'Transactions' }
period="24h" value={ Number(txCount24h).toLocaleString() }
isLoading={ txsStatsQuery.isPlaceholderData } period="24h"
href={ config.features.stats.isEnabled ? { pathname: '/stats/[id]', query: { id: 'newTxns' } } : undefined } isLoading={ isLoading }
/> href={ config.features.stats.isEnabled ? { pathname: '/stats/[id]', query: { id: 'newTxns' } } : undefined }
<StatsWidget />
label="Pending transactions" ) }
value={ Number(txsStatsQuery.data?.pending_transactions_count).toLocaleString() } { operationalTxns24h && (
period="1h" <StatsWidget
isLoading={ txsStatsQuery.isPlaceholderData } label={ txsStatsQuery.data?.operational_transactions_24h?.title || 'Daily op txns' }
/> value={ Number(operationalTxns24h).toLocaleString() }
<StatsWidget period="24h"
label="Transactions fees" isLoading={ isLoading }
value={ />
(Number(txsStatsQuery.data?.transaction_fees_sum_24h) / (10 ** config.chain.currency.decimals)) ) }
.toLocaleString(undefined, { maximumFractionDigits: 2 }) { pendingTxns && (
} <StatsWidget
valuePostfix={ thinsp + config.chain.currency.symbol } label={ txsStatsQuery.data?.pending_transactions_30m?.title || 'Pending transactions' }
period="24h" value={ Number(pendingTxns).toLocaleString() }
isLoading={ txsStatsQuery.isPlaceholderData } period={ isStatsFeatureEnabled ? '30min' : '1h' }
href={ config.features.stats.isEnabled ? { pathname: '/stats/[id]', query: { id: 'txnsFee' } } : undefined } isLoading={ isLoading }
/> />
<StatsWidget ) }
label="Avg. transaction fee" { txFeeSum24h && (
value={ txFeeAvg.usd ? txFeeAvg.usd : txFeeAvg.valueStr } <StatsWidget
valuePrefix={ txFeeAvg.usd ? '$' : undefined } label={ txsStatsQuery.data?.transactions_fee_24h?.title || 'Transactions fees' }
valuePostfix={ txFeeAvg.usd ? undefined : thinsp + config.chain.currency.symbol } value={ txFeeSum24h.toLocaleString(undefined, { maximumFractionDigits: 2 }) }
period="24h" valuePostfix={ thinsp + config.chain.currency.symbol }
isLoading={ txsStatsQuery.isPlaceholderData } period="24h"
href={ config.features.stats.isEnabled ? { pathname: '/stats/[id]', query: { id: 'averageTxnFee' } } : undefined } isLoading={ isLoading }
/> href={ config.features.stats.isEnabled ? { pathname: '/stats/[id]', query: { id: 'txnsFee' } } : undefined }
/>
) }
{ txFeeAvg && (
<StatsWidget
label={ txsStatsQuery.data?.average_transactions_fee_24h?.title || 'Avg. transaction fee' }
value={ txFeeAvg.usd ? txFeeAvg.usd : txFeeAvg.valueStr }
valuePrefix={ txFeeAvg.usd ? '$' : undefined }
valuePostfix={ txFeeAvg.usd ? undefined : thinsp + config.chain.currency.symbol }
period="24h"
isLoading={ isLoading }
href={ config.features.stats.isEnabled ? { pathname: '/stats/[id]', query: { id: 'averageTxnFee' } } : undefined }
/>
) }
</Box> </Box>
); );
}; };
......
...@@ -3,37 +3,59 @@ import React from 'react'; ...@@ -3,37 +3,59 @@ import React from 'react';
import config from 'configs/app'; import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import { VERIFIED_CONTRACTS_COUNTERS } from 'stubs/contract'; import { VERIFIED_CONTRACTS_COUNTERS, VERIFIED_CONTRACTS_COUNTERS_MICROSERVICE } from 'stubs/contract';
import StatsWidget from 'ui/shared/stats/StatsWidget'; import StatsWidget from 'ui/shared/stats/StatsWidget';
const isStatsFeatureEnabled = config.features.stats.isEnabled;
const VerifiedContractsCounters = () => { const VerifiedContractsCounters = () => {
const countersQuery = useApiQuery('verified_contracts_counters', { const countersStatsQuery = useApiQuery('stats_contracts', {
queryOptions: {
enabled: isStatsFeatureEnabled,
placeholderData: isStatsFeatureEnabled ? VERIFIED_CONTRACTS_COUNTERS_MICROSERVICE : undefined,
},
});
const countersApiQuery = useApiQuery('verified_contracts_counters', {
queryOptions: { queryOptions: {
placeholderData: VERIFIED_CONTRACTS_COUNTERS, enabled: !isStatsFeatureEnabled,
placeholderData: !isStatsFeatureEnabled ? VERIFIED_CONTRACTS_COUNTERS : undefined,
}, },
}); });
if (!countersQuery.data) { if (!(isStatsFeatureEnabled ? countersStatsQuery.data : countersApiQuery.data)) {
return null; return null;
} }
const isLoading = isStatsFeatureEnabled ? countersStatsQuery.isPlaceholderData : countersApiQuery.isPlaceholderData;
const contractsCount = isStatsFeatureEnabled ? countersStatsQuery.data?.total_contracts?.value : countersApiQuery.data?.smart_contracts;
const newContractsCount = isStatsFeatureEnabled ? countersStatsQuery.data?.new_contracts_24h?.value : countersApiQuery.data?.new_smart_contracts_24h;
const verifiedContractsCount = isStatsFeatureEnabled ?
countersStatsQuery.data?.total_verified_contracts?.value :
countersApiQuery.data?.verified_smart_contracts;
const newVerifiedContractsCount = isStatsFeatureEnabled ?
countersStatsQuery.data?.new_verified_contracts_24h?.value :
countersApiQuery.data?.new_verified_smart_contracts_24h;
return ( return (
<Box columnGap={ 3 } rowGap={ 3 } mb={ 6 } display="grid" gridTemplateColumns={{ base: '1fr', lg: 'repeat(2, 1fr)' }}> <Box columnGap={ 3 } rowGap={ 3 } mb={ 6 } display="grid" gridTemplateColumns={{ base: '1fr', lg: 'repeat(2, 1fr)' }}>
<StatsWidget <StatsWidget
label="Total contracts" label="Total contracts"
value={ Number(countersQuery.data.smart_contracts).toLocaleString() } value={ Number(contractsCount).toLocaleString() }
diff={ countersQuery.data.new_smart_contracts_24h } diff={ newContractsCount }
diffFormatted={ Number(countersQuery.data.new_smart_contracts_24h).toLocaleString() } diffFormatted={ Number(newContractsCount).toLocaleString() }
isLoading={ countersQuery.isPlaceholderData } isLoading={ isLoading }
// there is no stats for contracts growth for now // there is no stats for contracts growth for now
// href={ config.features.stats.isEnabled ? { pathname: '/stats/[id]', query: { id: 'contractsGrowth' } } : undefined } // href={ config.features.stats.isEnabled ? { pathname: '/stats/[id]', query: { id: 'contractsGrowth' } } : undefined }
/> />
<StatsWidget <StatsWidget
label="Verified contracts" label="Verified contracts"
value={ Number(countersQuery.data.verified_smart_contracts).toLocaleString() } value={ Number(verifiedContractsCount).toLocaleString() }
diff={ countersQuery.data.new_verified_smart_contracts_24h } diff={ newVerifiedContractsCount }
diffFormatted={ Number(countersQuery.data.new_verified_smart_contracts_24h).toLocaleString() } diffFormatted={ Number(newVerifiedContractsCount).toLocaleString() }
isLoading={ countersQuery.isPlaceholderData } isLoading={ isLoading }
href={ config.features.stats.isEnabled ? { pathname: '/stats/[id]', query: { id: 'verifiedContractsGrowth' } } : undefined } href={ config.features.stats.isEnabled ? { pathname: '/stats/[id]', query: { id: 'verifiedContractsGrowth' } } : undefined }
/> />
</Box> </Box>
......
...@@ -1508,10 +1508,10 @@ ...@@ -1508,10 +1508,10 @@
resolved "https://registry.yarnpkg.com/@blockscout/bens-types/-/bens-types-1.4.1.tgz#9182a79d9015b7fa2339edf0bfa3cd0c32045e66" resolved "https://registry.yarnpkg.com/@blockscout/bens-types/-/bens-types-1.4.1.tgz#9182a79d9015b7fa2339edf0bfa3cd0c32045e66"
integrity sha512-TlZ1HVdZ2Cswm/CcvNoxS+Ydiht/YGaLo//PJR/UmkmihlEFoY4HfVJvVcUnOQXi+Si7FwJ486DPii889nTJsQ== integrity sha512-TlZ1HVdZ2Cswm/CcvNoxS+Ydiht/YGaLo//PJR/UmkmihlEFoY4HfVJvVcUnOQXi+Si7FwJ486DPii889nTJsQ==
"@blockscout/stats-types@2.0.0": "@blockscout/stats-types@2.5.0-alpha":
version "2.0.0" version "2.5.0-alpha"
resolved "https://registry.yarnpkg.com/@blockscout/stats-types/-/stats-types-2.0.0.tgz#3805f8379b75377cde8a9ab76306af37bb735846" resolved "https://registry.yarnpkg.com/@blockscout/stats-types/-/stats-types-2.5.0-alpha.tgz#e34698577a337ce08b176d8709f89f185d9d9359"
integrity sha512-icYDsOHsDACjG/7VZhlV+1QRKSJOycblpswQ5Si0dqeWdOpbtmxSqolAS/z6C77d8p+uxZUCMjNa9otUCqn18A== integrity sha512-B4IYeNt3pqIIJvcnkLIXm4LNN77VxTV1VYopJ8t6iFPT+JC3BSvRWSpMJMl7nV+WCLywcW27BKmYxdV9rR66bw==
"@blockscout/visualizer-types@0.2.0": "@blockscout/visualizer-types@0.2.0":
version "0.2.0" version "0.2.0"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment