diff --git a/packages/page-explorer/src/Latency/index.tsx b/packages/page-explorer/src/Latency/index.tsx new file mode 100644 index 0000000000..e57c162bd6 --- /dev/null +++ b/packages/page-explorer/src/Latency/index.tsx @@ -0,0 +1,89 @@ +// Copyright 2017-2022 @polkadot/app-explorer authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { Detail } from './types'; + +import React, { useMemo } from 'react'; +import styled from 'styled-components'; + +import { CardSummary, Chart, Spinner, SummaryBox } from '@polkadot/react-components'; +import { formatNumber } from '@polkadot/util'; + +import { useTranslation } from '../translate'; +import useLatency from './useLatency'; + +interface Props { + className?: string; +} + +interface ChartInfo { + labels: string[]; + values: number[][]; +} + +const COLORS = ['#8c2200']; +const LEGENDS: string[] = []; +const OPTIONS = { + animation: { + duration: 0 + }, + aspectRatio: 3, + maintainAspectRatio: false +}; + +function getPoints (details: Detail[]): ChartInfo { + const labels = new Array(details.length); + const times = new Array(details.length); + + for (let i = 0; i < details.length; i++) { + labels[i] = formatNumber(details[i].blockNumber); + times[i] = details[i].delay / 1000; + } + + return { labels, values: [times] }; +} + +function Latency ({ className }: Props): React.ReactElement | null { + const { t } = useTranslation(); + const { details, timeAvg, timeMax, timeMin } = useLatency(); + + const { labels, values } = useMemo( + () => getPoints(details), + [details] + ); + + if (details.length <= 2) { + return ( + + ); + } + + return ( +
+ + ('min')}>{(timeMin / 1000).toFixed(3)}s + ('avg')}>{(timeAvg / 1000).toFixed(3)}s + ('max')}>{(timeMax / 1000).toFixed(3)}s + +
+

{t('blocktimes (last {{num}} blocks)', { replace: { num: labels.length } })}

+ +
+
+ ); +} + +export default styled(Latency)` + .container { + background: var(--bg-table); + border: 1px solid var(--border-table); + border-radius: 0.25rem; + padding: 1rem 1.5rem; + } +`; diff --git a/packages/page-explorer/src/Latency/types.ts b/packages/page-explorer/src/Latency/types.ts new file mode 100644 index 0000000000..d848fc6904 --- /dev/null +++ b/packages/page-explorer/src/Latency/types.ts @@ -0,0 +1,20 @@ +// Copyright 2017-2022 @polkadot/app-explorer authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { Hash } from '@polkadot/types/interfaces'; + +export interface Detail { + blockNumber: number; + countEvents: number; + countExtrinsics: number; + delay: number; + now: number; + parentHash: Hash; +} + +export interface Result { + details: Detail[]; + timeAvg: number; + timeMax: number; + timeMin: number; +} diff --git a/packages/page-explorer/src/Latency/useLatency.ts b/packages/page-explorer/src/Latency/useLatency.ts new file mode 100644 index 0000000000..1255d7408e --- /dev/null +++ b/packages/page-explorer/src/Latency/useLatency.ts @@ -0,0 +1,149 @@ +// Copyright 2017-2022 @polkadot/app-explorer authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { ApiPromise } from '@polkadot/api'; +import type { SignedBlockExtended } from '@polkadot/api-derive/types'; +import type { GenericExtrinsic, u32 } from '@polkadot/types'; +import type { Block } from '@polkadot/types/interfaces'; +import type { Detail, Result } from './types'; + +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { useApi, useCall } from '@polkadot/react-hooks'; + +const INITIAL_ITEMS = 50; +const MAX_ITEMS = INITIAL_ITEMS; + +function getSetter ({ extrinsics }: Block): GenericExtrinsic | undefined { + return extrinsics.find(({ method: { method, section } }) => + method === 'set' && + section === 'timestamp' + ); +} + +function extractNext (prev: Detail[], { block, events }: SignedBlockExtended): Detail[] { + const setter = getSetter(block); + + if (!setter) { + return prev; + } + + const now = (setter.args[0] as u32).toNumber(); + const blockNumber = block.header.number.toNumber(); + let delay = 0; + + if (prev.length) { + if (blockNumber <= prev[prev.length - 1].blockNumber) { + return prev; + } + + delay = now - prev[prev.length - 1].now; + } + + return [ + ...prev, + { + blockNumber, + countEvents: events.length, + countExtrinsics: block.extrinsics.length, + delay, + now, + parentHash: block.header.parentHash + } + ].slice(-MAX_ITEMS); +} + +function extractPrev (prev: Detail[], { block, events }: SignedBlockExtended): Detail[] { + const setter = getSetter(block); + + if (!setter) { + return prev; + } + + const now = (setter.args[0] as u32).toNumber(); + + prev[0].delay = prev[0].now - now; + + return [ + { + blockNumber: block.header.number.toNumber(), + countEvents: events.length, + countExtrinsics: block.extrinsics.length, + delay: 0, + now, + parentHash: block.header.parentHash + }, + ...prev + ]; +} + +async function getPrev (api: ApiPromise, { block: { header } }: SignedBlockExtended): Promise { + const blockNumbers: number[] = []; + let blockNumber = header.number.toNumber() - 1; + + for (let i = 1; blockNumber > 0 && i <= INITIAL_ITEMS; i++) { + blockNumbers.push(blockNumber); + blockNumber--; + } + + if (!blockNumbers.length) { + return []; + } + + const blocks = await Promise.all( + blockNumbers.map((n) => api.derive.chain.getBlockByNumber(n)) + ); + + return blocks.filter((b): b is SignedBlockExtended => !!b); +} + +export default function useLatency (): Result { + const { api } = useApi(); + const [details, setDetails] = useState([]); + const signedBlock = useCall(api.derive.chain.subscribeNewBlocks, []); + const hasHistoric = useRef(false); + + useEffect((): void => { + if (!signedBlock) { + return; + } + + setDetails((prev) => + extractNext(prev, signedBlock) + ); + + if (hasHistoric.current) { + return; + } + + hasHistoric.current = true; + + getPrev(api, signedBlock) + .then((blocks) => { + if (!blocks.length) { + return; + } + + setDetails((prev) => + blocks + .reduce((p, b) => extractPrev(p, b), prev) + .filter(({ delay }) => delay) + ); + }) + .catch(console.error); + }, [api, signedBlock]); + + return useMemo((): Result => { + const filtered = details.filter(({ delay }) => delay); + const delays = filtered.map(({ delay }) => delay); + + return filtered.length + ? { + details: filtered, + timeAvg: delays.reduce((avg, d) => avg + (d / delays.length), 0), + timeMax: Math.max(...delays), + timeMin: Math.min(...delays) + } + : { details: [], timeAvg: 0, timeMax: 0, timeMin: 0 }; + }, [details]); +} diff --git a/packages/page-explorer/src/index.tsx b/packages/page-explorer/src/index.tsx index d71f17deea..af9063e035 100644 --- a/packages/page-explorer/src/index.tsx +++ b/packages/page-explorer/src/index.tsx @@ -12,6 +12,7 @@ import { BlockAuthorsContext, EventsContext } from '@polkadot/react-query'; import BlockInfo from './BlockInfo'; import Forks from './Forks'; +import Latency from './Latency'; import Main from './Main'; import NodeInfo from './NodeInfo'; import { useTranslation } from './translate'; @@ -39,6 +40,10 @@ function ExplorerApp ({ basePath, className }: Props): React.ReactElement name: 'query', text: t('Block details') }, + { + name: 'latency', + text: t('Latency') + }, { name: 'forks', text: t('Forks') @@ -63,6 +68,7 @@ function ExplorerApp ({ basePath, className }: Props): React.ReactElement /> + diff --git a/packages/react-components/src/Chart/Line.tsx b/packages/react-components/src/Chart/Line.tsx index b44a123b48..7974af5acd 100644 --- a/packages/react-components/src/Chart/Line.tsx +++ b/packages/react-components/src/Chart/Line.tsx @@ -8,7 +8,7 @@ import ChartJs from 'chart.js'; import React, { useMemo } from 'react'; import * as Chart from 'react-chartjs-2'; -import { isBn } from '@polkadot/util'; +import { isBn, objectSpread } from '@polkadot/util'; interface State { chartData: ChartJs.ChartData; @@ -49,7 +49,7 @@ const chartOptions = { } }; -function calculateOptions (colors: (string | undefined)[] = [], legends: string[], labels: string[], values: (number | BN)[][]): State { +function calculateOptions (colors: (string | undefined)[] = [], legends: string[], labels: string[], values: (number | BN)[][], options: ChartJs.ChartOptions = {}): State { const chartData = values.reduce((chartData, values, index): Config => { const color = colors[index] || alphaColor(COLORS[index]); const data = values.map((value): number => isBn(value) ? value.toNumber() : value); @@ -68,14 +68,14 @@ function calculateOptions (colors: (string | undefined)[] = [], legends: string[ return { chartData, - chartOptions + chartOptions: objectSpread({}, chartOptions, options) }; } -function LineChart ({ className, colors, labels, legends, values }: LineProps): React.ReactElement | null { +function LineChart ({ className, colors, labels, legends, options, values }: LineProps): React.ReactElement | null { const { chartData, chartOptions } = useMemo( - () => calculateOptions(colors, legends, labels, values), - [colors, labels, legends, values] + () => calculateOptions(colors, legends, labels, values, options), + [colors, labels, legends, options, values] ); return ( diff --git a/packages/react-components/src/Chart/types.ts b/packages/react-components/src/Chart/types.ts index 89df323a0c..71ed9f9b94 100644 --- a/packages/react-components/src/Chart/types.ts +++ b/packages/react-components/src/Chart/types.ts @@ -3,6 +3,8 @@ import type { BN } from '@polkadot/util'; +import ChartJs from 'chart.js'; + export interface DoughnutValue { colors: string[]; label: string; @@ -41,5 +43,6 @@ export interface LineProps { className?: string; labels: string[]; legends: string[]; + options?: ChartJs.ChartOptions; values: (number | BN)[][]; }