MegaETH: uptime dashboard (#2968)

Fixes #2906
This commit is contained in:
tom goriunov
2025-09-23 20:41:43 +02:00
committed by GitHub
parent a4fc170a85
commit 83ec8774db
44 changed files with 785 additions and 95 deletions
+1
View File
@@ -24,6 +24,7 @@ export { default as getGasButton } from './getGasButton';
export { default as googleAnalytics } from './googleAnalytics';
export { default as growthBook } from './growthBook';
export { default as marketplace } from './marketplace';
export { default as megaEth } from './megaEth';
export { default as metasuites } from './metasuites';
export { default as mixpanel } from './mixpanel';
export { default as mudFramework } from './mudFramework';
+26
View File
@@ -0,0 +1,26 @@
import type { Feature } from './types';
import { getEnvValue } from '../utils';
const title = 'MegaETH chain';
const config: Feature<{ socketUrl: { metrics: string } }> = (() => {
const socketUrlMetrics = getEnvValue('NEXT_PUBLIC_MEGA_ETH_SOCKET_URL_METRICS');
if (socketUrlMetrics) {
return Object.freeze({
title,
isEnabled: true,
socketUrl: {
metrics: socketUrlMetrics,
},
});
}
return Object.freeze({
title,
isEnabled: false,
});
})();
export default config;
+28 -55
View File
@@ -1,5 +1,5 @@
# Set of ENVs for Ethereum network explorer
# https://eth.blockscout.com
# Set of ENVs for MEGA Testnet network explorer
# https://megaeth-testnet.blockscout.com
# This is an auto-generated file. To update all values, run "yarn dev:preset:sync --name=mega_eth"
# Local ENVs
@@ -9,70 +9,43 @@ NEXT_PUBLIC_APP_PORT=3000
NEXT_PUBLIC_APP_ENV=development
NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL=ws
NEXT_PUBLIC_USER_OPS_INDEXER_API_HOST=https://user-ops-indexer-base-mainnet.k8s-prod-2.blockscout.com
## CUSTOM COLOR THEME
NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG={'background':['rgba(223,217,217,1)','transparent'],'text_color':['rgba(16,17,18,0.80)','rgba(222,217,217,1)'],'border':['none','1px solid rgba(105,103,103,1)'],'search':{'border_width':['0px','2px']}}
NEXT_PUBLIC_COLOR_THEME_OVERRIDES={'bg':{'primary':{'_light':{'value':'rgba(254,253,253)'},'_dark':{'value':'rgba(25,25,26)'}}},'text':{'primary':{'_light':{'value':'rgba(16,17,18,0.80)'},'_dark':{'value':'rgba(222,217,217)'}},'secondary':{'_light':{'value':'rgba(138,136,136)'},'_dark':{'value':'rgba(133,133,133)'}}},'hover':{'_light':{'value':'rgba(104,200,158)'},'_dark':{'value':'rgba(104,200,158)'}},'selected':{'control':{'text':{'_light':{'value':'rgba(25,25,26)'},'_dark':{'value':'rgba(247,250,252)'}},'bg':{'_light':{'value':'rgba(242,239,239)'},'_dark':{'value':'rgba(255,255,255,0.06)'}}},'option':{'bg':{'_light':{'value':'rgba(84,75,75)'},'_dark':{'value':'rgba(87,87,87)'}}}},'icon':{'primary':{'_light':{'value':'rgba(138,136,136)'},'_dark':{'value':'rgba(133,133,133)'}},'secondary':{'_light':{'value':'rgba(176,176,176)'},'_dark':{'value':'rgba(105,103,103)'}}},'button':{'primary':{'_light':{'value':'rgba(105,103,103)'},'_dark':{'value':'rgba(133,133,133)'}}},'link':{'primary':{'_light':{'value':'rgba(57,146,108)'},'_dark':{'value':'rgba(57,146,108)'}}},'graph':{'line':{'_light':{'value':'rgba(105,103,103)'},'_dark':{'value':'rgba(57,146,108)'}},'gradient':{'start':{'_light':{'value':'rgba(105,103,103,0.3)'},'_dark':{'value':'rgba(57,146,108,0.3)'}},'stop':{'_light':{'value':'rgba(105,103,103,0)'},'_dark':{'value':'rgba(57,146,108,0)'}}}},'stats':{'bg':{'_light':{'value':'rgba(242,239,239)'},'_dark':{'value':'rgba(255,255,255,0.06)'}}},'topbar':{'bg':{'_light':{'value':'rgba(242,239,239)'},'_dark':{'value':'rgba(255,255,255,0.06)'}}},'navigation':{'text':{'selected':{'_light':{'value':'rgba(25,25,26)'},'_dark':{'value':'rgba(247,250,252)'}}},'bg':{'selected':{'_light':{'value':'rgba(242,239,239)'},'_dark':{'value':'rgba(255,255,255,0.06)'}}}},'tabs':{'text':{'primary':{'_light':{'value':'rgba(25,25,26)'},'_dark':{'value':'rgba(222,217,217)'}}}}}
NEXT_PUBLIC_COLOR_THEME_DEFAULT=dark
NEXT_PUBLIC_MEGA_ETH_SOCKET_URL_METRICS=wss://testnet-dashboard.megaeth.com/metrics
# Instance ENVs
NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS=['talentprotocol', 'efp', 'webacy', 'humanpassport', 'trustblock', 'smartmuv', 'blockscoutbadges', 'etherscore', 'gitpoap', 'drops', 'humanode', 'deepdao']
NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/widgets/config.json
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com
NEXT_PUBLIC_API_BASE_PATH=/
NEXT_PUBLIC_API_HOST=eth.blockscout.com
NEXT_PUBLIC_API_HOST=megaeth-testnet.blockscout.com
NEXT_PUBLIC_API_SPEC_URL=https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml
NEXT_PUBLIC_COLOR_THEME_DEFAULT=dark
NEXT_PUBLIC_COLOR_THEME_OVERRIDES={'bg':{'primary':{'_light':{'value':'rgba(254,253,253)'},'_dark':{'value':'rgba(25,25,26)'}}},'text':{'primary':{'_light':{'value':'rgba(16,17,18,0.80)'},'_dark':{'value':'rgba(222,217,217)'}},'secondary':{'_light':{'value':'rgba(138,136,136)'},'_dark':{'value':'rgba(133,133,133)'}}},'hover':{'_light':{'value':'rgba(104,200,158)'},'_dark':{'value':'rgba(104,200,158)'}},'selected':{'control':{'text':{'_light':{'value':'rgba(25,25,26)'},'_dark':{'value':'rgba(247,250,252)'}},'bg':{'_light':{'value':'rgba(242,239,239)'},'_dark':{'value':'rgba(255,255,255,0.06)'}}},'option':{'bg':{'_light':{'value':'rgba(84,75,75)'},'_dark':{'value':'rgba(87,87,87)'}}}},'icon':{'primary':{'_light':{'value':'rgba(138,136,136)'},'_dark':{'value':'rgba(133,133,133)'}},'secondary':{'_light':{'value':'rgba(176,176,176)'},'_dark':{'value':'rgba(105,103,103)'}}},'button':{'primary':{'_light':{'value':'rgba(105,103,103)'},'_dark':{'value':'rgba(133,133,133)'}}},'link':{'primary':{'_light':{'value':'rgba(57,146,108)'},'_dark':{'value':'rgba(57,146,108)'}}},'graph':{'line':{'_light':{'value':'rgba(105,103,103)'},'_dark':{'value':'rgba(57,146,108)'}},'gradient':{'start':{'_light':{'value':'rgba(105,103,103,0.3)'},'_dark':{'value':'rgba(57,146,108,0.3)'}},'stop':{'_light':{'value':'rgba(105,103,103,0)'},'_dark':{'value':'rgba(57,146,108,0)'}}}},'stats':{'bg':{'_light':{'value':'rgba(242,239,239)'},'_dark':{'value':'rgba(255,255,255,0.06)'}}},'topbar':{'bg':{'_light':{'value':'rgba(242,239,239)'},'_dark':{'value':'rgba(255,255,255,0.06)'}}},'navigation':{'text':{'selected':{'_light':{'value':'rgba(25,25,26)'},'_dark':{'value':'rgba(247,250,252)'}}},'bg':{'selected':{'_light':{'value':'rgba(242,239,239)'},'_dark':{'value':'rgba(255,255,255,0.06)'}}}},'tabs':{'text':{'primary':{'_light':{'value':'rgba(25,25,26)'},'_dark':{'value':'rgba(222,217,217)'}}}}}
NEXT_PUBLIC_CONTRACT_CODE_IDES=[{'title':'Remix IDE','url':'https://remix.ethereum.org/?address={hash}&blockscout={domain}','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}]
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info-test.k8s-dev.blockscout.com
NEXT_PUBLIC_DATA_AVAILABILITY_ENABLED=true
NEXT_PUBLIC_DEFI_DROPDOWN_ITEMS=[{'text':'Swapscout','icon':'swap','dappId':'swapscout'},{'text':'Revokescout','icon':'integration/partial','dappId':'revokescout'},{'text':'Payment link','icon':'payment_link','dappId':'peanut-protocol'}]
NEXT_PUBLIC_DEX_POOLS_ENABLED=true
NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/eth.json
NEXT_PUBLIC_FOOTER_LINKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/footer-links/eth-mainnet.json
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com
NEXT_PUBLIC_GAME_BADGE_CLAIM_LINK=https://badges.blockscout.com/mint/sherblockHolmesBadge
NEXT_PUBLIC_PUZZLE_GAME_BADGE_CLAIM_LINK=https://badges.blockscout.com/mint/sherblockHolmesBadge
NEXT_PUBLIC_GAS_REFUEL_PROVIDER_CONFIG={'name': 'Need gas?', 'url_template': 'https://smolrefuel.com/?outboundChain={chainId}&partner=blockscout&utm_source=blockscout&disableBridges=true', 'dapp_id': 'smol-refuel', 'logo': 'https://blockscout-content.s3.amazonaws.com/smolrefuel-logo-action-button.png'}
NEXT_PUBLIC_GRAPHIQL_TRANSACTION=0xd01175f1efa23f36c5579b3c13e2bbd0885017643a7efef5cbcb6b474384dfa8
NEXT_PUBLIC_HAS_BEACON_CHAIN=true
NEXT_PUBLIC_HAS_CONTRACT_AUDIT_REPORTS=true
NEXT_PUBLIC_HAS_USER_OPS=true
NEXT_PUBLIC_HIDE_INDEXING_ALERT_BLOCKS=true
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs', 'coin_price', 'market_cap']
NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true
NEXT_PUBLIC_MAINTENANCE_ALERT_MESSAGE=<p>Joined recent campaigns? Mint your Merit Badge <a href="https://badges.blockscout.com?utm_source=instance&utm_medium=eth">here</a></p>
NEXT_PUBLIC_MARKETPLACE_BANNER_CONTENT_URL=https://gist.githubusercontent.com/0xdeval/b27a4aecaad513fa033e37430a4f9a47/raw/3a2fa70068ea27c3e6d58dc4cdbeb732968d62f3/revokescout-banner.html
NEXT_PUBLIC_MARKETPLACE_BANNER_LINK_URL=https://eth.blockscout.com/apps/revokescout?chainId=1
NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/marketplace-categories/default.json
NEXT_PUBLIC_MARKETPLACE_ENABLED=true
NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=https://airtable.com/appiy5yijZpMMSKjT/shr6uMGPKjj1DK7NL
NEXT_PUBLIC_MARKETPLACE_SUGGEST_IDEAS_FORM=https://airtable.com/appiy5yijZpMMSKjT/pag3t82DUCyhGRZZO/form
NEXT_PUBLIC_GRAPHIQL_TRANSACTION=1
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs']
NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG={'background':['rgba(223,217,217,1)','transparent'],'text_color':['rgba(16,17,18,0.80)','rgba(222,217,217,1)'],'border':['none','1px solid rgba(105,103,103,1)'],'search':{'border_width':['0px','2px']}}
NEXT_PUBLIC_IS_TESTNET=true
NEXT_PUBLIC_METADATA_SERVICE_API_HOST=https://metadata.services.blockscout.com
NEXT_PUBLIC_METASUITES_ENABLED=true
NEXT_PUBLIC_MULTICHAIN_BALANCE_PROVIDER_CONFIG=[{'name': 'zerion', 'url_template': 'https://app.zerion.io/{address}/overview', 'logo': 'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/marketplace-logos/zerion.svg'},{'name': 'zapper', 'url_template': 'https://zapper.xyz/account/{address}', 'logo': 'https://blockscout-content.s3.amazonaws.com/zapper-icon.png'}]
NEXT_PUBLIC_NAME_SERVICE_API_HOST=https://bens.services.blockscout.com
NEXT_PUBLIC_NAVIGATION_HIGHLIGHTED_ROUTES=['/pools']
NEXT_PUBLIC_MIXPANEL_CONFIG_OVERRIDES={"record_sessions_percent": 0.5,"record_heatmap_data": true}
NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18
NEXT_PUBLIC_NETWORK_CURRENCY_NAME=Ether
NEXT_PUBLIC_NETWORK_CURRENCY_NAME=ETH
NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=ETH
NEXT_PUBLIC_NETWORK_EXPLORERS=[{'title':'Moralis','logo':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/explorer-logos/moralis.png','baseUrl':'https://moralis.com/','paths':{'token':'/chain/ethereum/token/price'}},{'title':'GeckoTerminal','logo':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/explorer-logos/geckoterminal.png','baseUrl':'https://www.geckoterminal.com/','paths':{'token':'/eth/pools'}},{'title':'Etherscan','logo':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/explorer-logos/etherscan.png','baseUrl':'https://etherscan.io/','paths':{'tx':'/tx','address':'/address','token':'/token','block':'/block'}}, {'title':'Blockchair','logo':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/explorer-logos/blockchair.png','baseUrl':'https://blockchair.com/','paths':{'tx':'/ethereum/transaction','address':'/ethereum/address','token':'/ethereum/erc-20/token','block':'/ethereum/block'}},{'title':'Sentio','logo':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/explorer-logos/sentio.png','baseUrl':'https://app.sentio.xyz/','paths':{'tx':'/tx/1','address':'/contract/1'}}, {'title':'Tenderly','logo':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/explorer-logos/tenderly.png','baseUrl':'https://dashboard.tenderly.co','paths':{'tx':'/tx/mainnet'}}, {'title':'0xPPL','logo':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/explorer-logos/0xPPL.png','baseUrl':'https://0xppl.com','paths':{'tx':'/Ethereum/tx','address':'/','token':'/c/Ethereum'}}, {'title':'3xpl','logo':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/explorer-logos/3xpl.png','baseUrl':'https://3xpl.com/','paths':{'tx':'/ethereum/transaction','address':'/ethereum/address'}}, {'title':'BlobScan','baseUrl':'https://blobscan.com','paths':{'blob':'/blob'}} ]
NEXT_PUBLIC_NETWORK_ID=1
NEXT_PUBLIC_NETWORK_NAME=Ethereum
NEXT_PUBLIC_NETWORK_RPC_URL=https://rpc.eth.gateway.fm
NEXT_PUBLIC_NETWORK_SHORT_NAME=Ethereum
NEXT_PUBLIC_NETWORK_VERIFICATION_TYPE=validation
NEXT_PUBLIC_NETWORK_ICON=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/mega-eth-light.svg
NEXT_PUBLIC_NETWORK_ICON_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/mega-eth-dark.svg
NEXT_PUBLIC_NETWORK_ID=6342
NEXT_PUBLIC_NETWORK_LOGO=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/mega-eth-light.svg
NEXT_PUBLIC_NETWORK_LOGO_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/mega-eth-dark.svg
NEXT_PUBLIC_NETWORK_NAME=MEGA Testnet
NEXT_PUBLIC_NETWORK_RPC_URL=https://carrot.megaeth.com/rpc
NEXT_PUBLIC_NETWORK_SHORT_NAME=MEGA Testnet
NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED=true
NEXT_PUBLIC_OG_IMAGE_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/eth.jpg
NEXT_PUBLIC_OTHER_LINKS=[{'url':'https://eth.drpc.org?ref=559183','text':'Public RPC'}]
NEXT_PUBLIC_REWARDS_SERVICE_API_HOST=https://merits.blockscout.com
NEXT_PUBLIC_SAFE_TX_SERVICE_URL=https://safe-transaction-mainnet.safe.global
NEXT_PUBLIC_SAVE_ON_GAS_ENABLED=true
NEXT_PUBLIC_OG_IMAGE_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/mega-eth.png
NEXT_PUBLIC_PUZZLE_GAME_BADGE_CLAIM_LINK=https://badges.blockscout.com/mint/capyPuzzleBadge
NEXT_PUBLIC_STATS_API_BASE_PATH=/stats-service
NEXT_PUBLIC_STATS_API_HOST=https://eth.blockscout.com
NEXT_PUBLIC_STATS_API_HOST=https://megaeth-testnet.blockscout.com
NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout
NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true
NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES=[{'name':'OpenSea','collection_url':'https://opensea.io/assets/ethereum/{hash}','instance_url':'https://opensea.io/assets/ethereum/{hash}/{id}','logo_url':'https://opensea.io/static/images/logos/opensea-logo.svg'},{'name':'Rarible','collection_url':'https://rarible.com/collection/{hash}/items','instance_url':'https://rarible.com/token/{hash}:{id}','logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/nft-marketplace-logos/rarible.png'},{'name':'Blur','collection_url':'https://blur.io/eth/collection/{hash}','instance_url':'https://blur.io/eth/asset/{hash}/{id}','logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/nft-marketplace-logos/blur.png'},{'name':'MagicEden','collection_url':'https://magiceden.io/collections/ethereum/{hash}','instance_url':'https://magiceden.io/item-details/ethereum/{hash}/{id}','logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/nft-marketplace-logos/magiceden.png'}]
NEXT_PUBLIC_VIEWS_CONTRACT_LANGUAGE_FILTERS=['solidity','vyper','yul','geas']
NEXT_PUBLIC_VIEWS_TOKEN_SCAM_TOGGLE_ENABLED=true
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
NEXT_PUBLIC_XSTAR_SCORE_URL=https://docs.xname.app/the-solution-adaptive-proof-of-humanity-on-blockchain/xhs-scoring-algorithm?utm_source=blockscout&utm_medium=address
NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS=['talentprotocol', 'efp', 'webacy', 'deepdao', 'humanpassport', 'trustblock', 'bankless']
NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/widgets/config.json
NEXT_PUBLIC_NAVIGATION_PROMO_BANNER_CONFIG={'img_url': 'https://gist.githubusercontent.com/maxaleks/0a02a0f15d018674171dc03e13f85cdd/raw/7ef0a5a3f7ec48bc202634d7aa6dd6fd71c8d0c5/promo.svg', 'text': 'Try Multichain!', 'bg_color': {'light': 'rgb(250, 245, 255)', 'dark': 'rgb(68, 51, 122)'}, 'text_color': {'light': 'rgb(107, 70, 193)', 'dark': 'rgb(233, 216, 253)'}, 'link_url': 'https://www.blockscout.com?utm_source=1&utm_medium=side-menu-banner'}
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
+7
View File
@@ -373,6 +373,12 @@ const rollupSchema = yup
}),
});
const megaEthSchema = yup
.object()
.shape({
NEXT_PUBLIC_MEGA_ETH_SOCKET_URL_METRICS: yup.string().test(urlTest),
});
const apiDocsScheme = yup
.object()
.shape({
@@ -1170,6 +1176,7 @@ const schema = yup
.concat(apiDocsScheme)
.concat(mixpanelSchema)
.concat(tacSchema)
.concat(megaEthSchema)
.concat(address3rdPartyWidgetsConfigSchema)
.concat(addressMetadataSchema)
.concat(userOpsSchema)
@@ -0,0 +1 @@
NEXT_PUBLIC_MEGA_ETH_SOCKET_URL_METRICS=wss://example.com
@@ -156,6 +156,11 @@ module.exports = {
return null;
}
break;
case '/uptime':
if (!process.env.NEXT_PUBLIC_MEGA_ETH_SOCKET_URL_METRICS) {
return null;
}
break;
case '/validators':
if (!process.env.NEXT_PUBLIC_VALIDATORS_CHAIN_TYPE) {
return null;
+10
View File
@@ -804,6 +804,16 @@ For blockchains that use the Celo platform. _Note_, that once the Celo mainnet b
&nbsp;
### MegaETH chain
For blockchains that use the MegaETH platform.
| Variable | Type| Description | Compulsoriness | Default value | Example value | Version |
| --- | --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_MEGA_ETH_SOCKET_URL_METRICS | `string` | Public WebSocket endpoint for streaming statistics data, used to display information on the uptime dashboard page. | - | - | `wss://testnet-dashboard.megaeth.com/metrics` | upcoming |
&nbsp;
### Ton Application Chain (TAC)
For Ton Application Chains, this feature enables additional views, such as a list of cross-chain operations and a detailed page for a specific cross-chain operation, as well as extra fields on the transaction page.
+3
View File
@@ -0,0 +1,3 @@
<svg viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.816 4.847a.884.884 0 0 1 .793.879v18.213h19.453c.472 0 .854.384.855.855l-.005.088a.856.856 0 0 1-.85.767H4.814l-.09.01a.885.885 0 0 1-.883-.885V5.726c0-.488.396-.884.884-.884l.09.005Zm19.217 1.101A2.191 2.191 0 0 1 26 8.127l-.01.224a2.193 2.193 0 0 1-1.957 1.956l-.223.01a1.43 1.43 0 0 1-.114-.004l-2.764 5.58c.286.369.46.83.46 1.335l-.01.224a2.193 2.193 0 0 1-1.956 1.955l-.224.012a2.191 2.191 0 0 1-2.19-2.19l.007-.164c.016-.216.063-.425.137-.62l-2.074-1.54c-.3.218-.658.363-1.047.405l-.237.013c-.239 0-.469-.04-.684-.11l-2.353 2.94c.14.288.221.61.221.953l-.01.223a2.193 2.193 0 0 1-1.956 1.956l-.224.01a2.192 2.192 0 0 1-2.179-1.966l-.01-.224a2.19 2.19 0 0 1 2.189-2.189l.154.006c.206.014.403.058.588.125l2.323-2.903a2.176 2.176 0 0 1-.24-.824l-.009-.187c0-1.21.98-2.19 2.19-2.19l.223.011a2.191 2.191 0 0 1 1.967 2.179l-.008.19a2.567 2.567 0 0 1-.024.176l2.338 1.736a2.19 2.19 0 0 1 .908-.196l.19.008c.037.003.073.01.11.014l2.702-5.452a2.178 2.178 0 0 1-.58-1.347l-.004-.135c0-1.21.98-2.19 2.19-2.19l.223.011ZM8.792 18.565a.54.54 0 1 0 .002 1.082.54.54 0 0 0-.002-1.082ZM19.094 16.7a.542.542 0 0 0-.432.53l.01.109c.052.246.27.431.531.431l.108-.01a.542.542 0 0 0 .432-.53l-.014-.124a.538.538 0 0 0-.159-.265l-.023-.01a.832.832 0 0 1-.16-.104.527.527 0 0 0-.184-.037l-.11.01Zm-5.296-4.106a.54.54 0 1 0 0 1.08.54.54 0 0 0 0-1.08ZM23.81 7.587a.54.54 0 1 0 0 1.081.54.54 0 0 0 0-1.081Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.063 14.904a.99.99 0 0 1 1.118.844 7.742 7.742 0 0 0 13.949 3.457.11.11 0 0 0-.089-.175h-1.907a.99.99 0 0 1 0-1.98h4.311a.99.99 0 0 1 .99.99v4.311a.99.99 0 0 1-1.98 0V21.04a.11.11 0 0 0-.193-.07 9.719 9.719 0 0 1-8.659 3.353A9.72 9.72 0 0 1 5.22 16.02a.99.99 0 0 1 .843-1.116Zm8.76-2.165a2.068 2.068 0 1 1 0 4.137 2.068 2.068 0 0 1 0-4.137Zm-.004 2.046a.03.03 0 0 0-.006.002.024.024 0 0 0-.006.004.028.028 0 0 0-.004.007.04.04 0 0 0-.002.006v.002H14.8l.045.002v-.002l-.001-.002-.001-.006a.022.022 0 0 0-.005-.007.024.024 0 0 0-.007-.004l-.002-.001-.003-.001h-.007ZM9.936 6.514a9.72 9.72 0 0 1 14.49 7.079.99.99 0 1 1-1.961.272 7.741 7.741 0 0 0-13.95-3.456.11.11 0 0 0 .09.174h1.907a.99.99 0 0 1 0 1.98H6.2a.99.99 0 0 1-.99-.99V7.262a.99.99 0 0 1 1.98 0v1.311a.11.11 0 0 0 .072.104.11.11 0 0 0 .122-.033 9.722 9.722 0 0 1 2.552-2.13Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 950 B

+41 -6
View File
@@ -256,6 +256,46 @@ export default function useNavItems(): ReturnType {
},
].filter(Boolean);
const statsNavItem = (() => {
const uptimeItem = {
text: 'Uptime',
nextRoute: { pathname: '/uptime' as const },
icon: 'refresh_menu',
isActive: pathname.startsWith('/uptime'),
};
if (config.features.stats.isEnabled && config.features.megaEth.isEnabled) {
return {
text: 'Charts & stats',
icon: 'stats',
isActive: pathname.startsWith('/stats') || pathname.startsWith('/uptime'),
subItems: [
{
text: `${ config.chain.name } stats`,
nextRoute: { pathname: '/stats' as const },
icon: 'graph',
isActive: pathname.startsWith('/stats/'),
},
uptimeItem,
],
};
}
if (!config.features.stats.isEnabled) {
if (config.features.megaEth.isEnabled) {
return uptimeItem;
}
return null;
}
return {
text: 'Charts & stats',
nextRoute: { pathname: '/stats' as const },
icon: 'stats',
isActive: pathname.startsWith('/stats'),
};
})();
const apiNavItem: NavItem | null = config.features.apiDocs.isEnabled ? {
text: 'API',
nextRoute: { pathname: '/api-docs' as const },
@@ -310,12 +350,7 @@ export default function useNavItems(): ReturnType {
icon: 'apps',
isActive: pathname.startsWith('/app'),
} : null,
config.features.stats.isEnabled ? {
text: 'Charts & stats',
nextRoute: { pathname: '/stats' as const },
icon: 'stats',
isActive: pathname.startsWith('/stats'),
} : null,
statsNavItem,
apiNavItem,
{
text: 'Other',
+1
View File
@@ -26,6 +26,7 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/clusters/[name]': 'Regular page',
'/stats': 'Root page',
'/stats/[id]': 'Regular page',
'/uptime': 'Root page',
'/api-docs': 'Regular page',
'/search-results': 'Regular page',
'/auth/profile': 'Root page',
+1
View File
@@ -29,6 +29,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/clusters/[name]': '%cluster_name% cluster | %app_name%',
'/stats': DEFAULT_TEMPLATE,
'/stats/[id]': DEFAULT_TEMPLATE,
'/uptime': DEFAULT_TEMPLATE,
'/api-docs': DEFAULT_TEMPLATE,
'/search-results': DEFAULT_TEMPLATE,
'/auth/profile': DEFAULT_TEMPLATE,
+1
View File
@@ -25,6 +25,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/apps/[id]': '%network_name% marketplace app',
'/stats': '%network_name% stats - %network_name% network insights',
'/stats/[id]': '%network_name% stats - %id% chart',
'/uptime': '%network_name% uptime',
'/api-docs': '%network_name% API docs - %network_name% developer tools',
'/search-results': '%network_name% search result for %q%',
'/auth/profile': '%network_name% - my profile',
+1
View File
@@ -24,6 +24,7 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/clusters/[name]': 'Cluster details',
'/stats': 'Stats',
'/stats/[id]': 'Stats chart',
'/uptime': 'Uptime',
'/api-docs': 'REST API',
'/search-results': 'Search results',
'/auth/profile': 'Profile',
+1
View File
@@ -14,6 +14,7 @@ function generateCspPolicy() {
descriptors.growthBook(),
descriptors.helia(),
descriptors.marketplace(),
descriptors.megaEth(),
descriptors.mixpanel(),
descriptors.monaco(),
descriptors.multichain(),
+1
View File
@@ -9,6 +9,7 @@ export { googleReCaptcha } from './googleReCaptcha';
export { growthBook } from './growthBook';
export { helia } from './helia';
export { marketplace } from './marketplace';
export { megaEth } from './megaEth';
export { mixpanel } from './mixpanel';
export { monaco } from './monaco';
export { multichain } from './multichain';
+17
View File
@@ -0,0 +1,17 @@
import type CspDev from 'csp-dev';
import config from 'configs/app';
const feature = config.features.megaEth;
export function megaEth(): CspDev.DirectiveDescriptor {
if (!feature.isEnabled) {
return {};
}
return {
'connect-src': [
feature.socketUrl.metrics,
],
};
}
+8
View File
@@ -315,3 +315,11 @@ export const notOpSuperchain: Guard = () => async() => {
};
}
};
export const megaEth: Guard = () => async() => {
if (!config.features.megaEth.isEnabled) {
return {
notFound: true,
};
}
};
+1
View File
@@ -25,6 +25,7 @@ export const login = factory([ guards.login ]);
export const dev = factory([ guards.dev ]);
export const publicTagsSubmit = factory([ guards.publicTagsSubmit ]);
export const pools = factory([ guards.pools ]);
export const megaEth = factory([ guards.megaEth ]);
export const zetaChainCCTX = factory([ guards.zetaChainCCTX ]);
// ROLLUPS
+1
View File
@@ -85,6 +85,7 @@ declare module "nextjs-routes" {
| StaticRoute<"/txn-withdrawals">
| StaticRoute<"/txs">
| DynamicRoute<"/txs/kettle/[hash]", { "hash": string }>
| StaticRoute<"/uptime">
| DynamicRoute<"/validators/[id]", { "id": string }>
| StaticRoute<"/validators">
| StaticRoute<"/verified-contracts">
+18
View File
@@ -0,0 +1,18 @@
import type { NextPage } from 'next';
import React from 'react';
import PageNextJs from 'nextjs/PageNextJs';
import Uptime from 'ui/megaEth/uptime/Uptime';
const Page: NextPage = () => {
return (
<PageNextJs pathname="/uptime">
<Uptime/>
</PageNextJs>
);
};
export default Page;
export { megaEth as getServerSideProps } from 'nextjs/getServerSideProps/main';
+2
View File
@@ -83,6 +83,7 @@
| "gear"
| "globe-b"
| "globe"
| "graph"
| "heart_filled"
| "heart_outline"
| "hexagon"
@@ -136,6 +137,7 @@
| "publictags_slim"
| "publictags"
| "qr_code"
| "refresh_menu"
| "refresh"
| "repeat"
| "restAPI"
+1 -1
View File
@@ -16,7 +16,7 @@ const PRESETS = {
garnet: 'https://explorer.garnetchain.com',
gnosis: 'https://gnosis.blockscout.com',
immutable: 'https://explorer.immutable.com',
mega_eth: 'https://eth.blockscout.com', // FIXME: change host when instance is ready
mega_eth: 'https://megaeth-testnet.blockscout.com',
mekong: 'https://mekong.blockscout.com',
neon_devnet: 'https://neon-devnet.blockscout.com',
optimism: 'https://optimism.blockscout.com',
+37
View File
@@ -0,0 +1,37 @@
export interface UptimeRealTimeData {
end_block: number;
instant_mgas_per_second: number;
instant_mini_block_interval: number;
instant_tps: number;
latest_mini_block_id: number;
updated_at: string;
}
export interface UptimeHistoryItem {
value: number;
timestamp: number;
}
export interface UptimeHistoryFull {
historical_gas_per_second_3h: Array<UptimeHistoryItem>;
historical_gas_per_second_7d: Array<UptimeHistoryItem>;
historical_gas_per_second_24h: Array<UptimeHistoryItem>;
historical_tps_3h: Array<UptimeHistoryItem>;
historical_tps_7d: Array<UptimeHistoryItem>;
historical_tps_24h: Array<UptimeHistoryItem>;
historical_mini_block_interval_3h: Array<UptimeHistoryItem>;
historical_mini_block_interval_7d: Array<UptimeHistoryItem>;
historical_mini_block_interval_24h: Array<UptimeHistoryItem>;
}
export type UptimeSocketData = {
realtime: UptimeRealTimeData;
timestamp: number;
type: 'realtime_metrics';
} | {
data: UptimeHistoryFull;
type: 'history_full';
} | {
data: Partial<Record<keyof UptimeHistoryFull, UptimeHistoryItem>>;
type: 'history_delta';
};
+92
View File
@@ -0,0 +1,92 @@
import React from 'react';
import config from 'configs/app';
import { test, expect } from 'playwright/lib';
import { port as socketPort } from 'playwright/utils/socket';
import { MINUTE, SECOND } from 'toolkit/utils/consts';
import Uptime from './Uptime';
const now = new Date('2022-11-11T12:00:00Z').getTime();
const REALTIME_DATA = {
realtime: {
end_block: 14416819,
instant_tps: 213,
instant_mgas_per_second: 50.91,
instant_mini_block_interval: 10.53,
latest_mini_block_id: 1208627478,
updated_at: '2025-08-19T10:04:53.167705977Z',
},
timestamp: 1755597893167,
type: 'realtime_metrics',
};
const HISTORY_FULL_DATA = {
data: {
historical_gas_per_second_3h: [
{ timestamp: (now - 5 * MINUTE) / SECOND, value: 78093010 },
{ timestamp: (now - 4 * MINUTE) / SECOND, value: 120212720 },
{ timestamp: (now - 3 * MINUTE) / SECOND, value: 49049412 },
{ timestamp: (now - 2 * MINUTE) / SECOND, value: 70900270 },
{ timestamp: (now - MINUTE) / SECOND, value: 97225784 },
],
historical_gas_per_second_24h: [],
historical_gas_per_second_7d: [],
historical_tps_3h: [
{ timestamp: (now - 5 * MINUTE) / SECOND, value: 196 },
{ timestamp: (now - 4 * MINUTE) / SECOND, value: 203.2 },
{ timestamp: (now - 3 * MINUTE) / SECOND, value: 197.6 },
{ timestamp: (now - 2 * MINUTE) / SECOND, value: 215.4 },
{ timestamp: (now - MINUTE) / SECOND, value: 218.2 },
],
historical_tps_24h: [],
historical_tps_7d: [],
historical_mini_block_interval_3h: [
{ timestamp: (now - 5 * MINUTE) / SECOND, value: 10.537408 },
{ timestamp: (now - 4 * MINUTE) / SECOND, value: 10.593221 },
{ timestamp: (now - 3 * MINUTE) / SECOND, value: 10.537408 },
{ timestamp: (now - 2 * MINUTE) / SECOND, value: 10.593221 },
{ timestamp: (now - MINUTE) / SECOND, value: 10.570825 },
],
historical_mini_block_interval_24h: [],
historical_mini_block_interval_7d: [],
},
type: 'history_full',
};
const HISTORY_DELTA_DATA = {
data: {
historical_gas_per_second_3h: { timestamp: now / SECOND, value: 87762230 },
historical_tps_3h: { timestamp: now / SECOND, value: 211.8 },
historical_mini_block_interval_3h: { timestamp: now / SECOND, value: 10.593221 },
},
type: 'history_delta',
};
const WEBSOCKET_URL = `ws://${ config.app.host }:${ socketPort }`;
test('default view', async({ render, createSocket, mockEnvs, page }) => {
await mockEnvs([
[ 'NEXT_PUBLIC_MEGA_ETH_SOCKET_URL_METRICS', WEBSOCKET_URL ],
]);
const component = await render(<Uptime/>, undefined, { withSocket: true });
const socket = await createSocket();
socket.send(JSON.stringify(HISTORY_FULL_DATA));
socket.send(JSON.stringify(REALTIME_DATA));
await expect(component).toHaveScreenshot();
await page.waitForFunction(() => {
return document.querySelector('path[data-name="chart-TPS-small"]')?.getAttribute('opacity') === '1';
});
await page.waitForFunction(() => {
return document.querySelector('path[data-name="chart-MGass-small"]')?.getAttribute('opacity') === '1';
});
await page.waitForFunction(() => {
return document.querySelector('path[data-name="chart-Block_time_ms-small"]')?.getAttribute('opacity') === '1';
});
await component.locator('.ChartOverlay').first().hover({ position: { x: 400, y: 200 } });
socket.send(JSON.stringify(HISTORY_DELTA_DATA));
await expect(component).toHaveScreenshot();
});
+26
View File
@@ -0,0 +1,26 @@
import React from 'react';
import PageTitle from 'ui/shared/Page/PageTitle';
import UptimeCharts from './UptimeCharts';
import UptimeStats from './UptimeStats';
import UptimeStatus from './UptimeStatus';
import useUptimeSocketData from './useUptimeSocketData';
const Uptime = () => {
const { realtimeData, historyData, status, onReconnect } = useUptimeSocketData();
return (
<>
<PageTitle
title="Uptime"
contentAfter={ <UptimeStatus status={ status } onReconnect={ onReconnect }/> }
/>
<UptimeStats realtimeData={ realtimeData }/>
<UptimeCharts historyData={ historyData }/>
</>
);
};
export default React.memo(Uptime);
+220
View File
@@ -0,0 +1,220 @@
import { Box, Flex, Grid, GridItem } from '@chakra-ui/react';
import * as d3 from 'd3';
import React from 'react';
import type { UptimeHistoryFull, UptimeHistoryItem } from 'types/api/megaEth';
import type { AxesConfigFn } from 'ui/shared/chart/types';
import { Heading } from 'toolkit/chakra/heading';
import { DAY, HOUR, SECOND } from 'toolkit/utils/consts';
import ChartWidget from 'ui/shared/chart/ChartWidget';
import TagGroupSelect from 'ui/shared/tagGroupSelect/TagGroupSelect';
const INTERVALS = [
{ id: '3h', title: '3h' } as const,
{ id: '24h', title: '24h' } as const,
{ id: '7d', title: '7d' } as const,
];
type IntervalId = (typeof INTERVALS)[number]['id'];
const TIME_FORMAT = '%e %b %Y, %H:%M:%S';
const AXES_CONFIG_BASE: AxesConfigFn = ({ isEnlarged, isMobile }) => ({
y: {
scale: { min: 0 },
},
x: {
ticks: isEnlarged && !isMobile ? 8 : 5,
},
});
const AXES_CONFIG_LONG: AxesConfigFn = () => ({
y: {
scale: { min: 0 },
},
});
const filterByInterval = (interval: IntervalId, now: number) => ({ date }: { date: Date }) => {
switch (interval) {
case '3h':
return date.getTime() > now - 3 * HOUR;
case '24h':
return date.getTime() > now - 24 * HOUR;
case '7d':
return date.getTime() > now - 7 * DAY;
}
};
// Algorithm provided by MegaETH team
const smoothData = (data: Array<UptimeHistoryItem>, windowSize: number): Array<UptimeHistoryItem> => {
const result: Array<UptimeHistoryItem> = [];
const halfWindow = Math.floor(windowSize / 2);
for (let i = 0; i < data.length; i++) {
const windowStart = Math.max(0, i - halfWindow);
const windowEnd = Math.min(data.length, i + halfWindow + 1);
const windowValues = data.slice(windowStart, windowEnd);
const validValues = windowValues.filter((point) => point.value !== null);
const average = validValues.length > 0 ?
validValues.reduce((sum, point) => sum + (point.value || 0), 0) / validValues.length :
null;
const value = average || data[i].value || 0;
result.push({ ...data[i], value });
}
return result;
};
interface Props {
historyData: UptimeHistoryFull | null;
}
const UptimeCharts = ({ historyData }: Props) => {
const [ interval, setInterval ] = React.useState<IntervalId>('3h');
const axesConfig = React.useMemo(() => {
switch (interval) {
case '3h':
case '24h':
return AXES_CONFIG_BASE;
case '7d':
return AXES_CONFIG_LONG;
}
}, [ interval ]);
const tpsItems = React.useMemo(() => {
if (!historyData) {
return [];
}
const data = (() => {
switch (interval) {
case '3h':
return historyData.historical_tps_3h;
case '7d':
return historyData.historical_tps_7d;
case '24h':
return historyData.historical_tps_24h;
}
})();
const now = Date.now();
const smoothedData = smoothData(data, 7);
return smoothedData
.map(({ value, timestamp }) => {
const date = new Date(timestamp * SECOND);
return { date, value: Number(value.toFixed(0)), dateLabel: d3.timeFormat(TIME_FORMAT)(date) };
})
.filter(filterByInterval(interval, now));
}, [ historyData, interval ]);
const gasItems = React.useMemo(() => {
if (!historyData) {
return [];
}
const data = (() => {
switch (interval) {
case '3h':
return historyData.historical_gas_per_second_3h;
case '7d':
return historyData.historical_gas_per_second_7d;
case '24h':
return historyData.historical_gas_per_second_24h;
}
})();
const now = Date.now();
const smoothedData = smoothData(data, 7);
return smoothedData
.map(({ value, timestamp }) => {
const date = new Date(timestamp * SECOND);
return { date, value: Number((value / 1_000_000).toFixed(2)), dateLabel: d3.timeFormat(TIME_FORMAT)(date) };
})
.filter(filterByInterval(interval, now));
}, [ historyData, interval ]);
const blockIntervalItems = React.useMemo(() => {
if (!historyData) {
return [];
}
const data = (() => {
switch (interval) {
case '3h':
return historyData.historical_mini_block_interval_3h;
case '7d':
return historyData.historical_mini_block_interval_7d;
case '24h':
return historyData.historical_mini_block_interval_24h;
}
})();
const now = Date.now();
const smoothedData = smoothData(data, 7);
return smoothedData
.map(({ value, timestamp }) => {
const date = new Date(timestamp * SECOND);
return { date, value: Number(value.toFixed(1)), dateLabel: d3.timeFormat(TIME_FORMAT)(date) };
})
.filter(filterByInterval(interval, now));
}, [ historyData, interval ]);
const handleIntervalChange = React.useCallback((newInterval: IntervalId) => {
setInterval(newInterval);
}, [ setInterval ]);
return (
<Box>
<Flex
alignItems="center"
justifyContent="space-between"
mb={ 6 }
>
<Heading level="2">Real-time statistics</Heading>
<TagGroupSelect<IntervalId> items={ INTERVALS } onChange={ handleIntervalChange } value={ interval } tagSize="lg"/>
</Flex>
<Grid
templateColumns={{ base: '1fr', lg: 'repeat(2, 1fr)' }}
gap={ 4 }
>
<GridItem colSpan={{ base: 1, lg: 2 }} minH={{ base: '220px', lg: '320px' }}>
<ChartWidget
title="TPS"
items={ tpsItems }
isLoading={ false }
isError={ false }
axesConfig={ axesConfig }
/>
</GridItem>
<GridItem minH={{ base: '220px', lg: '320px' }}>
<ChartWidget
title="MGas/s"
items={ gasItems }
isLoading={ false }
isError={ false }
axesConfig={ axesConfig }
/>
</GridItem>
<GridItem minH={{ base: '220px', lg: '320px' }}>
<ChartWidget
title="Block time (ms)"
items={ blockIntervalItems }
isLoading={ false }
isError={ false }
axesConfig={ axesConfig }
/>
</GridItem>
</Grid>
</Box>
);
};
export default React.memo(UptimeCharts);
+47
View File
@@ -0,0 +1,47 @@
import { Box } from '@chakra-ui/react';
import React from 'react';
import type { UptimeRealTimeData } from 'types/api/megaEth';
import StatsWidget from 'ui/shared/stats/StatsWidget';
interface Props {
realtimeData: UptimeRealTimeData | null;
}
const UptimeStats = ({ realtimeData }: Props) => {
return (
<Box
columnGap={ 2 }
rowGap={ 2 }
mb={ 8 }
display="grid"
gridTemplateColumns={{ base: '1fr', lg: 'repeat(4, 1fr)' }}
>
<StatsWidget
label="Current TPS"
hint="Number of transactions processed per second on the network"
value={ realtimeData ? Number(realtimeData.instant_tps).toLocaleString() : '-' }
/>
<StatsWidget
label="MGas/s"
hint="Number of computational gas consumed per second on the network"
value={ realtimeData ? Number(realtimeData.instant_mgas_per_second).toLocaleString(undefined, { maximumFractionDigits: 2 }) : '-' }
/>
<StatsWidget
label="Block height"
hint="Number of blocks created since the genesis block"
value={ realtimeData ? Number(realtimeData.latest_mini_block_id).toLocaleString() : '-' }
/>
<StatsWidget
label="Block time"
hint="Time taken by the sequencer to produce a new block"
valuePostfix=" ms"
value={ realtimeData ? Number(realtimeData.instant_mini_block_interval).toLocaleString(undefined, { maximumFractionDigits: 2 }) : '-' }
/>
</Box>
);
};
export default React.memo(UptimeStats);
+42
View File
@@ -0,0 +1,42 @@
import { chakra, HStack } from '@chakra-ui/react';
import React from 'react';
import { Button } from 'toolkit/chakra/button';
import IconSvg from 'ui/shared/IconSvg';
import StatusTag from 'ui/shared/statusTag/StatusTag';
import type { Status } from './useUptimeSocketData';
interface Props {
status: Status;
onReconnect: () => void;
}
const UptimeStatus = ({ status, onReconnect }: Props) => {
const statusTag = (() => {
switch (status) {
case 'connected':
return <StatusTag type="ok" text="Connected"/>;
case 'disconnected':
case 'error':
return <StatusTag type="error" text="Disconnected"/>;
case 'initial':
return <StatusTag type="pending" text="Initializing"/>;
}
return null;
})();
return (
<HStack ml="auto" columnGap={ 3 }>
{ statusTag }
<Button variant="link" gap={ 1 } onClick={ onReconnect } disabled={ status === 'connected' }>
<IconSvg name="refresh" boxSize={ 5 }/>
<chakra.span hideBelow="lg" fontSize="sm">Refresh</chakra.span>
</Button>
</HStack>
);
};
export default React.memo(UptimeStatus);
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+91
View File
@@ -0,0 +1,91 @@
import React from 'react';
import type { UptimeHistoryFull, UptimeRealTimeData, UptimeSocketData } from 'types/api/megaEth';
import config from 'configs/app';
import { SECOND } from 'toolkit/utils/consts';
const megaEthFeature = config.features.megaEth;
export type Status = 'initial' | 'connected' | 'disconnected' | 'error';
export default function useUptimeSocketData() {
const websocketRef = React.useRef<WebSocket | null>(null);
const heartbeatRef = React.useRef<number | undefined>(undefined);
const [ status, setStatus ] = React.useState<Status>('initial');
const [ realtimeData, setRealtimeData ] = React.useState<UptimeRealTimeData | null>(null);
const [ historyData, setHistoryData ] = React.useState<UptimeHistoryFull | null>(null);
const connect = React.useCallback(() => {
if (!megaEthFeature.isEnabled) {
return;
}
websocketRef.current = new WebSocket(megaEthFeature.socketUrl.metrics);
websocketRef.current.onmessage = async(event) => {
try {
const data = JSON.parse(event.data) as UptimeSocketData;
switch (data.type) {
case 'realtime_metrics':
setRealtimeData(data.realtime);
break;
case 'history_full':
setHistoryData(data.data);
break;
case 'history_delta':
setHistoryData((prev) => {
if (!prev) {
return null;
}
return Object.entries(data.data).reduce((acc, [ key, value ]) => {
acc[key as keyof UptimeHistoryFull] = [ ...(acc[key as keyof UptimeHistoryFull] || []), value ];
return acc;
}, { ...prev });
});
break;
}
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
}
};
websocketRef.current.onopen = () => {
setStatus('connected');
heartbeatRef.current = window.setInterval(() => {
if (websocketRef.current?.readyState === WebSocket.OPEN) {
websocketRef.current?.send('ping');
}
}, 10 * SECOND);
};
websocketRef.current.onerror = (error) => {
// eslint-disable-next-line no-console
console.error(error);
setStatus('error');
};
websocketRef.current.onclose = (event) => {
// eslint-disable-next-line no-console
console.error('WebSocket closed', event);
setStatus('disconnected');
};
}, []);
const onReconnect = React.useCallback(() => {
connect();
}, [ connect ]);
React.useEffect(() => {
connect();
return () => {
websocketRef.current?.close(4000, 'Component unmounted');
window.clearInterval(heartbeatRef.current);
};
}, [ connect ]);
return { status, realtimeData, historyData, onReconnect };
}
+1 -1
View File
@@ -38,7 +38,7 @@ test('base view +@dark-mode +@mobile', async({ render, mockApiResponse, page })
const component = await render(<Chart/>, { hooksConfig });
await page.waitForResponse(chartApiUrl);
await page.waitForFunction(() => {
return document.querySelector('path[data-name="chart-Charttitle-fullscreen"]')?.getAttribute('opacity') === '1';
return document.querySelector('path[data-name="chart-Chart_title-fullscreen"]')?.getAttribute('opacity') === '1';
});
await expect(component).toHaveScreenshot();
});
+1 -1
View File
@@ -25,7 +25,7 @@ test('base view +@dark-mode +@mobile', async({ render, mockApiResponse, mockEnvs
const component = await render(<GasTracker/>);
await page.waitForResponse(chartApiUrl);
await page.waitForFunction(() => {
return document.querySelector('path[data-name="chart-Averagegasprice-small"]')?.getAttribute('opacity') === '1';
return document.querySelector('path[data-name="chart-Average_gas_price-small"]')?.getAttribute('opacity') === '1';
});
await expect(component).toHaveScreenshot();
});
+8 -7
View File
@@ -55,11 +55,12 @@ const ChartSelectionX = ({ anchorEl, height, scale, data, onSelect }: Props) =>
const handleSelect = React.useCallback((x0: number, x1: number) => {
const startDate = scale.invert(x0);
const endDate = scale.invert(x1);
const xStep = dayjs(data[0].items[1].date).diff(dayjs(data[0].items[0].date));
if (Math.abs(dayjs(startDate).diff(endDate, 'day')) > SELECTION_THRESHOLD) {
if (Math.abs(dayjs(startDate).diff(endDate)) > SELECTION_THRESHOLD * xStep) {
onSelect([ dayjs.min(dayjs(startDate), dayjs(endDate)).toDate(), dayjs.max(dayjs(startDate), dayjs(endDate)).toDate() ]);
}
}, [ onSelect, scale ]);
}, [ onSelect, scale, data ]);
const cleanUp = React.useCallback(() => {
isActive.current = false;
@@ -92,19 +93,19 @@ const ChartSelectionX = ({ anchorEl, height, scale, data, onSelect }: Props) =>
const [ x ] = d3.pointer(event, anchorEl);
isActive.current = true;
startX.current = x;
})
}, { passive: true })
.on('mousemove.selectionX', (event: MouseEvent) => {
if (isActive.current) {
const [ x ] = d3.pointer(event, anchorEl);
startX.current && drawSelection(startX.current, x);
endX.current = x;
}
})
}, { passive: true })
.on('mouseup.selectionX', handelMouseUp)
.on('touchstart.selectionX', (event: TouchEvent) => {
const pointers = d3.pointers(event, anchorEl);
isActive.current = pointers.length === 2;
})
}, { passive: true })
.on('touchmove.selectionX', (event: TouchEvent) => {
if (isActive.current) {
const pointers = d3.pointers(event, anchorEl);
@@ -116,8 +117,8 @@ const ChartSelectionX = ({ anchorEl, height, scale, data, onSelect }: Props) =>
endX.current = pointers[1][0];
}
}
})
.on('touchend.selectionX', handelMouseUp);
}, { passive: true })
.on('touchend.selectionX', handelMouseUp, { passive: true });
d3.select('body').on('mouseup.selectionX', function(event) {
const isOutside = startX.current !== undefined && event.target !== anchorD3.node();
+4 -4
View File
@@ -125,22 +125,22 @@ const ChartTooltip = ({
.on('touchmove.tooltip', (event: TouchEvent) => event.preventDefault()) // prevent scrolling
.on(`touchstart.tooltip`, (event: TouchEvent) => {
isMultiTouch = event.touches.length > 1;
})
}, { passive: true })
.on(`touchend.tooltip`, (event: TouchEvent) => {
if (isMultiTouch && event.touches.length === 0) {
isMultiTouch = false;
}
})
}, { passive: true })
.on('pointerenter.tooltip pointerdown.tooltip', (event: PointerEvent) => {
if (!isMultiTouch) {
trackerId.current = createPointerTracker(event);
}
})
}, { passive: true })
.on('pointermove.tooltip', (event: PointerEvent) => {
if (event.pointerType === 'mouse' && !isMultiTouch && trackerId.current === undefined) {
trackerId.current = createPointerTracker(event);
}
});
}, { passive: true });
return () => {
anchorD3.on('touchmove.tooltip pointerenter.tooltip pointerdown.tooltip', null);
+4 -4
View File
@@ -35,7 +35,7 @@ test('base view +@dark-mode', async({ render, page }) => {
const component = await render(<ChartWidget { ...props } href={{ pathname: '/stats/[id]', query: { id: 'test' } }}/>);
await page.waitForFunction(() => {
return document.querySelector('path[data-name="chart-Nativecoincirculatingsupply-small"]')?.getAttribute('opacity') === '1';
return document.querySelector('path[data-name="chart-Native_coin_circulating_supply-small"]')?.getAttribute('opacity') === '1';
});
await expect(component).toHaveScreenshot();
@@ -84,7 +84,7 @@ test('small values', async({ render, page }) => {
const component = await render(<ChartWidget { ...modifiedProps }/>);
await page.waitForFunction(() => {
return document.querySelector('path[data-name="chart-Nativecoincirculatingsupply-small"]')?.getAttribute('opacity') === '1';
return document.querySelector('path[data-name="chart-Native_coin_circulating_supply-small"]')?.getAttribute('opacity') === '1';
});
await expect(component).toHaveScreenshot();
});
@@ -109,7 +109,7 @@ test('small variations in big values', async({ render, page }) => {
const component = await render(<ChartWidget { ...modifiedProps }/>);
await page.waitForFunction(() => {
return document.querySelector('path[data-name="chart-Nativecoincirculatingsupply-small"]')?.getAttribute('opacity') === '1';
return document.querySelector('path[data-name="chart-Native_coin_circulating_supply-small"]')?.getAttribute('opacity') === '1';
});
await expect(component).toHaveScreenshot();
});
@@ -125,7 +125,7 @@ test('incomplete day', async({ render, page }) => {
const component = await render(<ChartWidget { ...modifiedProps }/>);
await page.waitForFunction(() => {
return document.querySelector('path[data-name="chart-Nativecoincirculatingsupply-small"]')?.getAttribute('opacity') === '1';
return document.querySelector('path[data-name="chart-Native_coin_circulating_supply-small"]')?.getAttribute('opacity') === '1';
});
await expect(component).toHaveScreenshot();
+4 -1
View File
@@ -1,7 +1,7 @@
import { chakra, Flex } from '@chakra-ui/react';
import React, { useRef } from 'react';
import type { TimeChartItem } from './types';
import type { AxesConfigFn, TimeChartItem } from './types';
import { route, type Route } from 'nextjs-routes';
@@ -27,6 +27,7 @@ export type Props = {
emptyText?: string;
noAnimation?: boolean;
href?: Route;
axesConfig?: AxesConfigFn;
};
const ChartWidget = ({
@@ -40,6 +41,7 @@ const ChartWidget = ({
emptyText,
noAnimation,
href,
axesConfig,
}: Props) => {
const ref = useRef<HTMLDivElement>(null);
const { zoomRange, handleZoom, handleZoomReset } = useZoom();
@@ -57,6 +59,7 @@ const ChartWidget = ({
handleZoom={ handleZoom }
zoomRange={ zoomRange }
noAnimation={ noAnimation }
axesConfig={ axesConfig }
/>
);
+4 -1
View File
@@ -1,7 +1,7 @@
import { Box, Center, Flex, Text } from '@chakra-ui/react';
import React from 'react';
import type { TimeChartItem } from './types';
import type { AxesConfigFn, TimeChartItem } from './types';
import type { Resolution } from '@blockscout/stats-types';
import { Link } from 'toolkit/chakra/link';
@@ -23,6 +23,7 @@ export type Props = {
isEnlarged?: boolean;
noAnimation?: boolean;
resolution?: Resolution;
axesConfig?: AxesConfigFn;
};
const ChartWidgetContent = ({
@@ -37,6 +38,7 @@ const ChartWidgetContent = ({
isEnlarged,
noAnimation,
resolution,
axesConfig,
}: Props) => {
const hasItems = items && items.length > 2;
@@ -83,6 +85,7 @@ const ChartWidgetContent = ({
isEnlarged={ isEnlarged }
noAnimation={ noAnimation }
resolution={ resolution }
axesConfig={ axesConfig }
/>
<ChartWatermarkIcon w="162px" h="15%"/>
</Box>
+9 -6
View File
@@ -1,9 +1,10 @@
import { useToken } from '@chakra-ui/react';
import * as d3 from 'd3';
import { defaultsDeep } from 'es-toolkit/compat';
import React from 'react';
import { Resolution } from '@blockscout/stats-types';
import type { ChartMargin, TimeChartData, TimeChartItem } from 'ui/shared/chart/types';
import type { AxesConfigFn, ChartMargin, TimeChartData, TimeChartItem } from 'ui/shared/chart/types';
import useIsMobile from 'lib/hooks/useIsMobile';
import { useColorModeValue } from 'toolkit/chakra/color-mode';
@@ -26,6 +27,7 @@ interface Props {
margin?: ChartMargin;
noAnimation?: boolean;
resolution?: Resolution;
axesConfig?: AxesConfigFn;
}
const DEFAULT_CHART_MARGIN = { bottom: 20, left: 10, right: 20, top: 10 };
@@ -40,10 +42,11 @@ const ChartWidgetGraph = ({
noAnimation,
resolution,
zoomRange,
axesConfig: axesConfigProps,
}: Props) => {
const isMobile = useIsMobile();
const [ color ] = useToken('colors', useColorModeValue('theme.graph.line._light', 'theme.graph.line._dark'));
const chartId = `chart-${ title.split(' ').join('') }-${ isEnlarged ? 'fullscreen' : 'small' }`;
const chartId = `chart-${ title.replace(/[^\w\s]/g, '').split(' ').join('_') }-${ isEnlarged ? 'fullscreen' : 'small' }`;
const overlayRef = React.useRef<SVGRectElement>(null);
@@ -53,8 +56,8 @@ const ChartWidgetGraph = ({
items
.filter((item) => item.date >= range[0] && item.date <= range[1])
.map((item) => ({
...item,
dateLabel: getDateLabel(item.date, item.date_to, resolution),
...item,
})),
[ items, range, resolution ]);
@@ -62,7 +65,7 @@ const ChartWidgetGraph = ({
const margin: ChartMargin = React.useMemo(() => ({ ...DEFAULT_CHART_MARGIN, ...marginProps }), [ marginProps ]);
const axesConfig = React.useMemo(() => {
return {
return defaultsDeep(axesConfigProps?.({ isEnlarged, isMobile }), {
x: {
ticks: isEnlarged && !isMobile ? 8 : 4,
},
@@ -70,8 +73,8 @@ const ChartWidgetGraph = ({
ticks: isEnlarged ? 6 : 3,
nice: true,
},
};
}, [ isEnlarged, isMobile ]);
});
}, [ axesConfigProps, isEnlarged, isMobile ]);
const {
ref,
+4 -4
View File
@@ -40,14 +40,14 @@ export function trackPointer(event: PointerEvent, { start, move, out, end }: Poi
// disable current tracker when entering multi touch mode
touches.length > 1 && untrack(sourceEvent);
})
}, { passive: true })
.on(`pointerup.${ id } pointercancel.${ id } lostpointercapture.${ id }`, (sourceEvent: PointerEvent) => {
if (sourceEvent.pointerId !== id) {
return;
}
untrack(sourceEvent);
})
}, { passive: true })
.on(`pointermove.${ id }`, (sourceEvent) => {
if (sourceEvent.pointerId !== id) {
return;
@@ -56,7 +56,7 @@ export function trackPointer(event: PointerEvent, { start, move, out, end }: Poi
tracker.prev = tracker.point;
tracker.point = d3.pointer(sourceEvent, target);
move?.(tracker);
})
}, { passive: true })
.on(`pointerout.${ id }`, (e) => {
if (e.pointerId !== id) {
return;
@@ -64,7 +64,7 @@ export function trackPointer(event: PointerEvent, { start, move, out, end }: Poi
tracker.sourceEvent = e;
tracker.point = null;
out?.(tracker);
});
}, { passive: true });
start?.(tracker);
+5
View File
@@ -38,9 +38,14 @@ export interface AxisConfig {
ticks?: number;
nice?: boolean;
noLabel?: boolean;
scale?: {
min?: number;
};
}
export interface AxesConfig {
x?: AxisConfig;
y?: AxisConfig;
}
export type AxesConfigFn = (props: { isEnlarged?: boolean; isMobile?: boolean }) => AxesConfig;
+6 -4
View File
@@ -3,7 +3,7 @@ import { maxBy, uniq } from 'es-toolkit';
import type { AxesConfig, AxisConfig, TimeChartData } from '../types';
import { MONTH, YEAR } from 'toolkit/utils/consts';
import { DAY, MONTH, YEAR } from 'toolkit/utils/consts';
export const DEFAULT_MAXIMUM_SIGNIFICANT_DIGITS = 2;
export const DEFAULT_MAXIMUM_FRACTION_DIGITS = 3;
@@ -51,8 +51,10 @@ const tickFormatterX = (axis: d3.Axis<d3.NumberValue>) => (d: d3.AxisDomain) =>
format = d3.timeFormat('%Y');
} else if (span > 4 * MONTH) {
format = d3.timeFormat('%b \'%y');
} else {
} else if (span > 2 * DAY) {
format = d3.timeFormat('%d %b');
} else {
format = d3.timeFormat('%H:%M');
}
return format(d as Date);
@@ -64,10 +66,10 @@ function getAxisParamsY(data: Data, config?: AxisConfig) {
const max = d3.max(data, ({ items }) => d3.max(items, ({ value }) => value)) ?? 0;
const scale = config?.nice ?
d3.scaleLinear()
.domain([ min, max ])
.domain([ config?.scale?.min ?? min, max ])
.nice(config?.ticks ?? DEFAULT_TICKS_NUM) :
d3.scaleLinear()
.domain([ min, max ]);
.domain([ config?.scale?.min ?? min, max ]);
const ticks = scale.ticks(config?.ticks ?? DEFAULT_TICKS_NUM);
const labelFormatParams = getYLabelFormatParams(ticks);
@@ -83,6 +83,7 @@ const NavLinkGroup = ({ item, isCollapsed }: Props) => {
<Text
{ ...styleProps.textProps }
ml={ 3 }
whiteSpace="nowrap"
>
{ item.text }
</Text>