diff --git a/src/containers/App/index.tsx b/src/containers/App/index.tsx index a43002644..748b2e23c 100644 --- a/src/containers/App/index.tsx +++ b/src/containers/App/index.tsx @@ -136,7 +136,6 @@ export const AppWrapper = () => { ))} } /> - \{' '} diff --git a/src/containers/Ledgers/LedgerCountdownBanner.tsx b/src/containers/Ledgers/LedgerCountdownBanner.tsx deleted file mode 100644 index 7418406ef..000000000 --- a/src/containers/Ledgers/LedgerCountdownBanner.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { useEffect, useState } from 'react' -import './css/ledgerCountdownBanner.scss' - -const TARGET_LEDGER = 100_000_000 -const SECONDS_PER_LEDGER = 3.9 - -interface CountdownTime { - ledgersRemaining: number - days: number - hours: number - minutes: number - seconds: number -} - -const calculateCountdown = (currentLedger: number): CountdownTime => { - const ledgersRemaining = Math.max(0, TARGET_LEDGER - currentLedger) - const totalSeconds = ledgersRemaining * SECONDS_PER_LEDGER - - const days = Math.floor(totalSeconds / (24 * 60 * 60)) - const hours = Math.floor((totalSeconds % (24 * 60 * 60)) / (60 * 60)) - const minutes = Math.floor((totalSeconds % (60 * 60)) / 60) - const seconds = Math.floor(totalSeconds % 60) - - return { - ledgersRemaining, - days, - hours, - minutes, - seconds, - } -} - -interface LedgerCountdownBannerProps { - currentLedger?: number -} - -export const LedgerCountdownBanner = ({ - currentLedger, -}: LedgerCountdownBannerProps) => { - const [countdown, setCountdown] = useState(null) - const [isReached, setIsReached] = useState(false) - - useEffect(() => { - if (currentLedger === undefined) return - - const newCountdown = calculateCountdown(currentLedger) - setCountdown(newCountdown) - setIsReached(newCountdown.ledgersRemaining === 0) - }, [currentLedger]) - - if (!countdown) { - return null - } - - if (isReached) { - return ( - - - 🎉 - - XRPL has reached 100 million ledgers! - A historic milestone for the XRP Ledger - - 🎉 - - - ) - } - - const formatNumber = (num: number) => num.toString().padStart(2, '0') - - return ( - - - 🎊 - - Countdown to 100 Million Ledgers - - - Ledgers Remaining: - - {countdown.ledgersRemaining.toLocaleString()} - - - - Estimated Time: - - {countdown.days}d {formatNumber(countdown.hours)}h{' '} - {formatNumber(countdown.minutes)}m{' '} - {formatNumber(countdown.seconds)}s - - - - - 🎊 - - - ) -} diff --git a/src/containers/Ledgers/LedgerEntryHash.tsx b/src/containers/Ledgers/LedgerEntryHash.tsx index af2c9a0b6..ab93b277d 100644 --- a/src/containers/Ledgers/LedgerEntryHash.tsx +++ b/src/containers/Ledgers/LedgerEntryHash.tsx @@ -2,26 +2,15 @@ import { useTranslation } from 'react-i18next' import SuccessIcon from '../shared/images/success.svg' import { LedgerEntryValidator } from './LedgerEntryValidator' import { LedgerEntryHashTrustedCount } from './LedgerEntryHashTrustedCount' -import { ValidatorResponse } from './types' +import { LedgerHash } from '../shared/components/Streams' -export const LedgerEntryHash = ({ - hash, - unlCount, - validators, -}: { - hash: any - unlCount?: number - validators: { [pubkey: string]: ValidatorResponse } -}) => { +export const LedgerEntryHash = ({ hash }: { hash: LedgerHash }) => { const { t } = useTranslation() const shortHash = hash.hash.substring(0, 6) const barStyle = { background: `#${shortHash}` } const validated = hash.validated && return ( - + {hash.hash.substring(0, 6)} @@ -32,17 +21,12 @@ export const LedgerEntryHash = ({ {t('total')}: {hash.validations.length} - + {hash.validations.map((validation, i) => ( diff --git a/src/containers/Ledgers/LedgerEntryHashTrustedCount.tsx b/src/containers/Ledgers/LedgerEntryHashTrustedCount.tsx index 6a788176f..cac1abd8f 100644 --- a/src/containers/Ledgers/LedgerEntryHashTrustedCount.tsx +++ b/src/containers/Ledgers/LedgerEntryHashTrustedCount.tsx @@ -1,56 +1,56 @@ import { useTranslation } from 'react-i18next' +import { useMemo } from 'react' import { useTooltip } from '../shared/components/Tooltip' -import { Hash, ValidatorResponse } from './types' +import { useVHSValidators } from '../shared/components/VHSValidators' +import { LedgerValidation } from '../shared/components/Streams' export const LedgerEntryHashTrustedCount = ({ - hash, - unlCount, - validators, + validations, }: { - hash: Hash - unlCount?: number - validators: { [pubkey: string]: ValidatorResponse } + validations: LedgerValidation[] }) => { const { t } = useTranslation() const { hideTooltip, showTooltip } = useTooltip() - const className = hash.trusted_count < (unlCount || 0) ? 'missed' : '' + const { unl, validators } = useVHSValidators() - const getMissingValidators = () => { - const unl = {} - - Object.keys(validators).forEach((pubkey) => { - if (validators[pubkey].unl) { - unl[pubkey] = false + const status = useMemo(() => { + const missing = [...(unl || [])] + validations.forEach((v) => { + if (!validators?.[v.validation_public_key]) { + return } - }) - hash.validations.forEach((v) => { - if (unl[v.pubkey] !== undefined) { - delete unl[v.pubkey] + const missingIndex = missing.findIndex( + (assumedMissing) => assumedMissing === v.validation_public_key, + ) + if (missingIndex !== -1) { + missing.splice(missingIndex, 1) } }) - return Object.keys(unl).map((pubkey) => validators[pubkey]) - } - - const missing = - hash.trusted_count && className === 'missed' ? getMissingValidators() : null + return { + missing: missing.map((v) => validators?.[v]), + trustedCount: (unl?.length || 0) - missing.length, + } + }, [unl, validations, validators]) - return hash.trusted_count ? ( + return status.trustedCount ? ( - missing && missing.length && showTooltip('missing', e, { missing }) - } + className={status.missing.length > 0 ? 'missed' : ''} + onMouseMove={(e) => { + const { missing } = status + + missing.length && showTooltip('missing', e, { missing }) + }} onFocus={() => {}} onKeyUp={() => {}} onMouseLeave={() => hideTooltip()} > {t('unl')}: - {hash.trusted_count}/{unlCount} + {status.trustedCount}/{unl?.length} ) : null diff --git a/src/containers/Ledgers/LedgerEntryTransaction.tsx b/src/containers/Ledgers/LedgerEntryTransaction.tsx index 6ea0e8a79..2a291e8f3 100644 --- a/src/containers/Ledgers/LedgerEntryTransaction.tsx +++ b/src/containers/Ledgers/LedgerEntryTransaction.tsx @@ -15,7 +15,6 @@ export const LedgerEntryTransaction = ({ return ( { + const { validators } = useVHSValidators() const { showTooltip, hideTooltip } = useTooltip() const { selectedValidator, setSelectedValidator } = useSelectedValidator() - const trusted = validator.unl ? 'trusted' : '' - const unselected = selectedValidator ? 'unselected' : '' - const selected = selectedValidator === validator.pubkey ? 'selected' : '' - const className = `validation ${trusted} ${unselected} ${selected} ${validator.pubkey}` - const partial = validator.partial ? : null + const className = classNames( + 'validation', + validators?.[validation.validation_public_key]?.unl && 'trusted', + selectedValidator && 'unselected', + selectedValidator === validation.validation_public_key && 'selected', + validation.validation_public_key, + ) return ( showTooltip('validator', e, { - ...validator, - v: validators[validator.pubkey], + ...validation, + v: validators?.[validation.validation_public_key], }) } onFocus={() => {}} @@ -37,11 +39,13 @@ export const LedgerEntryValidator = ({ onMouseLeave={() => hideTooltip()} onClick={() => setSelectedValidator( - selectedValidator === validator.pubkey ? undefined : validator.pubkey, + selectedValidator === validation.validation_public_key + ? undefined + : validation.validation_public_key, ) } > - {partial} + {validation.partial && } ) } diff --git a/src/containers/Ledgers/LedgerListEntry.tsx b/src/containers/Ledgers/LedgerListEntry.tsx index 962f28570..8fe1c11c9 100644 --- a/src/containers/Ledgers/LedgerListEntry.tsx +++ b/src/containers/Ledgers/LedgerListEntry.tsx @@ -1,11 +1,15 @@ import { useTranslation } from 'react-i18next' -import { Ledger, ValidatorResponse } from './types' 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 { Tooltip, useTooltip } from '../shared/components/Tooltip' +import { + Tooltip, + TooltipProvider, + useTooltip, +} from '../shared/components/Tooltip' +import { Ledger } from '../shared/components/Streams' const SIGMA = '\u03A3' @@ -24,51 +28,38 @@ const LedgerIndex = ({ ledgerIndex }: { ledgerIndex: number }) => { ) } -export const LedgerListEntryInner = ({ - ledger, - unlCount, - validators, -}: { - ledger: Ledger - unlCount?: number - validators: { [pubkey: string]: ValidatorResponse } -}) => { +export const LedgerListEntryInner = ({ ledger }: { ledger: Ledger }) => { const { tooltip } = useTooltip() const { t } = useTranslation() - const time = ledger.close_time - ? new Date(ledger.close_time).toLocaleTimeString() + const time = ledger.closeTime + ? new Date(ledger.closeTime).toLocaleTimeString() : null return ( - + - + {time} {/* Render Transaction Count (can be 0) */} - {ledger.txn_count !== undefined && ( + {ledger.txCount !== undefined && ( - {t('txn_count')}:{ledger.txn_count.toLocaleString()} + {t('txn_count')}:{ledger.txCount.toLocaleString()} )} {/* Render Total Fees (can be 0) */} - {ledger.total_fees !== undefined && ( + {ledger.totalFees !== undefined && ( {SIGMA} {t('fees')}: - + )} - {ledger.hashes.map((hash) => ( - + {(ledger.hashes ?? []).map((hash) => ( + ))} @@ -76,18 +67,8 @@ export const LedgerListEntryInner = ({ ) } -export const LedgerListEntry = ({ - ledger, - unlCount, - validators, -}: { - ledger: Ledger - unlCount?: number - validators: { [pubkey: string]: ValidatorResponse } -}) => ( - +export const LedgerListEntry = ({ ledger }: { ledger: Ledger }) => ( + + + ) diff --git a/src/containers/Ledgers/LedgerMetrics.tsx b/src/containers/Ledgers/LedgerMetrics.tsx index 6b3a206d2..ff2cf7a9c 100644 --- a/src/containers/Ledgers/LedgerMetrics.tsx +++ b/src/containers/Ledgers/LedgerMetrics.tsx @@ -7,13 +7,14 @@ import QuorumIcon from '../shared/images/quorum.svg' import FeeIcon from '../shared/images/fee.svg' import ClockIcon from '../shared/images/clock.svg' import ClockAltIcon from '../shared/images/clock_2.svg' -import DecenTralizedIcon from '../shared/images/decentralized.svg' +import DecentralizedIcon from '../shared/images/decentralized.svg' import CreditIcon from '../shared/images/finance_credit.svg' import UserIcon from '../shared/images/user.svg' import HoverIcon from '../shared/images/hover.svg' import './css/ledgerMetrics.scss' import { useIsOnline } from '../shared/SocketContext' import { useLanguage } from '../shared/hooks' +import { useStreams } from '../shared/components/Streams' const DEFAULTS = { load_fee: '--', @@ -28,14 +29,13 @@ const DEFAULTS = { const TOOLTIP_Y_OFFSET = 70 export const LedgerMetrics = ({ - data: suppliedData, onPause, paused, }: { - data: any onPause: any paused: boolean }) => { + const { metrics: suppliedData } = useStreams() const data = { ...DEFAULTS, ...suppliedData } const { tooltip, showTooltip, hideTooltip } = useTooltip() const { t } = useTranslation() @@ -70,7 +70,7 @@ export const LedgerMetrics = ({ case 'ledger_interval': return case 'txn_ledger': - return + return case 'txn_sec': return case 'load_fee': @@ -113,7 +113,7 @@ export const LedgerMetrics = ({ } else if (key === 'nUnl' && data[key]?.length === 0) { return null } else if (key === 'nUnl') { - content = data[key].length + content = data[key]?.length return ( diff --git a/src/containers/Ledgers/Ledgers.tsx b/src/containers/Ledgers/Ledgers.tsx index b79208cdb..48f1e1a1c 100644 --- a/src/containers/Ledgers/Ledgers.tsx +++ b/src/containers/Ledgers/Ledgers.tsx @@ -8,32 +8,31 @@ import { VALIDATOR_ROUTE } from '../App/routes' import { LedgerListEntry } from './LedgerListEntry' import { useSelectedValidator } from './useSelectedValidator' import { usePreviousWithPausing } from '../shared/hooks/usePreviousWithPausing' +import { useStreams, Ledger } from '../shared/components/Streams' +import { useVHSValidators } from '../shared/components/VHSValidators' -export const Ledgers = ({ - paused, - ledgers = [], - unlCount, - validators = {}, -}: { - paused: boolean - ledgers: any[] - unlCount?: number - validators: any -}) => { +export const Ledgers = ({ paused }: { paused: boolean }) => { + const { validators: validatorsFromVHS } = useVHSValidators() const { selectedValidator } = useSelectedValidator() - const localLedgers = usePreviousWithPausing(ledgers, paused) + const { ledgers } = useStreams() + const localLedgers = usePreviousWithPausing>( + ledgers, + paused, + ) const isOnline = useIsOnline() return ( - {isOnline && ledgers.length > 0 ? ( + {isOnline && ledgers ? ( <> - {selectedValidator && ( + {selectedValidator && validatorsFromVHS && ( - {validators[selectedValidator].domain && ( - + {validatorsFromVHS[selectedValidator].domain && ( + )} - {localLedgers?.map((ledger) => ( - - ))} + {Object.values(localLedgers ?? {}) + .reverse() + .slice(0, 20) + ?.map((ledger) => ( + + ))} > ) : ( diff --git a/src/containers/Ledgers/css/ledgerCountdownBanner.scss b/src/containers/Ledgers/css/ledgerCountdownBanner.scss deleted file mode 100644 index 7f1b4620b..000000000 --- a/src/containers/Ledgers/css/ledgerCountdownBanner.scss +++ /dev/null @@ -1,144 +0,0 @@ -@use '../../shared/css/variables' as *; - -.ledger-countdown-banner { - padding: 12px 20px; - border-radius: $border-radius; - margin: 0 20px 15px; - animation: slide-down 0.5s ease-out; - background: linear-gradient(135deg, $blue-purple-40 0%, $blue-purple-70 100%); - box-shadow: 0 4px 12px rgb(0 0 0 / 15%); - color: $white; - - &.celebration { - animation: pulse 2s ease-in-out infinite; - background: linear-gradient(135deg, $magenta-40 0%, $magenta-70 100%); - } - - .banner-content { - display: flex; - max-width: 1200px; - align-items: center; - justify-content: space-between; - margin: 0 auto; - gap: 12px; - - @media (width <= 768px) { - flex-direction: column; - gap: 8px; - } - } - - .celebration-icon { - flex-shrink: 0; - animation: bounce 1s ease-in-out infinite; - font-size: 24px; - - &:nth-child(3) { - animation-delay: 0.2s; - } - - @media (width <= 768px) { - font-size: 20px; - } - } - - .banner-text { - flex: 1; - text-align: center; - - h2 { - margin: 0 0 4px; - font-size: 16px; - @include semibold; - - letter-spacing: 0.5px; - - @media (width <= 768px) { - font-size: 14px; - } - } - - p { - margin: 0; - font-size: 12px; - @include regular; - - opacity: 0.95; - } - } - - .countdown-info { - display: flex; - flex-wrap: wrap; - justify-content: center; - margin-top: 4px; - gap: 20px; - - @media (width <= 768px) { - gap: 12px; - } - } - - .ledger-count, - .time-estimate { - display: flex; - flex-direction: column; - align-items: center; - gap: 2px; - - .label { - font-size: 10px; - @include medium; - - letter-spacing: 0.5px; - opacity: 0.85; - text-transform: uppercase; - } - - .value { - font-family: 'Courier New', monospace; - font-size: 13px; - @include semibold; - - letter-spacing: 0.5px; - - @media (width <= 768px) { - font-size: 12px; - } - } - } -} - -@keyframes slide-down { - from { - opacity: 0; - transform: translateY(-20px); - } - - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes bounce { - 0%, - 100% { - transform: translateY(0); - } - - 50% { - transform: translateY(-8px); - } -} - -@keyframes pulse { - 0%, - 100% { - box-shadow: 0 4px 12px rgb(0 0 0 / 15%); - } - - 50% { - box-shadow: 0 4px 20px rgb(245 87 108 / 40%); - } -} diff --git a/src/containers/Ledgers/index.tsx b/src/containers/Ledgers/index.tsx index ba732886f..409369952 100644 --- a/src/containers/Ledgers/index.tsx +++ b/src/containers/Ledgers/index.tsx @@ -1,38 +1,16 @@ -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 { LedgerCountdownBanner } from './LedgerCountdownBanner' -import { Ledger, ValidatorResponse } from './types' import { useAnalytics } from '../shared/analytics' -import NetworkContext from '../shared/NetworkContext' -import { useIsOnline } from '../shared/SocketContext' - import { SelectedValidatorProvider } from './useSelectedValidator' - -const FETCH_INTERVAL_MILLIS = 5 * 60 * 1000 +import { StreamsProvider } from '../shared/components/Streams' +import { VHSValidatorsProvider } from '../shared/components/VHSValidators' export const LedgersPage = () => { const { trackScreenLoaded } = useAnalytics() - const [validators, setValidators] = useState< - Record - >({}) - const [ledgers, setLedgers] = useState([]) const [paused, setPaused] = useState(false) - const [metrics, setMetrics] = useState(undefined) - const [unlCount, setUnlCount] = useState(undefined) - const [currentLedgerIndex, setCurrentLedgerIndex] = useState< - number | undefined - >(undefined) - const { isOnline } = useIsOnline() - const { t } = useTranslation() - const network = useContext(NetworkContext) useEffect(() => { trackScreenLoaded() @@ -41,69 +19,20 @@ export 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 pause = () => setPaused(!paused) - - // Extract current ledger index from the ledgers array - useEffect(() => { - if (ledgers.length > 0) { - const maxLedger = Math.max(...ledgers.map((l) => l.ledger_index || 0)) - setCurrentLedgerIndex(maxLedger) - } - }, [ledgers]) + const { t } = useTranslation() return ( - {isOnline && ( - - )} - - - pause()} paused={paused} /> - - + + + + pause()} paused={paused} /> + + + + ) } diff --git a/src/containers/Ledgers/test/LedgersPage.test.js b/src/containers/Ledgers/test/LedgersPage.test.js index 56e04ddcd..3e946dea9 100644 --- a/src/containers/Ledgers/test/LedgersPage.test.js +++ b/src/containers/Ledgers/test/LedgersPage.test.js @@ -10,7 +10,7 @@ import prevLedgerMessage from './mock/prevLedger.json' import ledgerMessage from './mock/ledger.json' import validationMessage from './mock/validation.json' import rippledResponses from './mock/rippled.json' -import { QuickHarness, flushPromises } from '../../test/utils' +import { QuickHarness } from '../../test/utils' import { SelectedValidatorProvider } from '../useSelectedValidator' function sleep(ms) { @@ -71,7 +71,7 @@ class MockWsClient extends BaseMockWsClient { } } -const WS_URL = 'ws://localhost:1234' +const WS_URL = 'wss://fakenode.ripple.com:51233' describe('Ledgers Page container', () => { let server @@ -146,19 +146,22 @@ describe('Ledgers Page container', () => { expect(wrapper.find('.validation').length).toBe(0) expect(wrapper.find('.txn').length).toBe(0) - await sleep(250) server.send(prevLedgerMessage) - await sleep(260) wrapper.update() + expect(wrapper.exists()).toBe(true) + expect(wrapper.find('.ledgers').length).toBe(1) + expect(wrapper.find('.ledger-list').length).toBe(1) expect(wrapper.find('.ledger').length).toBe(1) server.send(validationMessage) - await flushPromises() + // validation-messages are processed in batches every 200 ms + await sleep(200 * 2) wrapper.update() + + expect(wrapper.exists()).toBe(true) expect(wrapper.find('.validation').length).toBe(1) server.send(ledgerMessage) - await sleep(250) wrapper.update() expect(wrapper.find('.ledger').length).toBe(2) @@ -201,7 +204,8 @@ describe('Ledgers Page container', () => { expect(wrapper.find('.selected-validator a.pubkey').length).toBe(0) wrapper.unmount() - + // wait for the React useEffect cleanup function to complete in StreamsProvider component + await sleep(100) expect(client.listenerCount('ledger')).toBe(0) expect(client.listenerCount('validation')).toBe(0) }, 8000) @@ -234,21 +238,27 @@ describe('Ledgers Page container', () => { path: '/my.custom.com', }) - expect(wrapper.find('.ledger').length).toBe(0) + await sleep(100) + wrapper.update() + expect(wrapper.find('.validation').length).toBe(0) - expect(wrapper.find('.txn').length).toBe(0) + // Recent refactor of the StreamsProvider eagerly fetches the latest validated ledger. The objective is to reduce the latency of displaying the first validated ledger. Consequently, loading the page fetches the latest validated ledger. + expect(wrapper.find('.ledger').length).toBe(1) + // count the number of div elements in the rendered HTML + expect(wrapper.find('.txn').hostNodes().length).toBe(6) - await sleep(260) server.send(prevLedgerMessage) await sleep(260) wrapper.update() - expect(wrapper.find('.ledger').length).toBe(1) + expect(wrapper.find('.ledger').length).toBe(2) server.send(validationMessage) - await flushPromises() + // validation-messages are processed in batches every 200 ms + await sleep(200 * 2) wrapper.update() expect(wrapper.find('.validation').length).toBe(1) + // the below ledger has already been ingested from `rippledResponses`. No new ledgers are added by this action. server.send(ledgerMessage) await sleep(250) wrapper.update() @@ -274,7 +284,7 @@ describe('Ledgers Page container', () => { const validations = wrapper.find('div.validation') const txn = wrapper.find('a.txn') - // check ledger transactions + // check ledger transactions: 6 transactions per ledger, 2 ledgers, so 12 transactions expect(txn.length).toBe(12) txn.first().simulate('focus') txn.first().simulate('mouseOver') diff --git a/src/containers/Ledgers/types.ts b/src/containers/Ledgers/types.ts deleted file mode 100644 index dcbb289f9..000000000 --- a/src/containers/Ledgers/types.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { TransactionSummary } from '../shared/types' - -export interface Hash { - hash: string - // eslint-disable-next-line camelcase -- TODO: change - trusted_count: number - validations: any[] // TODO: type properly - unselected: boolean - validated: boolean -} - -export interface Ledger { - // eslint-disable-next-line camelcase -- mimicking rippled - close_time: string - hashes: Hash[] - // eslint-disable-next-line camelcase -- mimicking rippled - ledger_hash: string - // eslint-disable-next-line camelcase -- mimicking rippled - ledger_index: number - // eslint-disable-next-line camelcase -- mimicking rippled - parent_hash: string - seen: number - // eslint-disable-next-line camelcase -- mimicking rippled - total_fees: number - // eslint-disable-next-line camelcase -- mimicking rippled - total_xrp: number - transactions: TransactionSummary[] - // eslint-disable-next-line camelcase -- TODO: change - txn_count: number -} - -export interface ValidatorResponse { - // eslint-disable-next-line camelcase -- from VHS - signing_key: string - // eslint-disable-next-line camelcase -- from VHS - master_key: string - unl: string - domain: string | null -} diff --git a/src/containers/Network/Hexagons.jsx b/src/containers/Network/Hexagons.tsx similarity index 75% rename from src/containers/Network/Hexagons.jsx rename to src/containers/Network/Hexagons.tsx index e351a3e9b..8c3402fbf 100644 --- a/src/containers/Network/Hexagons.jsx +++ b/src/containers/Network/Hexagons.tsx @@ -1,11 +1,11 @@ import { useEffect, useState } from 'react' -import PropTypes from 'prop-types' import { useWindowSize } from 'usehooks-ts' import { hexbin } from 'd3-hexbin' import { Loader } from '../shared/components/Loader' import { Tooltip, useTooltip } from '../shared/components/Tooltip' import './css/hexagons.scss' +import { ValidatorResponse } from '../shared/vhsTypes' const MAX_WIDTH = 1200 const getDimensions = (width) => ({ @@ -14,7 +14,21 @@ const getDimensions = (width) => ({ radius: Math.min(width, MAX_WIDTH) / 25, }) -const prepareHexagons = (data, list, height, radius, prev = []) => { +type Hexagon = { + cookie?: string + pubkey?: string + ledger_hash: string + x: number + y: number +} + +const prepareHexagons = ( + data, + list: Record, + height: number, + radius: number, + prev: Hexagon[] = [], +) => { const maxRows = Math.ceil(height / ((radius * 3) / 2)) const hexWidth = radius * Math.sqrt(3) let row = 0 @@ -41,16 +55,22 @@ const prepareHexagons = (data, list, height, radius, prev = []) => { ...pos, ...list[d.pubkey], prev: - prev[i] && prev[i].ledger_hash !== d.ledger_hash - ? prev[i].ledger_hash.substring(0, 6) + prev[i] && prev[i]?.ledger_hash !== d.ledger_hash + ? prev[i]?.ledger_hash.substr(0, 6) : undefined, } }) } -export const Hexagons = ({ list, data }) => { +export const Hexagons = ({ + list, + data, +}: { + data: any + list?: Record +}) => { const { width } = useWindowSize() - const [hexagons, setHexagons] = useState([]) + const [hexagons, setHexagons] = useState([]) const { width: gridWidth, height: gridHeight, radius } = getDimensions(width) const { tooltip, showTooltip, hideTooltip } = useTooltip() const bin = hexbin() @@ -61,16 +81,22 @@ export const Hexagons = ({ list, data }) => { .radius(radius) useEffect(() => { - if (width > 0) { + if (width > 0 && list) { setHexagons((prevHexagons) => - prepareHexagons(data, list, gridHeight, radius, prevHexagons), + prepareHexagons( + Object.values(data), + list, + gridHeight, + radius, + prevHexagons, + ), ) } }, [data, list, width, gridHeight, radius]) const renderHexagon = (d, theHex) => { const { cookie, pubkey, ledger_hash: ledgerHash } = d - const fill = `#${ledgerHash.substring(0, 6)}` + const fill = `#${ledgerHash.substr(0, 6)}` const strokeWidth = theHex.radius() / 16 return ( { transform={`translate(${d.x},${d.y})`} className="hexagon updated" onMouseOver={(e) => - showTooltip('validator', e, { ...d, v: list[d.pubkey] }) + showTooltip('validator', e, { ...d, v: list?.[d.pubkey] }) } onFocus={() => {}} onMouseLeave={hideTooltip} @@ -116,8 +142,3 @@ export const Hexagons = ({ list, data }) => { ) } - -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 0e4810a0a..5f2a10841 100644 --- a/src/containers/Network/Validators.tsx +++ b/src/containers/Network/Validators.tsx @@ -1,146 +1,120 @@ -import { useContext, useState } from 'react' -import axios from 'axios' +import './css/style.scss' + +import { useContext, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useQuery } from 'react-query' import { Helmet } from 'react-helmet-async' -import Streams from '../shared/components/Streams' +import { useQuery } from 'react-query' +import { getServerState } from '../../rippled/lib/rippled' import { ValidatorsTable } from './ValidatorsTable' -import Log from '../shared/log' import { - localizeNumber, - FETCH_INTERVAL_MILLIS, FETCH_INTERVAL_ERROR_MILLIS, FETCH_INTERVAL_FEE_SETTINGS_MILLIS, + localizeNumber, } from '../shared/utils' import { useLanguage } from '../shared/hooks' import { Hexagons } from './Hexagons' -import { - FeeSettings, - StreamValidator, - ValidatorResponse, -} from '../shared/vhsTypes' -import NetworkContext from '../shared/NetworkContext' - -import './css/style.scss' +import { FeeSettings, StreamValidator } from '../shared/vhsTypes' import { VALIDATORS_ROUTE } from '../App/routes' import { useRouteParams } from '../shared/routing' import ValidatorsTabs from './ValidatorsTabs' +import { useStreams, StreamsProvider } from '../shared/components/Streams' +import { + VHSValidatorsProvider, + useVHSValidators, +} from '../shared/components/VHSValidators' import SocketContext from '../shared/SocketContext' -import { getServerState } from '../../rippled/lib/rippled' +import NetworkContext from '../shared/NetworkContext' -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 [feeSettings, setFeeSettings] = useState( - undefined, - ) - const network = useContext(NetworkContext) +export const ValidatorsData = () => { const rippledSocket = useContext(SocketContext) - const { tab = 'uptime' } = useRouteParams(VALIDATORS_ROUTE) + const network = useContext(NetworkContext) - useQuery(['fetchValidatorsData'], () => fetchData(), { - refetchInterval: (returnedData, _) => - returnedData == null - ? FETCH_INTERVAL_ERROR_MILLIS - : FETCH_INTERVAL_MILLIS, - refetchOnMount: true, - enabled: process.env.VITE_ENVIRONMENT !== 'custom' || !!network, - }) + const language = useLanguage() + const { t } = useTranslation() + const { validators: validatorsFromValidations, metrics } = useStreams() + const { validators: validatorsFromVHS, unl } = useVHSValidators() + const [feeSettings, setFeeSettings] = useState() - useQuery(['fetchFeeSettingsData'], () => fetchFeeSettingsData(), { - refetchInterval: (returnedData, _) => - returnedData == null - ? FETCH_INTERVAL_ERROR_MILLIS - : FETCH_INTERVAL_FEE_SETTINGS_MILLIS, - refetchOnMount: true, - enabled: process.env.VITE_ENVIRONMENT !== 'custom' || !!network, - }) + const merged = useMemo(() => { + if ( + !validatorsFromVHS || + !( + validatorsFromValidations && + Object.keys(validatorsFromValidations).length + ) + ) { + return [] + } - function mergeLatest( - validators: Record, - live: Record, - ): Record { 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].signing_key } + // 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 fetchFeeSettingsData() { - if (tab === 'voting') { - getServerState(rippledSocket).then((res) => { - setFeeSettings({ - base_fee: res.state.validated_ledger.base_fee, - reserve_base: res.state.validated_ledger.reserve_base, - reserve_inc: res.state.validated_ledger.reserve_inc, - }) - }) - } - } + return Object.values(updated) + }, [validatorsFromVHS, validatorsFromValidations]) - function fetchData() { - const url = `${process.env.VITE_DATA_URL}/validators/${network}` + const validatorCount = useMemo( + () => merged && Object.keys(merged).length, + [merged], + ) - return axios - .get(url) - .then((resp) => resp.data.validators) - .then((validators) => { - const newValidatorList: Record = {} - validators.forEach((v: ValidatorResponse) => { - newValidatorList[v.signing_key] = v - }) + const { tab = 'uptime' } = useRouteParams(VALIDATORS_ROUTE) - 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)) - } + useQuery(['fetchFeeSettingsData'], () => fetchFeeSettingsData(), { + refetchInterval: (returnedData, _) => + returnedData == null + ? FETCH_INTERVAL_ERROR_MILLIS + : FETCH_INTERVAL_FEE_SETTINGS_MILLIS, + refetchOnMount: true, + enabled: + (process.env.VITE_ENVIRONMENT !== 'custom' || !!network) && + tab === 'voting', + }) - 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, - } + function fetchFeeSettingsData() { + getServerState(rippledSocket).then((res) => { + setFeeSettings({ + base_fee: res.state.validated_ledger.base_fee, + reserve_base: res.state.validated_ledger.reserve_base, + reserve_inc: res.state.validated_ledger.reserve_inc, }) - return mergeLatest(newValidatorsList, value) }) } - const validatorCount = Object.keys(vList).length - const Body = { uptime: ( - + ), voting: ( { return ( {t('validators')} - {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}) )} @@ -181,3 +148,11 @@ export const Validators = () => { ) } + +export const Validators = () => ( + + + + + +) diff --git a/src/containers/Network/ValidatorsTable.tsx b/src/containers/Network/ValidatorsTable.tsx index d07d094e3..89375d928 100644 --- a/src/containers/Network/ValidatorsTable.tsx +++ b/src/containers/Network/ValidatorsTable.tsx @@ -13,7 +13,7 @@ import { useLanguage } from '../shared/hooks' import { DROPS_TO_XRP_FACTOR, renderXRP } from '../shared/utils' interface ValidatorsTableProps { - validators: StreamValidator[] + validators?: StreamValidator[] metrics: any tab: string feeSettings?: FeeSettings @@ -51,7 +51,11 @@ const sortValidators = (data) => { export const ValidatorsTable = (props: ValidatorsTableProps) => { const { validators: rawValidators, metrics, tab, feeSettings } = props - const validators = rawValidators ? sortValidators(rawValidators) : undefined + const validators = rawValidators + ? sortValidators( + rawValidators.filter((v) => v.master_key || v.signing_key || v.pubkey), + ) + : undefined const { t } = useTranslation() const language = useLanguage() @@ -93,7 +97,8 @@ export const ValidatorsTable = (props: ValidatorsTableProps) => { const renderValidator = (d) => { const color = d.ledger_hash ? `#${d.ledger_hash.substring(0, 6)}` : '' const trusted = d.unl ? 'yes' : 'no' - const pubkey = d.master_key || d.signing_key + // Note: Validator information is obtained from VHS and subscription Stream. The latter type uses pubkey as an alias for validation_public_key field (which is the signing key of the validator) + const pubkey = d.master_key || d.signing_key || d.pubkey const onNegativeUnl = metrics.nUnl && metrics.nUnl.includes(pubkey) const nUnl = onNegativeUnl ? 'yes' : 'no' const ledgerIndex = d.ledger_index ?? d.current_index diff --git a/src/containers/Network/test/validators.test.js b/src/containers/Network/test/validators.test.js index 5f6739702..1a1d4e1b1 100644 --- a/src/containers/Network/test/validators.test.js +++ b/src/containers/Network/test/validators.test.js @@ -2,6 +2,7 @@ import { mount } from 'enzyme' import moxios from 'moxios' import WS from 'jest-websocket-mock' import { Route } from 'react-router' +import { QueryClientProvider } from 'react-query' import i18n from '../../../i18n/testConfig' import mockValidators from './mockValidators.json' import validationMessage from './mockValidation.json' @@ -10,6 +11,9 @@ import MockWsClient from '../../test/mockWsClient' import { QuickHarness } from '../../test/utils' import { VALIDATORS_ROUTE } from '../../App/routes' import { Validators } from '../Validators' +import { StreamsProvider } from '../../shared/components/Streams' +import { queryClient } from '../../shared/QueryClient' +import { VHSValidatorsProvider } from '../../shared/components/VHSValidators' const WS_URL = 'ws://localhost:1234' @@ -19,9 +23,18 @@ describe('Validators Tab container', () => { const createWrapper = () => mount( - - } /> - + + + + + } /> + + + + , ) @@ -54,7 +67,7 @@ describe('Validators Tab container', () => { expect(wrapper.find('.validators').length).toBe(1) expect(wrapper.find('.stat').html()).toBe( - 'validators_found: 0', + 'validators_found: 0 (unl: )', ) expect(wrapper.find('.validators-table').length).toBe(1) diff --git a/src/containers/shared/components/Streams.jsx b/src/containers/shared/components/Streams.jsx deleted file mode 100644 index 4ccf51bfb..000000000 --- a/src/containers/shared/components/Streams.jsx +++ /dev/null @@ -1,583 +0,0 @@ -import { Component } from 'react' -import PropTypes from 'prop-types' -import axios from 'axios' -import Log from '../log' -import SocketContext from '../SocketContext' -import { getNegativeUNL, getQuorum } from '../../../rippled' -import { getLedger, getServerInfo } from '../../../rippled/lib/rippled' -import { summarizeLedger } from '../../../rippled/lib/summarizeLedger' -import { convertRippleDate } from '../../../rippled/lib/convertRippleDate' - -const MAX_LEDGER_COUNT = 15 - -const PURGE_INTERVAL = 10 * 1000 -const MAX_AGE = 5 * 60 * 1000 - -const throttle = (func, limit) => { - let inThrottle - let queued = false - return function throttled(...args) { - const context = this - if (!inThrottle) { - queued = false - func.apply(context, args) - inThrottle = true - setTimeout(() => { - if (queued) { - func.apply(context, args) - } - inThrottle = false - queued = false - }, limit) - } else { - queued = true - } - } -} - -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) - -// fetch full ledger -const fetchLedger = (ledger, rippledSocket, attempts = 0) => - getLedger(rippledSocket, { ledger_hash: ledger.ledger_hash }) - .then(summarizeLedger) - .then((summary) => { - Object.assign(ledger, summary) - return summary - }) - .catch((error) => { - Log.error(error.toString()) - if (error.code === 404 && attempts < 5) { - Log.info( - `retry ledger ${ledger.ledger_index} (attempt:${attempts + 1})`, - ) - return sleep(500).then(() => - fetchLedger(ledger, rippledSocket, attempts + 1), - ) - } - throw error - }) - -const fetchLoadFee = (rippledSocket) => - getServerInfo(rippledSocket) - .then((result) => result.info) - .then((info) => { - const ledgerFeeInfo = info.validated_ledger - const loadFee = ledgerFeeInfo.base_fee_xrp * (info.load_factor ?? 1) - return { load_fee: Number(loadFee.toPrecision(4)).toString() } - }) - -const formatLedgers = (data) => - Object.entries(data) - .map(([_index, ledger]) => { - const hashes = Object.entries(ledger.hashes || {}).map((h) => { - const validated = h[0] === ledger.ledger_hash - - h[1].sort((a, b) => { - if (a.time === b.time) { - const sa = a.unl ? 1 : 0 - const sb = b.unl ? 1 : 0 - return sb - sa - } - - return a.time - b.time - }) - - // Dedupe validations count using pubkey - const rawUnlEntries = h[1].filter( - (entry) => typeof entry.unl === 'string' && entry.unl.length > 0, - ) - const pubkeyList = rawUnlEntries.map((entry) => entry.pubkey) - const dedupedTrustedCount = new Set(pubkeyList).size - - return { - hash: h[0], - trusted_count: dedupedTrustedCount, - validations: h[1], - unselected: !validated && Boolean(ledger.ledger_hash), - validated, - } - }) - return { ...ledger, hashes } - }) - .sort((a, b) => b.ledger_index - a.ledger_index) - -export const fetchNegativeUNL = async (rippledSocket) => - getNegativeUNL(rippledSocket) - .then((data) => { - if (data === undefined) throw new Error('undefined nUNL') - - return data - }) - .catch((e) => { - Log.error(e) - return [] - }) - -export 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 fetchMetrics = () => - axios - .get('/api/v1/metrics') - .then((result) => result.data) - .catch((e) => Log.error(e)) - -class Streams extends Component { - constructor(props) { - super(props) - const { updateLedgers, updateValidators } = props - this.state = { - ledgers: {}, - metrics: {}, // eslint-disable-line - validators: {}, // eslint-disable-line - maxLedger: 0, - allLedgers: {}, - allValidators: {}, - } - - this.updateLedgers = throttle((ledgers) => { - updateLedgers(formatLedgers(ledgers)) - }, 250) - - this.updateValidators = throttle((validators) => { - updateValidators(Object.entries(validators).map((v) => v[1])) - }, 100) - } - - async componentDidMount() { - await this.connect() - this.updateNegativeUNL() - if (process.env.VITE_ENVIRONMENT !== 'custom') { - this.updateMetricsFromServer() - } - this.purge = setInterval(this.purge, 5000) - this.purgeAll = setInterval(this.purgeAll, PURGE_INTERVAL) - - const rippledSocket = this.context - rippledSocket.send({ - command: 'subscribe', - streams: ['ledger', 'validations'], - }) - } - - componentWillUnmount() { - const rippledSocket = this.context - rippledSocket.send({ - command: 'unsubscribe', - streams: ['ledger', 'validations'], - }) - - if (this.onLedgerWrapper) { - rippledSocket.off('ledger', this.onLedgerWrapper) - } - - if (this.onValidationWrapper) { - rippledSocket.off('validation', this.onValidationWrapper) - } - - clearInterval(this.purge) - clearInterval(this.purgeAll) - } - - // handle ledger messages - async handleLedger(data) { - const ledger = await this.addLedger(data) - const { - ledger_hash: ledgerHash, - ledger_index: ledgerIndex, - txn_count: txnCount, - } = data - - Log.info('new ledger', ledgerIndex) - ledger.ledger_hash = ledgerHash - ledger.txn_count = txnCount - ledger.close_time = convertRippleDate(data.ledger_time) - - const baseFee = data.fee_base / 1000000 - return { - ledger, - baseFee, - } - } - - // handle validation messages - async handleValidation(data) { - const { ledger_hash: ledgerHash, validation_public_key: pubkey } = data - const ledgerIndex = Number(data.ledger_index) - - if (!this.isValidatedChain(ledgerIndex)) { - return undefined - } - - await this.addLedger(data) - - this.setState((prevState) => { - if (!prevState.allValidators[pubkey]) { - const allValidators = { - [pubkey]: { pubkey, ledger_index: 0 }, - } - return { allValidators } - } - return {} - }) - - const { allValidators } = this.state - if ( - allValidators[pubkey].ledger_hash !== ledgerHash && - ledgerIndex > allValidators[pubkey].ledger_index - ) { - this.setState((prevState) => { - const newValidatorData = Object.assign( - prevState.allValidators[pubkey], - { - ledger_hash: ledgerHash, - ledger_index: ledgerIndex, - last: Date.now(), - }, - ) - const newAllValidators = Object.assign(prevState.allValidators, { - [pubkey]: newValidatorData, - }) - return { allValidators: newAllValidators } - }) - - return { - ledger_index: Number(ledgerIndex), - ledger_hash: ledgerHash, - pubkey, - partial: !data.full, - time: convertRippleDate(data.signing_time), - cookie: data.cookie, - } - } - - return undefined - } - - onMetric(data) { - const { updateMetrics } = this.props - this.setState((prevState) => { - const metrics = Object.assign(prevState.metrics, data) - updateMetrics(metrics) - return { metrics } - }) - } - - onLedgerSummary(data) { - const { ledger_index: ledgerIndex } = data - if (ledgerIndex % 256 === 0) this.updateNegativeUNL() - this.setState((prevState) => { - const { ledgers, maxLedger } = this.getTruncatedLedgers(ledgerIndex) - ledgers[ledgerIndex] = Object.assign( - ledgers[ledgerIndex] || { hashes: {} }, - data, - ) - if (ledgers[ledgerIndex].txn_count == null) { - ledgers[ledgerIndex].txn_count = data.transactions.length - } - this.updateLedgers(ledgers) - return { ledgers, maxLedger } - }) - } - - onLedger(data) { - const { ledger_index: ledgerIndex } = data - this.setState((prevState) => { - const { ledgers, maxLedger } = this.getTruncatedLedgers(ledgerIndex) - ledgers[ledgerIndex] = Object.assign( - ledgers[ledgerIndex] || { hashes: {} }, - data, - ) - this.updateLedgers(ledgers) - return { ledgers, maxLedger } - }) - } - - onValidation(data) { - const { validators: vList } = this.props - const { ledger_index: ledgerIndex, ledger_hash: ledgerHash } = data - - this.setState((prevState) => { - const { validators } = prevState - const { ledgers, maxLedger } = this.getTruncatedLedgers(ledgerIndex) - - if (maxLedger - ledgerIndex > MAX_LEDGER_COUNT) { - return undefined - } - - if (!ledgers[ledgerIndex]) { - ledgers[ledgerIndex] = { - ledger_index: ledgerIndex, - hashes: {}, - } - } - - if (!ledgers[ledgerIndex].hashes[ledgerHash]) { - ledgers[ledgerIndex].hashes[ledgerHash] = [] - } - - ledgers[ledgerIndex].hashes[ledgerHash].push({ - pubkey: data.pubkey, - partial: data.partial, - time: data.time, - unl: vList && vList[data.pubkey] && vList[data.pubkey].unl, - cookie: data.cookie, - }) - - validators[data.pubkey] = data - this.updateLedgers(ledgers) - this.updateValidators(validators) - return { ledgers, validators, maxLedger } - }) - } - - async setStateAsync(state) { - return new Promise((resolve) => { - this.setState(state, resolve) - }) - } - - getTruncatedLedgers(max) { - const { ledgers, maxLedger } = this.state - const newMax = Math.max(max, maxLedger) - Object.keys(ledgers).forEach((key) => { - if (newMax - key >= MAX_LEDGER_COUNT) { - delete ledgers[key] - } - }) - - return { ledgers, maxLedger: max } - } - - purge = () => { - const now = Date.now() - this.setState((prevState) => { - const prev = prevState.validators - const validators = {} - - Object.keys(prev).forEach((key) => { - if (now - prev[key].time < 30000) { - validators[key] = prev[key] - } - }) - - return { validators } - }) - } - - // purge old data - purgeAll = () => { - const now = Date.now() - this.setState((prevState) => { - const allLedgers = { ...prevState.allLedgers } - Object.keys(allLedgers).forEach((key) => { - if (now - allLedgers[key].seen > MAX_AGE) { - delete allLedgers[key] - } - }) - - const allValidators = { ...prevState.allValidators } - Object.keys(allValidators).forEach((key) => { - if (now - allValidators[key].last > MAX_AGE) { - delete allValidators[key] - } - }) - return { allLedgers, allValidators } - }) - } - - // determine if the ledger index - // is on the validated ledger chain - isValidatedChain(ledgerIndex) { - const { allLedgers } = this.state - let prev = ledgerIndex - 1 - while (allLedgers[prev]) { - if (allLedgers[prev].ledger_hash) { - return true - } - prev -= 1 - } - - return false - } - - // add the ledger to the cache - async addLedger(data) { - const { ledger_index: ledgerIndex } = data - - await this.setStateAsync((prevState) => { - if (!prevState.allLedgers[ledgerIndex]) { - const allLedgers = Object.assign(prevState.allLedgers, { - [ledgerIndex]: { - ledger_index: Number(ledgerIndex), - seen: Date.now(), - }, - }) - return { allLedgers } - } - return {} - }) - - const { allLedgers } = this.state - return allLedgers[ledgerIndex] - } - - // update rolling metrics - updateMetrics(baseFee) { - const ledgerChain = this.organizeChain().slice(-50) - Log.info(ledgerChain.length) - - 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.total_fees) { - fees += d.total_fees - txWithFeesCount += d.txn_count - } - if (d.txn_count) { - txCount += d.txn_count - } - ledgerCount += 1 - }) - - return { - base_fee: Number(baseFee.toPrecision(4)).toString(), - txn_sec: time ? ((txCount / time) * 1000).toFixed(2) : undefined, - txn_ledger: ledgerCount ? (txCount / ledgerCount).toFixed(2) : undefined, - ledger_interval: timeCount - ? (time / timeCount / 1000).toFixed(3) - : undefined, - avg_fee: txWithFeesCount - ? (fees / txWithFeesCount).toPrecision(4) - : undefined, - } - } - - // convert to array and sort - organizeChain() { - const { allLedgers } = this.state - return Object.entries(allLedgers) - .map((d) => d[1]) - .sort((a, b) => a.ledger_index - b.ledger_index) - } - - updateMetricsFromServer() { - fetchMetrics().then((metrics) => this.onMetric(metrics)) - } - - updateQuorum() { - const rippledSocket = this.context - fetchQuorum(rippledSocket).then((quorum) => { - const { updateMetrics } = this.props - this.setState((prevState) => { - const metrics = Object.assign(prevState.metrics, { quorum }) - updateMetrics(metrics) - return { metrics } - }) - }) - } - - updateNegativeUNL() { - this.updateQuorum() - const rippledSocket = this.context - fetchNegativeUNL(rippledSocket).then((nUnl) => { - const { updateMetrics } = this.props - this.setState((prevState) => { - const metrics = Object.assign(prevState.metrics, { nUnl }) - updateMetrics(metrics) - return { metrics } - }) - }) - } - - async connect() { - const rippledSocket = this.context - - this.onLedgerWrapper = async (streamResult) => { - if (streamResult.type !== 'ledgerClosed') { - return - } - const { ledger, baseFee } = await this.handleLedger(streamResult) - this.onLedger(ledger) - fetchLedger(ledger, rippledSocket) - .then((ledgerSummary) => { - this.onLedgerSummary(ledgerSummary) - }) - .then(() => { - if (process.env.VITE_ENVIRONMENT === 'custom') { - this.onMetric(this.updateMetrics(baseFee)) - } - }) - .catch((e) => { - Log.error('Ledger fetch error', e.message) - Log.error(e) - }) - // update the load fee - fetchLoadFee(rippledSocket) - .then((loadFee) => { - this.onMetric(loadFee) - }) - .catch((e) => { - Log.error('Ledger fetch error', e.message) - Log.error(e) - }) - // calculate custom network metrics on the frontend - // because there is no backend server connection (since there is no one network) - if (process.env.VITE_ENVIRONMENT === 'custom') { - this.onMetric(this.updateMetrics(baseFee)) - } else { - this.updateMetricsFromServer() - } - } - - rippledSocket.on('ledger', this.onLedgerWrapper) - - this.onValidationWrapper = async (streamResult) => { - const data = await this.handleValidation(streamResult) - if (data) { - this.onValidation(data) - } - } - - rippledSocket.on('validation', this.onValidationWrapper) - } - - render() { - return null - } -} - -Streams.contextType = SocketContext - -Streams.propTypes = { - validators: PropTypes.shape({}), - updateMetrics: PropTypes.func, - updateLedgers: PropTypes.func, - updateValidators: PropTypes.func, -} - -Streams.defaultProps = { - validators: {}, - updateMetrics: () => {}, - updateLedgers: () => {}, - updateValidators: () => {}, -} - -export default Streams diff --git a/src/containers/shared/components/Streams/StreamsContext.tsx b/src/containers/shared/components/Streams/StreamsContext.tsx new file mode 100644 index 000000000..1e4139771 --- /dev/null +++ b/src/containers/shared/components/Streams/StreamsContext.tsx @@ -0,0 +1,14 @@ +import { contextFactory } from '../../helpers' +import { Ledger, Metrics } from './types' +import { StreamValidator } from '../../vhsTypes' + +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..d675381b7 --- /dev/null +++ b/src/containers/shared/components/Streams/StreamsProvider.tsx @@ -0,0 +1,363 @@ +import { FC, useContext, useEffect, useMemo, useRef, useState } from 'react' +import axios from 'axios' +import type { LedgerStream, ValidationStream } from 'xrpl' +import { AnyJson } from 'xrpl-client' +import { useQuery } from 'react-query' +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, LedgerHash, RunningMetrics } from './types' +import { StreamValidator } from '../../vhsTypes' + +const THROTTLE = 200 +const MAX_LEDGERS_SHOWN = 50 + +const fetchMetrics = () => + axios + .get('/api/v1/metrics') + .then((result) => result.data) + .catch((e) => Log.error(e)) + +const fetchNegativeUNL = async (rippledSocket) => + getNegativeUNL(rippledSocket) + .then((data) => { + if (data === undefined) throw new Error('undefined nUNL') + + return data + }) + .catch((e) => { + Log.error(e) + return [] + }) + +const fetchQuorum = async (rippledSocket): Promise => + getQuorum(rippledSocket) + .then((data) => { + if (data === undefined) throw new Error('undefined quorum') + return data + }) + .catch((e) => Log.error(e)) + +const truncateLedgers = (ledgers: Record, count) => + Object.entries(ledgers) + .sort((a, b) => parseInt(b[0], 10) - parseInt(a[0], 10)) + .slice(0, count) + .reduce((accumulator, [index, ledger]) => { + accumulator[index] = ledger + return accumulator + }, {}) + +export const StreamsProvider: FC = ({ children }) => { + // In custom mode we populate metrics from ledgers loaded into memory + const useServerMetrics = process.env.VITE_ENVIRONMENT !== 'custom' + 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 [runningMetrics, setRunningMetrics] = useState({}) + const [loadFee, setLoadFee] = useState() + const { data: quorum, refetch: refetchQuorum } = useQuery( + 'quorum', + () => fetchQuorum(socket), + { enabled: socket.getState().online }, + ) + const { data: nUnl, refetch: refetchNUnl } = useQuery( + 'nUnl', + () => fetchNegativeUNL(socket), + { enabled: socket.getState().online }, + ) + const { data: serverRunningMetrics, refetch: refetchServerRunningMetrics } = + useQuery('runningMetrics', () => fetchMetrics(), { + enabled: socket.getState().online, + }) + + function addLedger(index: number | string) { + // Only add new ledgers that are newer than the last one added. + Log.trace(`Processing ledger index: ${index}`, 'StreamsProvider.addLedger') + if (!firstLedgerRef.current) { + firstLedgerRef.current = Number(index) + } + if (firstLedgerRef.current > Number(index)) { + return + } + + if (!(index in ledgers)) { + setLedgers((previousLedgers) => ({ + [index]: { + index: Number(index), + seen: Date.now(), + hashes: [], + transactions: undefined, + txCount: undefined, + }, + ...truncateLedgers(previousLedgers, MAX_LEDGERS_SHOWN - 1), + })) + } + } + + function updateMetrics() { + const ledgerChain = Object.values(ledgers) + .sort((a, b) => a.index - b.index) + .slice(-MAX_LEDGERS_SHOWN) + + 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 + }) + + setRunningMetrics({ + txn_ledger: ledgerCount ? (txCount / ledgerCount).toFixed(2) : undefined, + txn_sec: time ? ((txCount / time) * 1000).toFixed(2) : undefined, + avg_fee: txWithFeesCount + ? (fees / txWithFeesCount).toPrecision(4) + : undefined, + ledger_interval: timeCount + ? (time / timeCount / 1000).toFixed(3) + : undefined, + }) + } + + function onLedger(data: LedgerStream) { + Log.trace( + `Processing ledger data: ${JSON.stringify(data)}`, + 'StreamsProvider.onLedger', + ) + 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) + } + + useServerMetrics ? refetchServerRunningMetrics() : 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) { + refetchNUnl() + refetchQuorum() + } + + // use ledger_hash to fetch the complete ledger info from the rippled server. + // other fields (like close_time) can be obtained from the response of the `ledger` RPC. + getLedger(socket, { ledger_hash: data.ledger_hash }) + .then(populateFromLedgerResponse) + .catch((error) => { + Log.error(error) + }) + } + + const populateFromLedgerResponse = (ledger: any) => { + Log.trace( + `Processing ledger: ${JSON.stringify(ledger)}`, + 'StreamsProvider.populateFromLedgerResponse', + ) + const ledgerSummary = summarizeLedger(ledger) + let idx: number + // legacy rippled clients (prior to v1.12.0) populate `seqNum` field instead of `ledger_index` + if (!Number.isNaN(Number(ledgerSummary.ledger_index))) { + idx = Number(ledgerSummary.ledger_index) + } else { + idx = Number(ledger.seqNum) + } + if (Number.isNaN(idx)) { + Log.error( + `Unable to typecast ledger-index to number type: ${ledgerSummary.ledger_index}`, + 'StreamsProvider.populateFromLedgerResponse', + ) + return + } + setLedgers((previousLedgers) => { + const newLedger = Object.assign(previousLedgers[idx] ?? { hashes: [] }, { + index: idx, + txCount: ledgerSummary.transactions.length, + closeTime: convertRippleDate(ledgerSummary.ledger_time), + transactions: ledgerSummary.transactions, + // Note: Ledger.totalFees is of `number` (primitive javascript) type, whereas ledgerSummary.total_fees is of `Number` type. + // Both of these types are compatible with each other: https://stackoverflow.com/questions/67155108/what-is-the-difference-between-number-and-number-in-typescript + totalFees: ledgerSummary.total_fees, // fix type + }) + const matchingHashIndex = newLedger?.hashes.findIndex( + (hash) => hash.hash === ledgerSummary.ledger_hash, + ) + const matchingHash = newLedger?.hashes[matchingHashIndex] + if (matchingHash) { + matchingHash.validated = true + } + if (newLedger && matchingHash) { + newLedger.hashes[matchingHashIndex] = { + ...matchingHash, + } + } + + return { ...previousLedgers, [idx]: newLedger } + }) + } + + const onValidation = (data: ValidationStream) => { + Log.trace( + `Processing validation data: ${JSON.stringify(data)}`, + 'StreamsProvider.onValidation', + ) + 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() { + Log.trace( + `Processing validation queue: ${JSON.stringify(validationQueue.current)}`, + 'StreamsProvider.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] as LedgerHash + if (!matchingHash) { + matchingHash = { + hash: validation.ledger_hash, + unselected: false, + 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 (json: AnyJson) => void) + socket.on('validation', onValidation as (json: AnyJson) => void) + + // 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) + .catch((error) => { + Log.error(error) + }) + } + + return () => { + clearTimeout(interval) + if (socket) { + socket.send({ + command: 'unsubscribe', + streams: ['ledger', 'validations'], + }) + socket.off('ledger', onLedger) + socket.off('validation', onValidation) + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- this should only run when the socket is connected or disconnected + }, []) + + useEffect(() => { + ledgersRef.current = ledgers + }, [ledgers]) + + const value = useMemo( + () => ({ + ledgers, + validators, + metrics: { + ...(useServerMetrics ? serverRunningMetrics : runningMetrics), + load_fee: loadFee, + quorum, + nUnl, + }, + }), + [ + useServerMetrics, + ledgers, + runningMetrics, + serverRunningMetrics, + validators, + loadFee, + quorum, + nUnl, + ], + ) + + return ( + {children} + ) +} diff --git a/src/containers/shared/components/Streams/index.ts b/src/containers/shared/components/Streams/index.ts new file mode 100644 index 000000000..4052a9a4d --- /dev/null +++ b/src/containers/shared/components/Streams/index.ts @@ -0,0 +1,3 @@ +export * from './types' +export * from './StreamsContext' +export * from './StreamsProvider' diff --git a/src/containers/shared/components/Streams/types.ts b/src/containers/shared/components/Streams/types.ts new file mode 100644 index 000000000..85d1897b1 --- /dev/null +++ b/src/containers/shared/components/Streams/types.ts @@ -0,0 +1,37 @@ +import type { ValidationStream } from 'xrpl' +import { TransactionSummary } from '../../types' + +export type LedgerValidation = ValidationStream & { partial?: boolean } + +export interface LedgerHash { + hash: string + validated: boolean + unselected: boolean + validations: LedgerValidation[] + time: number + cookie?: string +} + +export interface Ledger { + transactions: TransactionSummary[] + index: number + hashes: LedgerHash[] + seen: number + txCount?: number + closeTime: number + totalFees: number +} + +export interface RunningMetrics { + txn_sec?: string + txn_ledger?: string + ledger_interval?: string + avg_fee?: string +} + +export interface Metrics extends RunningMetrics { + base_fee?: string + load_fee?: string + nUnl?: string[] + quorum?: string +} diff --git a/src/containers/shared/components/VHSValidators/VHSValidatorsContext.tsx b/src/containers/shared/components/VHSValidators/VHSValidatorsContext.tsx new file mode 100644 index 000000000..7cd422a73 --- /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..85b629eff --- /dev/null +++ b/src/containers/shared/components/VHSValidators/VHSValidatorsProvider.tsx @@ -0,0 +1,65 @@ +import { FC, useContext } from 'react' +import { useQuery } from 'react-query' +import axios from 'axios' +import { VHSValidatorsContext } from './VHSValidatorsContext' +import { ValidatorResponse } from '../../vhsTypes' +import Log from '../../log' +import NetworkContext from '../../NetworkContext' +import { VHSValidatorsHookResult } from './types' +import { FETCH_INTERVAL_ERROR_MILLIS, FETCH_INTERVAL_MILLIS } from '../../utils' + +export const VHSValidatorsProvider: FC = ({ children }) => { + const network = useContext(NetworkContext) + + const { data: value } = useQuery( + ['fetchValidatorsData'], + () => fetchVHSData(), + { + refetchInterval: (returnedData, _) => + returnedData == null + ? FETCH_INTERVAL_ERROR_MILLIS + : FETCH_INTERVAL_MILLIS, + 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/index.ts b/src/containers/shared/components/VHSValidators/index.ts new file mode 100644 index 000000000..bfad07ec0 --- /dev/null +++ b/src/containers/shared/components/VHSValidators/index.ts @@ -0,0 +1,3 @@ +export * from './types' +export * from './VHSValidatorsContext' +export * from './VHSValidatorsProvider' 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/helpers/contextFactory.ts b/src/containers/shared/helpers/contextFactory.ts new file mode 100644 index 000000000..fd9f1a80e --- /dev/null +++ b/src/containers/shared/helpers/contextFactory.ts @@ -0,0 +1,24 @@ +import { createContext, useContext } from 'react' + +type NameProps = { + hook: string + provider: string +} + +export const contextFactory = ( + { 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/helpers/index.ts b/src/containers/shared/helpers/index.ts new file mode 100644 index 000000000..7a7bbbbd4 --- /dev/null +++ b/src/containers/shared/helpers/index.ts @@ -0,0 +1 @@ +export * from './contextFactory' diff --git a/src/rippled/lib/rippled.js b/src/rippled/lib/rippled.js index fca22a980..a6775f0ab 100644 --- a/src/rippled/lib/rippled.js +++ b/src/rippled/lib/rippled.js @@ -56,6 +56,9 @@ const getLedger = (rippledSocket, parameters) => { } return query(rippledSocket, request).then((resp) => { + if (!resp) { + throw new Error(`No response from rippled: ${JSON.stringify(resp)}`, 500) + } if (resp.error_message === 'ledgerNotFound') { throw new Error('ledger not found', 404) }
A historic milestone for the XRP Ledger