diff --git a/src/containers/App/index.tsx b/src/containers/App/index.tsx index 246627e07..29e96fa80 100644 --- a/src/containers/App/index.tsx +++ b/src/containers/App/index.tsx @@ -26,7 +26,7 @@ import { AMENDMENTS_ROUTE, AMENDMENT_ROUTE, } from './routes' -import Ledgers from '../Ledgers' +import { LedgersPage as Ledgers } from '../Ledgers' import { Ledger } from '../Ledger' import { AccountsRouter } from '../Accounts/AccountsRouter' import { Transaction } from '../Transactions' diff --git a/src/containers/App/test/App.test.jsx b/src/containers/App/test/App.test.jsx index 98b7baf12..15674340b 100644 --- a/src/containers/App/test/App.test.jsx +++ b/src/containers/App/test/App.test.jsx @@ -17,7 +17,7 @@ import { Error } from '../../../rippled/lib/utils' jest.mock('../../Ledgers/LedgerMetrics', () => ({ __esModule: true, - default: () => null, + LedgerMetrics: () => null, })) jest.mock('xrpl-client', () => ({ diff --git a/src/containers/Ledgers/LedgerEntryHash.tsx b/src/containers/Ledgers/LedgerEntryHash.tsx new file mode 100644 index 000000000..e9ae1fb4e --- /dev/null +++ b/src/containers/Ledgers/LedgerEntryHash.tsx @@ -0,0 +1,39 @@ +import { useTranslation } from 'react-i18next' +import SuccessIcon from '../shared/images/success.svg' +import { LedgerEntryValidation } from './LedgerEntryValidator' +import { LedgerEntryHashTrustedCount } from './LedgerEntryHashTrustedCount' + +export const LedgerEntryHash = ({ hash }: { hash: any }) => { + const { t } = useTranslation() + const shortHash = hash.hash.substr(0, 6) + const barStyle = { background: `#${shortHash}` } + const validated = hash.validated && + return ( +
+
+
+
{hash.hash.substr(0, 6)}
+ {validated} +
+
+
+
{t('total')}:
+ {hash.validations.length} +
+ +
+
+ {hash.validations.map((validation, i) => ( + + ))} +
+
+ ) +} diff --git a/src/containers/Ledgers/LedgerEntryHashTrustedCount.tsx b/src/containers/Ledgers/LedgerEntryHashTrustedCount.tsx new file mode 100644 index 000000000..c2016419f --- /dev/null +++ b/src/containers/Ledgers/LedgerEntryHashTrustedCount.tsx @@ -0,0 +1,60 @@ +import { useTranslation } from 'react-i18next' +import classNames from 'classnames' +import { useMemo } from 'react' +import { ValidationStream } from 'xrpl' +import { useTooltip } from '../shared/components/Tooltip' +import { useVHSValidators } from '../shared/components/VHSValidators/VHSValidatorsContext' + +export const LedgerEntryHashTrustedCount = ({ + validations, +}: { + validations: ValidationStream[] +}) => { + const { t } = useTranslation() + const { hideTooltip, showTooltip } = useTooltip() + const { unl, validators } = useVHSValidators() + + const status = useMemo(() => { + const missing = [...(unl || [])] + validations.forEach((v) => { + if (!validators?.[v.validation_public_key]) { + return + } + + const missingIndex = missing.findIndex( + (assumedMissing) => assumedMissing === v.validation_public_key, + ) + if (missingIndex !== -1) { + missing.splice(missingIndex, 1) + } + }) + + return { + missing: missing.map((v) => validators?.[v]), + trustedCount: (unl?.length || 0) - missing.length, + } + }, [unl, validations]) + + return status.trustedCount ? ( + { + const { missing } = status + + missing.length && showTooltip('missing', e, { missing }) + }} + onFocus={() => {}} + onKeyUp={() => {}} + onMouseLeave={() => hideTooltip()} + > +
{t('unl')}:
+ + {status.trustedCount}/{unl?.length} + +
+ ) : null +} diff --git a/src/containers/Ledgers/LedgerEntryTransaction.tsx b/src/containers/Ledgers/LedgerEntryTransaction.tsx new file mode 100644 index 000000000..acf884051 --- /dev/null +++ b/src/containers/Ledgers/LedgerEntryTransaction.tsx @@ -0,0 +1,34 @@ +import classNames from 'classnames' +import { getAction, getCategory } from '../shared/components/Transaction' +import { TRANSACTION_ROUTE } from '../App/routes' +import { TransactionActionIcon } from '../shared/components/TransactionActionIcon/TransactionActionIcon' +import { RouteLink } from '../shared/routing' +import { useTooltip } from '../shared/components/Tooltip' + +export const LedgerEntryTransaction = ({ + transaction, +}: { + transaction: any +}) => { + const { hideTooltip, showTooltip } = useTooltip() + + return ( + showTooltip('tx', e, transaction)} + onFocus={() => {}} + onMouseLeave={() => hideTooltip()} + to={TRANSACTION_ROUTE} + params={{ identifier: transaction.hash }} + > + + {transaction.hash} + + ) +} diff --git a/src/containers/Ledgers/LedgerEntryTransactions.tsx b/src/containers/Ledgers/LedgerEntryTransactions.tsx new file mode 100644 index 000000000..6ce29e6b2 --- /dev/null +++ b/src/containers/Ledgers/LedgerEntryTransactions.tsx @@ -0,0 +1,20 @@ +import { memo } from 'react' +import { Loader } from '../shared/components/Loader' +import { LedgerEntryTransaction } from './LedgerEntryTransaction' + +export const LedgerEntryTransactions = memo( + ({ transactions }: { transactions: any[] }) => ( + <> + {!transactions && } +
+ {transactions?.map((tx) => ( + + ))} +
+ + ), + (prevProps, nextProps) => + prevProps.transactions && + nextProps.transactions && + prevProps.transactions.length === nextProps.transactions.length, +) diff --git a/src/containers/Ledgers/LedgerEntryValidator.tsx b/src/containers/Ledgers/LedgerEntryValidator.tsx new file mode 100644 index 000000000..c6a7c25f5 --- /dev/null +++ b/src/containers/Ledgers/LedgerEntryValidator.tsx @@ -0,0 +1,49 @@ +import classNames from 'classnames' +import { useSelectedValidator } from './useSelectedValidator' +import { useTooltip } from '../shared/components/Tooltip' +import { useVHSValidators } from '../shared/components/VHSValidators/VHSValidatorsContext' + +export const LedgerEntryValidation = ({ + validation, + index, +}: { + validation: any + index: number +}) => { + const { showTooltip, hideTooltip } = useTooltip() + const { selectedValidator, setSelectedValidator } = useSelectedValidator() + const { validators } = useVHSValidators() + const className = classNames( + 'validation', + validators?.[validation.validation_public_key]?.unl && 'trusted', + selectedValidator && 'unselected', + selectedValidator === validation.validation_public_key && 'selected', + ) + + return ( +
+ showTooltip('validator', e, { + ...validation, + v: validators?.[validation.validation_public_key], + }) + } + onFocus={() => {}} + onKeyUp={() => {}} + onMouseLeave={() => hideTooltip()} + onClick={() => + setSelectedValidator( + selectedValidator && + selectedValidator === validation.validation_public_key + ? undefined + : validation.validation_public_key, + ) + } + > + {validation.partial &&
} +
+ ) +} diff --git a/src/containers/Ledgers/LedgerListEntry.tsx b/src/containers/Ledgers/LedgerListEntry.tsx new file mode 100644 index 000000000..1424e1dc0 --- /dev/null +++ b/src/containers/Ledgers/LedgerListEntry.tsx @@ -0,0 +1,74 @@ +import { useTranslation } from 'react-i18next' +import { RouteLink } from '../shared/routing' +import { LEDGER_ROUTE } from '../App/routes' +import { Amount } from '../shared/components/Amount' +import { LedgerEntryHash } from './LedgerEntryHash' +import { LedgerEntryTransactions } from './LedgerEntryTransactions' +import { Ledger } from '../shared/components/Streams/types' +import { + Tooltip, + TooltipProvider, + useTooltip, +} from '../shared/components/Tooltip' + +const SIGMA = '\u03A3' + +const LedgerIndex = ({ ledgerIndex }: { ledgerIndex: number }) => { + const { t } = useTranslation() + const flagLedger = ledgerIndex % 256 === 0 + return ( +
+ + {ledgerIndex} + +
+ ) +} + +export const LedgerListEntryInner = ({ ledger }: { ledger: Ledger }) => { + const { tooltip } = useTooltip() + const { t } = useTranslation() + const time = ledger.closeTime + ? new Date(ledger.closeTime).toLocaleTimeString() + : null + + return ( +
+
+ +
{time}
+ {/* Render Transaction Count (can be 0) */} + {ledger.txCount !== undefined && ( +
+ {t('txn_count')}:{ledger.txCount.toLocaleString()} +
+ )} + {/* Render Total Fees (can be 0) */} + {ledger.totalFees !== undefined && ( +
+ {SIGMA} {t('fees')}: + + + +
+ )} + +
+
+ {ledger.hashes.map((hash) => ( + + ))} +
+ +
+ ) +} + +export const LedgerListEntry = ({ ledger }: { ledger: Ledger }) => ( + + + +) diff --git a/src/containers/Ledgers/LedgerMetrics.jsx b/src/containers/Ledgers/LedgerMetrics.jsx deleted file mode 100644 index 8ee430b8a..000000000 --- a/src/containers/Ledgers/LedgerMetrics.jsx +++ /dev/null @@ -1,161 +0,0 @@ -import { withTranslation } from 'react-i18next' -import { Component } from 'react' -import PropTypes from 'prop-types' -import Tooltip from '../shared/components/Tooltip' -import { renderXRP } from '../shared/utils' -import PauseIcon from '../shared/images/ic_pause.svg' -import ResumeIcon from '../shared/images/ic_play.svg' -import './css/ledgerMetrics.scss' -import SocketContext from '../shared/SocketContext' - -const DEFAULTS = { - load_fee: '--', - txn_sec: '--', - txn_ledger: '--', - ledger_interval: '--', - avg_fee: '--', - quorum: '--', - nUnl: [], -} - -class LedgerMetrics extends Component { - constructor(props) { - super(props) - this.state = { - tooltip: null, - } - } - - static getDerivedStateFromProps(nextProps, prevState) { - return { ...prevState, ...nextProps } - } - - showTooltip = (event) => { - const { data, nUnl } = this.state - this.setState({ - tooltip: { - nUnl: data.nUnl, - mode: 'nUnl', - v: nUnl, - x: event.pageX, - y: event.pageY, - }, - }) - } - - hideTooltip = () => this.setState({ tooltip: null }) - - renderPause() { - const { t, onPause, paused } = this.props - const Icon = paused ? ResumeIcon : PauseIcon - const text = paused ? 'resume' : 'pause' - - return ( -
- - {t(text)} -
- ) - } - - render() { - const { language, t } = this.props - const { data: stateData } = this.state - const data = { ...DEFAULTS, ...stateData } - - if (data.load_fee === '--') { - data.load_fee = data.base_fee || '--' - } - delete data.base_fee - const items = Object.keys(data) - .map((key) => { - let content = null - - let className = 'label' - if (data[key] === undefined && key !== 'nUnl') { - content = '--' - } else if (key.includes('fee') && !isNaN(data[key])) { - content = renderXRP(data[key], language) - } else if (key === 'ledger_interval' && data[key] !== '--') { - content = `${data[key]} ${t('seconds_short')}` - } else if (key === 'nUnl' && data[key]?.length === 0) { - return null - } else if (key === 'nUnl') { - content = data[key].length - className = 'label n-unl-metric' - return ( -
{}} - onBlur={() => {}} - onMouseOver={(e) => this.showTooltip(e)} - onMouseOut={this.hideTooltip} - tabIndex={0} - key={key} - > - - {t(key)} - - {content} -
- ) - } else { - content = data[key] - } - - return ( -
-
{t(key)}
- {content} -
- ) - }) - .reverse() - - const { tooltip } = this.state - - // eslint-disable-next-line react/destructuring-assignment - const isOnline = this.context.getState().online - - return ( -
- {isOnline && ( - <> -
{this.renderPause()}
-
{items}
- - - )} -
- ) - } -} - -LedgerMetrics.contextType = SocketContext - -LedgerMetrics.propTypes = { - data: PropTypes.shape({}), - language: PropTypes.string.isRequired, - t: PropTypes.func.isRequired, - onPause: PropTypes.func.isRequired, - paused: PropTypes.bool.isRequired, -} - -LedgerMetrics.defaultProps = { - data: {}, -} - -export default withTranslation()(LedgerMetrics) diff --git a/src/containers/Ledgers/LedgerMetrics.tsx b/src/containers/Ledgers/LedgerMetrics.tsx new file mode 100644 index 000000000..83dd48fec --- /dev/null +++ b/src/containers/Ledgers/LedgerMetrics.tsx @@ -0,0 +1,120 @@ +import { useTranslation } from 'react-i18next' +import { Tooltip, useTooltip } from '../shared/components/Tooltip' +import { renderXRP } from '../shared/utils' +import PauseIcon from '../shared/images/ic_pause.svg' +import ResumeIcon from '../shared/images/ic_play.svg' +import './css/ledgerMetrics.scss' +import { useIsOnline } from '../shared/SocketContext' +import { useLanguage } from '../shared/hooks' +import { useStreams } from '../shared/components/Streams/StreamsContext' + +const DEFAULTS = { + load_fee: '--', + txn_sec: '--', + txn_ledger: '--', + ledger_interval: '--', + avg_fee: '--', + quorum: '--', + nUnl: [], +} + +export const LedgerMetrics = ({ + onPause, + paused, +}: { + onPause: any + paused: boolean +}) => { + const { metrics: suppliedData } = useStreams() + const data = { ...DEFAULTS, ...suppliedData } + const { tooltip, showTooltip, hideTooltip } = useTooltip() + const { t } = useTranslation() + const isOnline = useIsOnline() + const language = useLanguage() + + const renderPause = () => { + const Icon = paused ? ResumeIcon : PauseIcon + const text = paused ? 'resume' : 'pause' + + return ( +
+ + {t(text)} +
+ ) + } + + if (data.load_fee === '--') { + data.load_fee = data.base_fee || '--' + } + delete data.base_fee + const items = Object.keys(data) + .map((key) => { + let content: any = null + + let className = 'label' + if (data[key] === undefined && key !== 'nUnl') { + content = '--' + } else if (key.includes('fee') && !isNaN(data[key])) { + content = renderXRP(data[key], language) + } else if (key === 'ledger_interval' && data[key] !== '--') { + content = `${data[key]} ${t('seconds_short')}` + } else if (key === 'nUnl' && data[key]?.length === 0) { + return null + } else if (key === 'nUnl') { + content = data[key].length + className = 'label n-unl-metric' + return ( +
{}} + onBlur={() => {}} + onMouseOver={(e) => showTooltip('nUnl', e, { nUnl: data.nUnl })} + onMouseOut={() => hideTooltip()} + tabIndex={0} + key={key} + > + + {t(key)} + + {content} +
+ ) + } else { + content = data[key] + } + + return ( +
+
{t(key)}
+ {content} +
+ ) + }) + .reverse() + + return ( +
+ {isOnline && ( + <> +
{renderPause()}
+
{items}
+ + + )} +
+ ) +} diff --git a/src/containers/Ledgers/Ledgers.jsx b/src/containers/Ledgers/Ledgers.jsx deleted file mode 100644 index f0110e5c5..000000000 --- a/src/containers/Ledgers/Ledgers.jsx +++ /dev/null @@ -1,295 +0,0 @@ -import { Component } from 'react' -import { withTranslation } from 'react-i18next' -import PropTypes from 'prop-types' -import { CURRENCY_OPTIONS } from '../shared/transactionUtils' -import { localizeNumber } from '../shared/utils' -import Tooltip from '../shared/components/Tooltip' -import './css/ledgers.scss' -import SuccessIcon from '../shared/images/success.svg' -import DomainLink from '../shared/components/DomainLink' -import { Loader } from '../shared/components/Loader' -import SocketContext from '../shared/SocketContext' -import { getAction, getCategory } from '../shared/components/Transaction' -import { TransactionActionIcon } from '../shared/components/TransactionActionIcon/TransactionActionIcon' -import { Legend } from './Legend' -import { RouteLink } from '../shared/routing' -import { LEDGER_ROUTE, TRANSACTION_ROUTE, VALIDATOR_ROUTE } from '../App/routes' - -const SIGMA = '\u03A3' - -class Ledgers extends Component { - constructor(props) { - super(props) - this.state = { - ledgers: [], - validators: {}, - tooltip: null, - } - } - - static getDerivedStateFromProps(nextProps, prevState) { - return { - selected: nextProps.selected, - ledgers: nextProps.paused ? prevState.ledgers : nextProps.ledgers, - validators: nextProps.validators, - unlCount: nextProps.unlCount, - } - } - - getMissingValidators = (hash) => { - const { validators } = this.props - const unl = {} - - Object.keys(validators).forEach((pubkey) => { - if (validators[pubkey].unl) { - unl[pubkey] = false - } - }) - - hash.validations.forEach((v) => { - if (unl[v.pubkey] !== undefined) { - delete unl[v.pubkey] - } - }) - - return Object.keys(unl).map((pubkey) => validators[pubkey]) - } - - showTooltip = (mode, event, data) => { - const { validators } = this.state - this.setState({ - tooltip: { - ...data, - mode, - v: mode === 'validator' && validators[data.pubkey], - x: event.currentTarget.offsetLeft, - y: event.currentTarget.offsetTop, - }, - }) - } - - hideTooltip = () => this.setState({ tooltip: null }) - - renderSelected = () => { - const { validators, selected } = this.state - const v = validators[selected] || {} - return ( -
- {v.domain && } - - {selected} - -
- ) - } - - renderLedger = (ledger) => { - const time = ledger.close_time - ? new Date(ledger.close_time).toLocaleTimeString() - : null - const transactions = ledger.transactions || [] - - return ( -
-
- {this.renderLedgerIndex(ledger.ledger_index)} -
{time}
- {this.renderTxnCount(ledger.txn_count)} - {this.renderFees(ledger.total_fees)} - {ledger.transactions == null && } -
- {transactions.map(this.renderTransaction)} -
-
-
{ledger.hashes.map(this.renderHash)}
-
- ) - } - - renderLedgerIndex = (ledgerIndex) => { - const { t } = this.props - const flagLedger = ledgerIndex % 256 === 0 - return ( -
- - {ledgerIndex} - -
- ) - } - - renderTxnCount = (count) => { - const { t } = this.props - return count !== undefined ? ( -
- {t('txn_count')}:{count.toLocaleString()} -
- ) : null - } - - renderFees = (d) => { - const { t, language } = this.props - const options = { ...CURRENCY_OPTIONS, currency: 'XRP' } - const amount = localizeNumber(d, language, options) - return d !== undefined ? ( -
- {SIGMA} {t('fees')}:{amount} -
- ) : null - } - - renderTransaction = (tx) => ( - this.showTooltip('tx', e, tx)} - onFocus={(e) => {}} - onMouseLeave={this.hideTooltip} - to={TRANSACTION_ROUTE} - params={{ identifier: tx.hash }} - > - - {tx.hash} - - ) - - renderHash = (hash) => { - const { t } = this.props - const shortHash = hash.hash.substr(0, 6) - const barStyle = { background: `#${shortHash}` } - const validated = hash.validated && - return ( -
-
-
-
{hash.hash.substr(0, 6)}
- {validated} -
-
-
-
{t('total')}:
- {hash.validations.length} -
- {this.renderTrustedCount(hash)} -
-
- {hash.validations.map(this.renderValidator)} -
-
- ) - } - - renderTrustedCount = (hash) => { - const { t } = this.props - const { unlCount } = this.state - const className = hash.trusted_count < unlCount ? 'missed' : null - const missing = - hash.trusted_count && className === 'missed' - ? this.getMissingValidators(hash) - : null - - return hash.trusted_count ? ( - - missing && - missing.length && - this.showTooltip('missing', e, { missing }) - } - onFocus={(e) => {}} - onKeyUp={(e) => {}} - onMouseLeave={this.hideTooltip} - > -
{t('unl')}:
- - {hash.trusted_count}/{unlCount} - -
- ) : null - } - - renderValidator = (v, i) => { - const { setSelected } = this.props - const { selected: selectedState } = this.state - const trusted = v.unl ? 'trusted' : '' - const unselected = selectedState ? 'unselected' : '' - const selected = selectedState === v.pubkey ? 'selected' : '' - const className = `validation ${trusted} ${unselected} ${selected} ${v.pubkey}` - const partial = v.partial ?
: null - return ( -
this.showTooltip('validator', e, v)} - onFocus={(e) => {}} - onKeyUp={(e) => {}} - onMouseLeave={this.hideTooltip} - onClick={() => setSelected(v.pubkey)} - > - {partial} -
- ) - } - - render() { - const { ledgers, selected, tooltip } = this.state - const { t, language, isOnline } = this.props - return ( -
- {isOnline && ledgers.length > 0 ? ( - <> - -
{selected && this.renderSelected()}
-
- {ledgers.map(this.renderLedger)}{' '} - -
{' '} - - ) : ( - - )} -
- ) - } -} - -Ledgers.contextType = SocketContext - -Ledgers.propTypes = { - ledgers: PropTypes.arrayOf(PropTypes.shape({})), // eslint-disable-line - validators: PropTypes.shape({}), // eslint-disable-line - unlCount: PropTypes.number, // eslint-disable-line - selected: PropTypes.string, // eslint-disable-line - setSelected: PropTypes.func, - language: PropTypes.string.isRequired, - t: PropTypes.func.isRequired, - paused: PropTypes.bool, - isOnline: PropTypes.bool.isRequired, -} - -Ledgers.defaultProps = { - ledgers: [], - validators: {}, - unlCount: 0, - selected: null, - setSelected: () => {}, - paused: false, -} - -export default withTranslation()(Ledgers) diff --git a/src/containers/Ledgers/Ledgers.tsx b/src/containers/Ledgers/Ledgers.tsx new file mode 100644 index 000000000..153a1e47e --- /dev/null +++ b/src/containers/Ledgers/Ledgers.tsx @@ -0,0 +1,66 @@ +import { Tooltip, useTooltip } from '../shared/components/Tooltip' +import './css/ledgers.scss' +import DomainLink from '../shared/components/DomainLink' +import { Loader } from '../shared/components/Loader' +import { useIsOnline } from '../shared/SocketContext' +import { Legend } from './Legend' +import { RouteLink } from '../shared/routing' +import { VALIDATOR_ROUTE } from '../App/routes' +import { LedgerListEntry } from './LedgerListEntry' +import { useSelectedValidator } from './useSelectedValidator' +import { usePreviousWithPausing } from '../shared/hooks/usePreviousWithPausing' +import { useStreams } from '../shared/components/Streams/StreamsContext' +import { Ledger } from '../shared/components/Streams/types' +import { useVHSValidators } from '../shared/components/VHSValidators/VHSValidatorsContext' + +export const Ledgers = ({ paused }: { paused: boolean }) => { + const { validators: validatorsFromVHS } = useVHSValidators() + const { selectedValidator } = useSelectedValidator() + const { ledgers } = useStreams() + const localLedgers = usePreviousWithPausing>( + ledgers, + paused, + ) + const isOnline = useIsOnline() + const { tooltip } = useTooltip() + + return ( +
+ {isOnline && ledgers ? ( + <> + +
+ {selectedValidator && validatorsFromVHS && ( +
+ {validatorsFromVHS[selectedValidator].domain && ( + + )} + + {selectedValidator} + +
+ )} +
+
+ {localLedgers && + Object.values(localLedgers) + .reverse() + .slice(0, 20) + ?.map((ledger) => ( + + ))}{' '} + +
{' '} + + ) : ( + + )} +
+ ) +} diff --git a/src/containers/Ledgers/css/ledgers.scss b/src/containers/Ledgers/css/ledgers.scss index b1e8f8047..470f2bd08 100644 --- a/src/containers/Ledgers/css/ledgers.scss +++ b/src/containers/Ledgers/css/ledgers.scss @@ -98,11 +98,10 @@ $ledger-width: 196px; } .ledger { - overflow: visible; width: $ledger-width; flex-grow: 0; flex-shrink: 0; - margin-left: $ledgers-margin-large; + margin-right: $ledgers-margin-large; animation-duration: 0.4s; animation-name: ledger-enter; white-space: normal; @@ -114,12 +113,10 @@ $ledger-width: 196px; @keyframes ledger-enter { from { - width: 0; - margin-left: -$ledgers-margin-large; + margin-left: -$ledger-width; } to { - width: $ledger-width; margin-left: 0; } } @@ -128,7 +125,6 @@ $ledger-width: 196px; min-height: 170px; padding: $ledgers-margin-large; border: $ledgers-border; - border-bottom: 0; color: $black-40; font-size: 10px; line-height: 12px; @@ -228,7 +224,7 @@ $ledger-width: 196px; .hash { overflow: hidden; - padding: 0px 32px 32px; + padding: 0 32px 32px; border: 1px solid $black-60; border-top: 0; background: rgba($black-80, 0.7); @@ -236,6 +232,10 @@ $ledger-width: 196px; font-size: 15px; text-align: left; + &:last-child:not(:first-child) { + border-bottom: 0; + } + .bar { height: 2px; margin: 0px -32px; diff --git a/src/containers/Ledgers/index.tsx b/src/containers/Ledgers/index.tsx index f1b432890..7ef7c2699 100644 --- a/src/containers/Ledgers/index.tsx +++ b/src/containers/Ledgers/index.tsx @@ -1,35 +1,18 @@ -import { useContext, useEffect, useState } from 'react' +import { useEffect, useState } from 'react' import { Helmet } from 'react-helmet-async' import { useTranslation } from 'react-i18next' -import { useQuery } from 'react-query' -import axios from 'axios' -import Log from '../shared/log' -import { FETCH_INTERVAL_ERROR_MILLIS } from '../shared/utils' -import Streams from '../shared/components/Streams' -import LedgerMetrics from './LedgerMetrics' -import Ledgers from './Ledgers' -import { Ledger, ValidatorResponse } from './types' +import { LedgerMetrics } from './LedgerMetrics' +import { Ledgers } from './Ledgers' import { useAnalytics } from '../shared/analytics' -import NetworkContext from '../shared/NetworkContext' -import { useIsOnline } from '../shared/SocketContext' -import { useLanguage } from '../shared/hooks' +import { TooltipProvider } from '../shared/components/Tooltip' +import { SelectedValidatorProvider } from './useSelectedValidator' +import { StreamsProvider } from '../shared/components/Streams/StreamsProvider' +import { VHSValidatorsProvider } from '../shared/components/VHSValidators/VHSValidatorsProvider' -const FETCH_INTERVAL_MILLIS = 5 * 60 * 1000 - -const LedgersPage = () => { +export const LedgersPage = () => { const { trackScreenLoaded } = useAnalytics() - const [validators, setValidators] = useState< - Record - >({}) - const [ledgers, setLedgers] = useState([]) const [paused, setPaused] = useState(false) - const [selected, setSelected] = useState(null) - const [metrics, setMetrics] = useState(undefined) - const [unlCount, setUnlCount] = useState(undefined) - const { isOnline } = useIsOnline() const { t } = useTranslation() - const network = useContext(NetworkContext) - const language = useLanguage() useEffect(() => { trackScreenLoaded() @@ -38,73 +21,23 @@ const LedgersPage = () => { } }, [trackScreenLoaded]) - const fetchValidators = () => { - const url = `${process.env.VITE_DATA_URL}/validators/${network}` - - return axios - .get(url) - .then((resp) => resp.data.validators) - .then((validatorResponse) => { - const newValidators: Record = {} - let newUnlCount = 0 - - validatorResponse.forEach((v: ValidatorResponse) => { - if (v.unl === process.env.VITE_VALIDATOR) { - newUnlCount += 1 - } - newValidators[v.signing_key] = v - }) - - setValidators(newValidators) - setUnlCount(newUnlCount) - return true - }) - .catch((e) => Log.error(e)) - } - - useQuery(['fetchValidatorData'], async () => fetchValidators(), { - refetchInterval: (returnedData, _) => - returnedData == null - ? FETCH_INTERVAL_ERROR_MILLIS - : FETCH_INTERVAL_MILLIS, - refetchOnMount: true, - enabled: !!network, - }) - - const updateSelected = (pubkey: string) => { - setSelected(selected === pubkey ? null : pubkey) - } - const pause = () => setPaused(!paused) return (
- {isOnline && ( - - )} - pause()} - paused={paused} - /> - + + + + + pause()} paused={paused} /> + + + + + + +
) } - -export default LedgersPage diff --git a/src/containers/Ledgers/test/LedgersPage.test.js b/src/containers/Ledgers/test/LedgersPage.test.js index 2b5f474f4..78a5a12d5 100644 --- a/src/containers/Ledgers/test/LedgersPage.test.js +++ b/src/containers/Ledgers/test/LedgersPage.test.js @@ -5,7 +5,7 @@ import configureMockStore from 'redux-mock-store' import thunk from 'redux-thunk' import { Provider } from 'react-redux' import i18n from '../../../i18n/testConfig' -import Ledgers from '../index' +import { LedgersPage } from '../index' import { initialState } from '../../../rootReducer' import SocketContext from '../../shared/SocketContext' import NetworkContext from '../../shared/NetworkContext' @@ -15,6 +15,7 @@ import ledgerMessage from './mock/ledger.json' import validationMessage from './mock/validation.json' import rippledResponses from './mock/rippled.json' import { QuickHarness } from '../../test/utils' +import { SelectedValidatorProvider } from '../useSelectedValidator' function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)) @@ -86,13 +87,15 @@ describe('Ledgers Page container', () => { return mount( - - - - - - - + + + + + + + + + , ) } diff --git a/src/containers/Ledgers/useSelectedValidator.tsx b/src/containers/Ledgers/useSelectedValidator.tsx new file mode 100644 index 000000000..da3b31ab3 --- /dev/null +++ b/src/containers/Ledgers/useSelectedValidator.tsx @@ -0,0 +1,42 @@ +import { + createContext, + Dispatch, + FC, + SetStateAction, + useContext, + useMemo, + useState, +} from 'react' + +export interface SelectedValidatorContextType { + selectedValidator?: string + setSelectedValidator: Dispatch> +} + +export const SelectedValidatorContext = + createContext({ + selectedValidator: undefined, + setSelectedValidator: (validator: SetStateAction) => + validator, + }) + +export const SelectedValidatorProvider: FC = ({ children }) => { + const [selectedValidator, setSelectedValidator] = useState() + + const selectedValidatorValues = useMemo( + () => ({ + selectedValidator, + setSelectedValidator, + }), + [selectedValidator], + ) + + return ( + + {children} + + ) +} + +export const useSelectedValidator = (): SelectedValidatorContextType => + useContext(SelectedValidatorContext) diff --git a/src/containers/NFT/NFTHeader/NFTHeader.tsx b/src/containers/NFT/NFTHeader/NFTHeader.tsx index 5d59d5549..1a46f7ac6 100644 --- a/src/containers/NFT/NFTHeader/NFTHeader.tsx +++ b/src/containers/NFT/NFTHeader/NFTHeader.tsx @@ -4,7 +4,7 @@ import { useQuery } from 'react-query' import { Loader } from '../../shared/components/Loader' import './styles.scss' import SocketContext from '../../shared/SocketContext' -import Tooltip from '../../shared/components/Tooltip' +import { Tooltip, TooltipInstance } from '../../shared/components/Tooltip' import { getNFTInfo, getAccountInfo } from '../../../rippled/lib/rippled' import { formatNFTInfo, formatAccountInfo } from '../../../rippled/lib/utils' import { localizeDate, BAD_REQUEST, HASH_REGEX } from '../../shared/utils' @@ -39,7 +39,7 @@ export const NFTHeader = (props: Props) => { const { tokenId, setError } = props const rippledSocket = useContext(SocketContext) const { trackException } = useAnalytics() - const [tooltip, setTooltip] = useState(null) + const [tooltip, setTooltip] = useState(undefined) const { data, isFetching: loading } = useQuery( ['getNFTInfo', tokenId], @@ -90,11 +90,16 @@ export const NFTHeader = (props: Props) => { : undefined const showTooltip = (event: any, d: any) => { - setTooltip({ ...d, mode: 'nftId', x: event.pageX, y: event.pageY }) + setTooltip({ + data: d, + mode: 'nftId', + x: event.currentTarget.offsetLeft, + y: event.currentTarget.offsetTop, + }) } const hideTooltip = () => { - setTooltip(null) + setTooltip(undefined) } const renderHeaderContent = () => { @@ -154,7 +159,7 @@ export const NFTHeader = (props: Props) => {
{loading ? : renderHeaderContent()}
- +
) } diff --git a/src/containers/Network/Hexagons.jsx b/src/containers/Network/Hexagons.jsx index a55c0f5de..68c5b2c14 100644 --- a/src/containers/Network/Hexagons.jsx +++ b/src/containers/Network/Hexagons.jsx @@ -4,9 +4,8 @@ import { useWindowSize } from 'usehooks-ts' import { hexbin } from 'd3-hexbin' import { Loader } from '../shared/components/Loader' -import Tooltip from '../shared/components/Tooltip' +import { Tooltip, useTooltip } from '../shared/components/Tooltip' import './css/hexagons.scss' -import { useLanguage } from '../shared/hooks' const MAX_WIDTH = 1200 const getDimensions = (width) => ({ @@ -50,11 +49,10 @@ const prepareHexagons = (data, list, height, radius, prev = []) => { } export const Hexagons = ({ list, data }) => { - const language = useLanguage() const { width } = useWindowSize() - const [tooltip, setToolip] = useState() const [hexagons, setHexagons] = useState([]) const { width: gridWidth, height: gridHeight, radius } = getDimensions(width) + const { tooltip, showTooltip, hideTooltip } = useTooltip() const bin = hexbin() .extent([ [0, 0], @@ -65,25 +63,17 @@ export const Hexagons = ({ list, data }) => { useEffect(() => { if (width > 0) { setHexagons((prevHexagons) => - prepareHexagons(data, list, gridHeight, radius, prevHexagons), + prepareHexagons( + Object.values(data), + list, + gridHeight, + radius, + prevHexagons, + ), ) } }, [data, list, width, gridHeight, radius]) - const showTooltip = (event, tooltipData) => { - setToolip({ - ...tooltipData, - mode: 'validator', - v: list[tooltipData.pubkey], - x: event.nativeEvent.offsetX, - y: event.nativeEvent.offsetY, - }) - } - - const hideTooltip = () => { - setToolip(null) - } - const renderHexagon = (d, theHex) => { const { cookie, pubkey, ledger_hash: ledgerHash } = d const fill = `#${ledgerHash.substr(0, 6)}` @@ -93,7 +83,9 @@ export const Hexagons = ({ list, data }) => { key={`${pubkey}${cookie}${ledgerHash}`} transform={`translate(${d.x},${d.y})`} className="hexagon updated" - onMouseOver={(e) => showTooltip(e, d)} + onMouseOver={(e) => + showTooltip('validator', e, { ...d, v: list[d.pubkey] }) + } onFocus={() => {}} onMouseLeave={hideTooltip} > @@ -126,12 +118,7 @@ export const Hexagons = ({ list, data }) => { {hexagons?.length === 0 && }
- +
) } - -Hexagons.propTypes = { - list: PropTypes.shape({}).isRequired, - data: PropTypes.arrayOf(PropTypes.shape({})).isRequired, // eslint-disable-line -} diff --git a/src/containers/Network/Validators.tsx b/src/containers/Network/Validators.tsx index 8d5c1fcd4..da9f62ebe 100644 --- a/src/containers/Network/Validators.tsx +++ b/src/containers/Network/Validators.tsx @@ -1,124 +1,92 @@ -import { useContext, useState } from 'react' -import axios from 'axios' +import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { useQuery } from 'react-query' import NetworkTabs from './NetworkTabs' -import Streams from '../shared/components/Streams' import ValidatorsTable from './ValidatorsTable' -import Log from '../shared/log' -import { - localizeNumber, - FETCH_INTERVAL_MILLIS, - FETCH_INTERVAL_ERROR_MILLIS, -} from '../shared/utils' +import { localizeNumber } from '../shared/utils' import { useLanguage } from '../shared/hooks' import { Hexagons } from './Hexagons' -import { StreamValidator, ValidatorResponse } from '../shared/vhsTypes' -import NetworkContext from '../shared/NetworkContext' +import { StreamValidator } from '../shared/vhsTypes' +import { TooltipProvider } from '../shared/components/Tooltip' +import { useStreams } from '../shared/components/Streams/StreamsContext' +import { useVHSValidators } from '../shared/components/VHSValidators/VHSValidatorsContext' export const Validators = () => { const language = useLanguage() const { t } = useTranslation() - const [vList, setVList] = useState>({}) - const [validations, setValidations] = useState([]) - const [metrics, setMetrics] = useState({}) - const [unlCount, setUnlCount] = useState(0) - const network = useContext(NetworkContext) + const { validators: validatorsFromValidations, metrics } = useStreams() + const { validators: validatorsFromVHS, unl } = useVHSValidators() - useQuery(['fetchValidatorsData'], () => fetchData(), { - refetchInterval: (returnedData, _) => - returnedData == null - ? FETCH_INTERVAL_ERROR_MILLIS - : FETCH_INTERVAL_MILLIS, - refetchOnMount: true, - enabled: process.env.VITE_ENVIRONMENT !== 'custom' || !!network, - }) - - function mergeLatest( - validators: Record, - live: Record, - ): Record { + const merged = useMemo(() => { + if ( + !validatorsFromVHS || + !( + validatorsFromValidations && + Object.keys(validatorsFromValidations).length + ) + ) { + return + } const updated: Record = {} - const keys = new Set(Object.keys(validators).concat(Object.keys(live))) + const keys = new Set( + Object.keys(validatorsFromVHS).concat( + Object.keys(validatorsFromValidations), + ), + ) keys.forEach((d: string) => { - const newData: StreamValidator = validators[d] || live[d] - if (newData.ledger_index == null && live[d] && live[d].ledger_index) { + const newData: StreamValidator = + validatorsFromVHS[d] || validatorsFromValidations[d] + if ( + newData.ledger_index == null && + validatorsFromValidations[d] && + validatorsFromValidations[d].ledger_index + ) { // VHS uses `current_index` instead of `ledger_index` // If `ledger_index` isn't defined, then we're still using the VHS data, // instead of the Streams data - newData.ledger_index = live[d].ledger_index - newData.ledger_hash = live[d].ledger_hash + newData.ledger_index = validatorsFromValidations[d].ledger_index + } + if (newData.current_index == null) { + newData.signing_key = validatorsFromValidations[d].pubkey + } + // latest hash and time comes from the validations stream + if (validatorsFromValidations[d]) { + newData.time = validatorsFromValidations[d].time + newData.ledger_hash = validatorsFromValidations[d].ledger_hash } + updated[d] = newData }) - return updated - } - - function fetchData() { - const url = `${process.env.VITE_DATA_URL}/validators/${network}` + return Object.values(updated) + }, [validatorsFromVHS, validatorsFromValidations]) - return axios - .get(url) - .then((resp) => resp.data.validators) - .then((validators) => { - const newValidatorList: Record = {} - validators.forEach((v: ValidatorResponse) => { - newValidatorList[v.signing_key] = v - }) - - setVList(() => mergeLatest(newValidatorList, vList)) - setUnlCount(validators.filter((d: any) => Boolean(d.unl)).length) - return true // indicating success in getting the data - }) - .catch((e) => Log.error(e)) - } - - const updateValidators = (newValidations: StreamValidator[]) => { - // @ts-ignore - Work around type assignment for complex validation data types - setValidations(newValidations) - setVList((value) => { - const newValidatorsList: Record = { ...value } - newValidations.forEach((validation: any) => { - newValidatorsList[validation.pubkey] = { - ...value[validation.pubkey], - signing_key: validation.pubkey, - ledger_index: validation.ledger_index, - ledger_hash: validation.ledger_hash, - } - }) - return mergeLatest(newValidatorsList, value) - }) - } + const validatorCount = useMemo( + () => merged && Object.keys(merged).length, + [merged], + ) - const validatorCount = Object.keys(vList).length return (
- {network && ( - - )} { // @ts-ignore - Work around for complex type assignment issues - + + + }
{t('validators_found')}: {localizeNumber(validatorCount, language)} - {unlCount !== 0 && ( + {unl?.length !== 0 && ( {' '} - ({t('unl')}: {unlCount}) + ({t('unl')}: {unl?.length}) )}
- +
) diff --git a/src/containers/Network/index.tsx b/src/containers/Network/index.tsx index 70888bc9d..dace43f78 100644 --- a/src/containers/Network/index.tsx +++ b/src/containers/Network/index.tsx @@ -10,6 +10,24 @@ import { UpgradeStatus } from './UpgradeStatus' import { Nodes } from './Nodes' import NoMatch from '../NoMatch' import './css/style.scss' +import { StreamsProvider } from '../shared/components/Streams/StreamsProvider' +import { VHSValidatorsProvider } from '../shared/components/VHSValidators/VHSValidatorsProvider' + +export const ValidatorsPage = () => ( + + + + + +) + +export const UpgradeStatusPage = () => ( + + + + + +) export const Network = () => { const { trackScreenLoaded } = useAnalytics() @@ -32,8 +50,8 @@ export const Network = () => { } const Body = { - 'upgrade-status': UpgradeStatus, - validators: Validators, + 'upgrade-status': UpgradeStatusPage, + validators: ValidatorsPage, nodes: Nodes, }[tab] return ( diff --git a/src/containers/PayStrings/PayStringHeader/index.tsx b/src/containers/PayStrings/PayStringHeader/index.tsx index 9c85c55ed..b5bc4ea38 100644 --- a/src/containers/PayStrings/PayStringHeader/index.tsx +++ b/src/containers/PayStrings/PayStringHeader/index.tsx @@ -1,10 +1,8 @@ import { useState, useRef } from 'react' -import { useTranslation } from 'react-i18next' import PayStringLogomark from '../../shared/images/PayString_Logomark.png' import QuestIcon from '../../shared/images/hover_question.svg' -import Tooltip from '../../shared/components/Tooltip' +import { Tooltip } from '../../shared/components/Tooltip/Tooltip' import './styles.scss' -import { useLanguage } from '../../shared/hooks' export interface PayStringHeaderProps { accountId: string @@ -12,8 +10,6 @@ export interface PayStringHeaderProps { export const PayStringHeader = ({ accountId }: PayStringHeaderProps) => { const [showToolTip, setShowToolTip] = useState(false) - const { t } = useTranslation() - const language = useLanguage() const questionRef = useRef(null) return (
@@ -36,9 +32,7 @@ export const PayStringHeader = ({ accountId }: PayStringHeaderProps) => {
{showToolTip && questionRef.current && ( ( + { hook, provider }: NameProps, + initialState?: T, +) => { + const Context = createContext(initialState) + + const useContextFactory = (): T => { + const context = useContext(Context) + + if (context === undefined) { + throw new Error(`${hook} must be used in a child of ${provider}`) + } + return context + } + + return [Context, useContextFactory] as const +} diff --git a/src/containers/shared/components/Streams/StreamsContext.tsx b/src/containers/shared/components/Streams/StreamsContext.tsx new file mode 100644 index 000000000..c83ecfb0d --- /dev/null +++ b/src/containers/shared/components/Streams/StreamsContext.tsx @@ -0,0 +1,13 @@ +import { contextFactory } from '../../../helpers/contextFactory' +import { Ledger, Metrics } from './types' + +const [StreamsContext, useStreams] = contextFactory<{ + metrics: Metrics + ledgers: Record + validators: Record +}>({ + hook: 'useStreams', + provider: 'StreamsProvider', +}) + +export { StreamsContext, useStreams } diff --git a/src/containers/shared/components/Streams/StreamsProvider.tsx b/src/containers/shared/components/Streams/StreamsProvider.tsx new file mode 100644 index 000000000..325c09ca5 --- /dev/null +++ b/src/containers/shared/components/Streams/StreamsProvider.tsx @@ -0,0 +1,322 @@ +import { FC, useContext, useEffect, useRef, useState } from 'react' +import axios from 'axios' +import type { LedgerStream, ValidationStream } from 'xrpl' +import SocketContext from '../../SocketContext' +import { getLedger } from '../../../../rippled/lib/rippled' +import { convertRippleDate } from '../../../../rippled/lib/convertRippleDate' +import { summarizeLedger } from '../../../../rippled/lib/summarizeLedger' +import Log from '../../log' +import { getNegativeUNL, getQuorum } from '../../../../rippled' +import { XRP_BASE } from '../../transactionUtils' +import { StreamsContext } from './StreamsContext' +import { Ledger } from './types' + +const THROTTLE = 200 + +// TODO: use useQuery +const fetchMetrics = () => + axios + .get('/api/v1/metrics') + .then((result) => result.data) + .catch((e) => Log.error(e)) + +// TODO: use useQuery +const fetchNegativeUNL = async (rippledSocket) => + getNegativeUNL(rippledSocket) + .then((data) => { + if (data === undefined) throw new Error('undefined nUNL') + + return data + }) + .catch((e) => { + Log.error(e) + return [] + }) + +// TODO: use useQuery +const fetchQuorum = async (rippledSocket) => + getQuorum(rippledSocket) + .then((data) => { + if (data === undefined) throw new Error('undefined quorum') + return data + }) + .catch((e) => Log.error(e)) + +export const StreamsProvider: FC = ({ children }) => { + const [ledgers, setLedgers] = useState>([]) + const ledgersRef = useRef>(ledgers) + const firstLedgerRef = useRef(0) + const [validators, setValidators] = useState>({}) + const validationQueue = useRef([]) + const socket = useContext(SocketContext) + + // metrics + const [loadFee, setLoadFee] = useState('--') + const [txnSec, setTxnSec] = useState('--') + const [txnLedger, setTxnLedger] = useState('--') + const [ledgerInterval, setLedgerInterval] = useState('--') + const [avgFee, setAvgFee] = useState('--') + const [quorum, setQuorum] = useState('--') + const [nUnl, setNUnl] = useState([]) + + function addLedger(index: number | string) { + // Only add new ledgers that are newer than the last one added. + if (!firstLedgerRef.current) { + firstLedgerRef.current = Number(index) + } + if (firstLedgerRef.current > Number(index)) { + return + } + + // TODO: only keep 20 + if (!(index in ledgers)) { + setLedgers((previousLedgers) => ({ + [index]: { + index: Number(index), + seen: Date.now(), + hashes: [], + transactions: undefined, + txCount: undefined, + }, + ...previousLedgers, + })) + } + } + + // TODO: use useQuery + function updateMetricsFromServer() { + fetchMetrics().then((serverMetrics) => { + setTxnSec(serverMetrics.txn_sec) + setTxnLedger(serverMetrics.txn_ledger) + setAvgFee(serverMetrics.avg_fee) + setLedgerInterval(serverMetrics.ledger_interval) + }) + } + + function updateMetrics() { + const ledgerChain = Object.values(ledgers) + .sort((a, b) => a.index - b.index) + .slice(-100) + + let time = 0 + let fees = 0 + let timeCount = 0 + let txCount = 0 + let txWithFeesCount = 0 + let ledgerCount = 0 + + ledgerChain.forEach((d, i) => { + const next = ledgerChain[i + 1] + if (next && next.seen && d.seen) { + time += next.seen - d.seen + timeCount += 1 + } + + if (d.totalFees) { + fees += d.totalFees + txWithFeesCount += d.txCount ?? 0 + } + if (d.txCount) { + txCount += d.txCount + } + ledgerCount += 1 + }) + + setTxnSec(time ? ((txCount / time) * 1000).toFixed(2) : '--') + setTxnLedger(ledgerCount ? (txCount / ledgerCount).toFixed(2) : '--') + setLedgerInterval(timeCount ? (time / timeCount / 1000).toFixed(3) : '--') + setAvgFee(txWithFeesCount ? (fees / txWithFeesCount).toPrecision(4) : '--') + } + + function updateQuorum() { + fetchQuorum(socket).then((newQuorum) => { + setQuorum(newQuorum) + }) + } + + function updateNegativeUNL() { + fetchNegativeUNL(socket).then((newNUnl) => { + setNUnl(newNUnl) + }) + } + + function onLedger(data: LedgerStream) { + if (!ledgersRef.current[data.ledger_index]) { + // The ledger closed, but we did not have an existing entry likely because the page just loaded and its + // validations came in before we connected to the websocket. + addLedger(data.ledger_index) + } + + if (process.env.VITE_ENVIRONMENT !== 'custom') { + // In custom mode we populate metrics from ledgers loaded into memory + updateMetricsFromServer() + } else { + // Make call to metrics tracked on the backend + updateMetrics() + } + + setLoadFee((data.fee_base / XRP_BASE).toString()) + + // After each flag ledger we should check the UNL and quorum which are correlated and can only update every flag ledger. + if (data.ledger_index % 256 === 0 || quorum === '--') { + updateNegativeUNL() + updateQuorum() + } + + // TODO: Set fields before getting full ledger info + // set validated hash + // set closetime + + getLedger(socket, { ledger_hash: data.ledger_hash }).then( + populateFromLedgerResponse, + ) + } + + const populateFromLedgerResponse = (ledger: Promise) => { + const ledgerSummary = summarizeLedger(ledger) + setLedgers((previousLedgers) => { + const ledger = Object.assign( + previousLedgers[ledgerSummary.ledger_index] ?? {}, + { + txCount: ledgerSummary.transactions.length, + closeTime: convertRippleDate(ledgerSummary.ledger_time), + transactions: ledgerSummary.transactions, + totalFees: ledgerSummary.total_fees, // fix type + }, + ) + const matchingHashIndex = ledger?.hashes.findIndex( + (hash) => hash.hash === ledgerSummary.ledger_hash, + ) + const matchingHash = ledger?.hashes[matchingHashIndex] + if (matchingHash) { + matchingHash.validated = true + } + if (ledger && matchingHash) { + ledger.hashes[matchingHashIndex] = { + ...matchingHash, + } + } + + // eslint-disable-next-line no-param-reassign + previousLedgers[ledgerSummary.ledger_index] = ledger + + return { ...previousLedgers } + }) + } + + const onValidation = (data: ValidationStream) => { + if (!ledgersRef.current[Number(data.ledger_index)]) { + addLedger(data.ledger_index) + } + if (firstLedgerRef.current <= Number(data.ledger_index)) { + validationQueue.current.push(data) + } + } + + // Process validations in chunks to make re-renders more manageable. + function processValidationQueue() { + setTimeout(processValidationQueue, THROTTLE) + + if (validationQueue.current.length < 1) { + return + } + // copy the queue and clear it, so we aren't adding more while processing + const queue = [...validationQueue.current] + validationQueue.current = [] + setLedgers((previousLedgers) => { + queue.forEach((validation) => { + const ledger = previousLedgers[Number(validation.ledger_index)] + const matchingHashIndex = ledger?.hashes.findIndex( + (hash) => hash.hash === validation.ledger_hash, + ) + let matchingHash = ledger?.hashes[matchingHashIndex] + if (!matchingHash) { + matchingHash = { + hash: validation.ledger_hash, + validated: false, + validations: [], + time: convertRippleDate(validation.signing_time), + cookie: validation.cookie, + } + ledger.hashes.push(matchingHash) + } + matchingHash.validations = [...matchingHash.validations, validation] + if (ledger) { + ledger.hashes = [...(ledger?.hashes || [])] + ledger.hashes[matchingHashIndex] = { + ...matchingHash, + } + } + }) + return { ...previousLedgers } + }) + setValidators((previousValidators) => { + const newValidators: any = { ...previousValidators } + queue.forEach((validation) => { + newValidators[validation.validation_public_key] = { + ...previousValidators[validation.validation_public_key], + cookie: validation.cookie, + ledger_index: Number(validation.ledger_index), + ledger_hash: validation.ledger_hash, + pubkey: validation.validation_public_key, + partial: !validation.full, + time: convertRippleDate(validation.signing_time), + last: Date.now(), + } + }) + return newValidators + }) + } + + useEffect(() => { + const interval = setTimeout(processValidationQueue, THROTTLE) + + if (socket) { + socket.send({ + command: 'subscribe', + streams: ['ledger', 'validations'], + }) + socket.on('ledger', onLedger as any) + socket.on('validation', onValidation as any) + + // Load in the most recent validated ledger to prevent the page from being empty until the next validations come in. + getLedger(socket, { ledger_index: 'validated' }).then( + populateFromLedgerResponse, + ) + } + + return () => { + clearTimeout(interval) + if (socket) { + socket.send({ + command: 'unsubscribe', + streams: ['ledger', 'validations'], + }) + socket.off('ledger', onLedger) + socket.off('validation', onValidation) + } + } + }, [socket]) + + useEffect(() => { + ledgersRef.current = ledgers + }, [ledgers]) + + const value = { + ledgers, + validators, + metrics: { + load_fee: loadFee, + txn_sec: txnSec, + txn_ledger: txnLedger, + ledger_interval: ledgerInterval, + avg_fee: avgFee, + quorum, + nUnl, + }, + } + + return ( + {children} + ) +} diff --git a/src/containers/shared/components/Streams/types.ts b/src/containers/shared/components/Streams/types.ts new file mode 100644 index 000000000..f71634d41 --- /dev/null +++ b/src/containers/shared/components/Streams/types.ts @@ -0,0 +1,30 @@ +import { ValidationStream } from 'xrpl' + +export interface LedgerHash { + hash: string + validated: boolean + validations: ValidationStream[] + time: number + cookie?: string +} + +export interface Ledger { + transactions: any[] + index: number + hashes: LedgerHash[] + seen: number + txCount?: number + closeTime: number + totalFees: number +} + +export interface Metrics { + load_fee: string + txn_sec: string + txn_ledger: string + ledger_interval: string + avg_fee: string + quorum: string + nUnl: string[] + base_fee?: string +} diff --git a/src/containers/shared/components/Tooltip.jsx b/src/containers/shared/components/Tooltip.jsx deleted file mode 100644 index 4d65d2209..000000000 --- a/src/containers/shared/components/Tooltip.jsx +++ /dev/null @@ -1,151 +0,0 @@ -import { Component } from 'react' -import PropTypes from 'prop-types' -import successIcon from '../images/success.png' -import { localizeDate } from '../utils' -import '../css/tooltip.scss' -import PayStringToolTip from '../images/paystring_tooltip.svg' -import { TxStatus } from './TxStatus' -import { TxLabel } from './TxLabel' - -const PADDING_Y = 20 -const DATE_OPTIONS = { - hour: 'numeric', - minute: 'numeric', - second: 'numeric', - hour12: true, -} - -class Tooltip extends Component { - constructor(props) { - super(props) - this.state = {} - } - - static getDerivedStateFromProps(nextProps) { - return nextProps.data || { mode: null } - } - - renderNegativeUnlTooltip() { - const { nUnl } = this.state - const list = nUnl.map((key) => { - const short = key.substr(0, 8) - return
{`${short}...`}
- }) - - return list - } - - renderValidatorTooltip() { - const { language } = this.props - const { v = {}, pubkey, time } = this.state - const key = v.master_key || pubkey - - return ( - <> -
{v.domain}
-
{key}
-
{localizeDate(time, language, DATE_OPTIONS)}
- {v.unl && ( -
- {v.unl} - {v.unl} -
- )} - - ) - } - - renderTxTooltip() { - const { type, result, account } = this.state - return ( - <> -
- -
-
{account}
- - ) - } - - renderMissingValidators() { - const { missing } = this.state - const { t } = this.props - const list = missing.map((d) => ( -
- {d.domain || d.master_key} -
- )) - - return ( - <> -
{t('missing')}:
- {list} - - ) - } - - renderNFTId() { - const { tokenId } = this.state - return
{tokenId}
- } - - renderPayStringToolTip() { - const { t } = this.props - - return ( - <> - - {t('paystring_explainer_blurb')} - - ) - } - - render() { - const { mode, x, y } = this.state - const style = { top: y + PADDING_Y, left: x } - let content = null - let className = 'tooltip' - if (mode === 'validator') { - content = this.renderValidatorTooltip() - } else if (mode === 'tx') { - content = this.renderTxTooltip() - } else if (mode === 'nUnl') { - content = this.renderNegativeUnlTooltip() - } else if (mode === 'missing') { - style.background = 'rgba(120,0,0,.9)' - content = this.renderMissingValidators() - } else if (mode === 'paystring') { - className += ' paystring' - content = this.renderPayStringToolTip() - } else if (mode === 'nftId') { - content = this.renderNFTId() - } - - return content ? ( -
this.setState({ mode: null })} - onKeyUp={() => this.setState({ mode: null })} - > - {content} -
- ) : null - } -} - -Tooltip.propTypes = { - t: PropTypes.func, - language: PropTypes.string, - data: PropTypes.shape({}), -} - -Tooltip.defaultProps = { - t: (d) => d, - language: undefined, - data: null, -} - -export default Tooltip diff --git a/src/containers/shared/components/Tooltip/Tooltip.tsx b/src/containers/shared/components/Tooltip/Tooltip.tsx new file mode 100644 index 000000000..340cda0db --- /dev/null +++ b/src/containers/shared/components/Tooltip/Tooltip.tsx @@ -0,0 +1,123 @@ +import { CSSProperties } from 'react' +import { useTranslation } from 'react-i18next' +import successIcon from '../../images/success.png' +import { localizeDate } from '../../utils' +import '../../css/tooltip.scss' +import PayStringToolTip from '../../images/paystring_tooltip.svg' +import { TxStatus } from '../TxStatus' +import { TxLabel } from '../TxLabel' +import { useLanguage } from '../../hooks' +import { convertRippleDate } from '../../../../rippled/lib/convertRippleDate' + +const PADDING_Y = 20 +const DATE_OPTIONS = { + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + hour12: true, +} + +export interface TooltipInstance { + data?: any + mode: string + x: number + y: number +} + +export const Tooltip = ({ tooltip }: { tooltip?: TooltipInstance }) => { + const { t } = useTranslation() + const language = useLanguage() + + if (!tooltip) { + // eslint-disable-next-line react/jsx-no-useless-fragment + return <> + } + + const { data } = tooltip + + const renderNegativeUnlTooltip = () => + data.nUnl.map((key) => { + const short = key.substr(0, 8) + return
{`${short}...`}
+ }) + + const renderValidatorTooltip = () => { + // eslint-disable-next-line camelcase + const { v = {}, pubkey, signing_time } = data + const key = v.master_key || pubkey + + return ( + <> +
{v.domain}
+
{key}
+
+ {localizeDate( + convertRippleDate(signing_time), + language, + DATE_OPTIONS, + )} +
+ {v.unl && ( +
+ {v.unl} + {v.unl} +
+ )} + + ) + } + + const renderTxTooltip = () => { + const { type, result, account } = data + return ( + <> +
+ +
+
{account}
+ + ) + } + + const renderMissingValidators = () => ( + <> +
{t('missing')}:
+ {data.missing.map((d) => ( +
+ {d.domain || d.master_key} +
+ ))} + + ) + + const renderNFTId = () =>
{data.tokenId}
+ + const renderPayStringToolTip = () => ( + <> + + {t('paystring_explainer_blurb')} + + ) + + const { x, y, mode } = tooltip + const style: CSSProperties = { top: y + PADDING_Y, left: x } + const modeMap = { + validator: renderValidatorTooltip, + tx: renderTxTooltip, + nUnl: renderNegativeUnlTooltip, + missing: renderMissingValidators, + paystring: renderPayStringToolTip, + nftId: renderNFTId, + } + + return modeMap[mode] ? ( +
+ {modeMap[mode]()} +
+ ) : null +} diff --git a/src/containers/shared/components/Tooltip/index.ts b/src/containers/shared/components/Tooltip/index.ts new file mode 100644 index 000000000..a7dae4c91 --- /dev/null +++ b/src/containers/shared/components/Tooltip/index.ts @@ -0,0 +1,2 @@ +export * from './Tooltip' +export * from './useTooltip' diff --git a/src/containers/shared/components/Tooltip/useTooltip.tsx b/src/containers/shared/components/Tooltip/useTooltip.tsx new file mode 100644 index 000000000..a386564e2 --- /dev/null +++ b/src/containers/shared/components/Tooltip/useTooltip.tsx @@ -0,0 +1,69 @@ +import { + createContext, + Dispatch, + FC, + MouseEvent, + SetStateAction, + useContext, + useMemo, + useState, +} from 'react' + +export interface TooltipContextType { + tooltip?: any + setTooltip: Dispatch> + hideTooltip: () => void + showTooltip: ( + mode: string, + event: MouseEvent | MouseEvent, + data: any, + ) => void +} + +export const TooltipContext = createContext({ + tooltip: undefined, + setTooltip: (tt: SetStateAction) => tt, + hideTooltip: () => {}, + showTooltip: () => {}, +}) + +export const TooltipProvider: FC = ({ children }) => { + const [tooltip, setTooltip] = useState() + const hideTooltip = () => setTooltip(undefined) + const showTooltip = ( + mode: string, + event: MouseEvent, + data: any, + ) => { + setTooltip({ + data, + mode, + x: + event.currentTarget instanceof HTMLElement + ? event.currentTarget.offsetLeft + : event.nativeEvent.offsetX, + y: + event.currentTarget instanceof HTMLElement + ? event.currentTarget.offsetTop + : event.nativeEvent.offsetY, + }) + } + + const tooltipValues = useMemo( + () => ({ + tooltip, + setTooltip, + hideTooltip, + showTooltip, + }), + [tooltip], + ) + + return ( + + {children} + + ) +} + +export const useTooltip = (): TooltipContextType => useContext(TooltipContext) diff --git a/src/containers/shared/components/VHSValidators/VHSValidatorsContext.tsx b/src/containers/shared/components/VHSValidators/VHSValidatorsContext.tsx new file mode 100644 index 000000000..28621beb3 --- /dev/null +++ b/src/containers/shared/components/VHSValidators/VHSValidatorsContext.tsx @@ -0,0 +1,10 @@ +import { contextFactory } from '../../../helpers/contextFactory' +import { VHSValidatorsHookResult } from './types' + +const [VHSValidatorsContext, useVHSValidators] = + contextFactory({ + hook: 'useVHSValidators', + provider: 'VHSValidatorsProvider', + }) + +export { VHSValidatorsContext, useVHSValidators } diff --git a/src/containers/shared/components/VHSValidators/VHSValidatorsProvider.tsx b/src/containers/shared/components/VHSValidators/VHSValidatorsProvider.tsx new file mode 100644 index 000000000..1c9c00d03 --- /dev/null +++ b/src/containers/shared/components/VHSValidators/VHSValidatorsProvider.tsx @@ -0,0 +1,62 @@ +import { FC, useContext } from 'react' +import { useQuery } from 'react-query' +import axios from 'axios' +import { VHSValidatorsContext } from './VHSValidatorsContext' +import { FETCH_INTERVAL_ERROR_MILLIS, FETCH_INTERVAL_MILLIS } from '../../utils' +import { ValidatorResponse } from '../../vhsTypes' +import Log from '../../log' +import NetworkContext from '../../NetworkContext' +import { VHSValidatorsHookResult } from './types' + +export const VHSValidatorsProvider: FC = ({ children }) => { + const network = useContext(NetworkContext) + + const { data: value } = useQuery( + ['fetchValidatorsData'], + () => fetchVHSData(), + { + refetchInterval: 0, + refetchOnMount: true, + enabled: process.env.VITE_ENVIRONMENT !== 'custom' || !!network, + initialData: { + unl: undefined, + validators: undefined, + }, + }, + ) + + function fetchVHSData(): Promise { + const url = `${process.env.VITE_DATA_URL}/validators/${network}` + + return axios + .get(url) + .then((resp) => resp.data.validators) + .then((validators) => { + const newValidatorList: Record = {} + validators.forEach((v: ValidatorResponse) => { + newValidatorList[v.signing_key] = v + }) + + return { + validators: newValidatorList, + unl: validators + .filter((d: ValidatorResponse) => Boolean(d.unl)) + .map((d: ValidatorResponse) => d.signing_key), + } + }) + .catch((e) => { + Log.error(e) + + return { + unl: undefined, + validators: undefined, + } + }) + } + + return ( + + {children} + + ) +} diff --git a/src/containers/shared/components/VHSValidators/types.ts b/src/containers/shared/components/VHSValidators/types.ts new file mode 100644 index 000000000..d6f231144 --- /dev/null +++ b/src/containers/shared/components/VHSValidators/types.ts @@ -0,0 +1,6 @@ +import { ValidatorResponse } from '../../vhsTypes' + +export interface VHSValidatorsHookResult { + validators?: Record + unl?: string[] +} diff --git a/src/containers/shared/css/tooltip.scss b/src/containers/shared/css/tooltip.scss index 032bea9e8..a1c6d5e4b 100644 --- a/src/containers/shared/css/tooltip.scss +++ b/src/containers/shared/css/tooltip.scss @@ -13,7 +13,7 @@ img { height: 16px; - margin: 0px 5px; + margin: 0 5px; vertical-align: middle; } @@ -26,7 +26,7 @@ max-width: 100px; color: $black-10; font-size: 10px; - letter-spacing: 0px; + letter-spacing: 0; text-overflow: ellipsis; white-space: nowrap; } @@ -49,7 +49,7 @@ font-size: 10px; } - &.paystring { + &.tooltip-paystring { width: 274px; font-size: 12px; @@ -58,4 +58,8 @@ margin-bottom: 8px; } } + + &.tooltip-missing { + background: rgb(120 0 0 / 90%); + } } diff --git a/src/containers/shared/hooks/usePreviousWithPausing.tsx b/src/containers/shared/hooks/usePreviousWithPausing.tsx new file mode 100644 index 000000000..b912a2775 --- /dev/null +++ b/src/containers/shared/hooks/usePreviousWithPausing.tsx @@ -0,0 +1,21 @@ +import { useLayoutEffect, useState } from 'react' + +/** + * A hook that prevents a value from being updated until it is unpaused. + * @param value - The value that is applied when paused is false + * @param paused - Should the value be updated + */ +export function usePreviousWithPausing( + value: T, + paused: boolean, +): T | undefined { + const [val, setVal] = useState() + + useLayoutEffect(() => { + if (!paused) { + setVal(value) + } + }, [paused, value]) // this code will run when the value of 'value' changes + + return val // in the end, return the current ref value. +}