From fed6962ef5111865e50483a6368bd5e4020c4a14 Mon Sep 17 00:00:00 2001 From: Jaco Date: Wed, 1 Jun 2022 15:25:08 +0200 Subject: [PATCH] API stats charts (#7671) --- packages/apps/src/Root.tsx | 21 ++-- packages/page-explorer/src/Api/index.tsx | 117 ++++++++++++++++++ .../page-explorer/src/Latency/useLatency.ts | 6 +- packages/page-explorer/src/index.tsx | 11 +- packages/react-api/src/Api.tsx | 53 ++++++-- packages/react-api/src/types.ts | 5 +- .../react-components/src/ApiStats/Context.ts | 17 +++ .../react-components/src/ApiStats/index.tsx | 23 ++++ .../react-components/src/ApiStats/types.ts | 16 +++ .../src/ApiStats/useApiStats.ts | 63 ++++++++++ packages/react-components/src/Tabs/index.tsx | 5 +- packages/react-components/src/Tabs/types.ts | 1 + packages/react-components/src/index.tsx | 1 + 13 files changed, 311 insertions(+), 28 deletions(-) create mode 100644 packages/page-explorer/src/Api/index.tsx create mode 100644 packages/react-components/src/ApiStats/Context.ts create mode 100644 packages/react-components/src/ApiStats/index.tsx create mode 100644 packages/react-components/src/ApiStats/types.ts create mode 100644 packages/react-components/src/ApiStats/useApiStats.ts diff --git a/packages/apps/src/Root.tsx b/packages/apps/src/Root.tsx index 263b68ef6f..f289755b71 100644 --- a/packages/apps/src/Root.tsx +++ b/packages/apps/src/Root.tsx @@ -9,6 +9,7 @@ import { HashRouter } from 'react-router-dom'; import { ThemeProvider } from 'styled-components'; import { Api } from '@polkadot/react-api'; +import { ApiStats } from '@polkadot/react-components'; import Queue from '@polkadot/react-components/Status/Queue'; import { BlockAuthors, Events } from '@polkadot/react-query'; import { settings } from '@polkadot/ui-settings'; @@ -49,15 +50,17 @@ function Root ({ isElectron, store }: Props): React.ReactElement { isElectron={isElectron} store={store} > - - - - - - - - - + + + + + + + + + + + diff --git a/packages/page-explorer/src/Api/index.tsx b/packages/page-explorer/src/Api/index.tsx new file mode 100644 index 0000000000..bf19380852 --- /dev/null +++ b/packages/page-explorer/src/Api/index.tsx @@ -0,0 +1,117 @@ +// Copyright 2017-2022 @polkadot/app-explorer authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { Stats } from '@polkadot/react-components/ApiStats/types'; + +import React, { useContext, useMemo } from 'react'; +import styled from 'styled-components'; + +import { ApiStatsContext, CardSummary, Chart, Spinner, SummaryBox } from '@polkadot/react-components'; +import { formatNumber } from '@polkadot/util'; + +import { useTranslation } from '../translate'; + +interface Props { + className?: string; +} + +interface ChartContents { + labels: string[]; + values: number[][]; +} + +interface ChartInfo { + requests: ChartContents; +} + +const COLORS_REQUESTS = ['#008c8c', '#00448c', '#8c0044', '#acacac']; +const OPTIONS = { + animation: { + duration: 0 + }, + aspectRatio: 6, + maintainAspectRatio: true +}; + +function getPoints (all: Stats[]): ChartInfo { + const requests: ChartContents = { + labels: [], + values: [[], [], [], []] + }; + + const reqBase = all.reduce((a, { stats: { active: { requests, subscriptions } } }) => a + requests + subscriptions, 0); + + for (let i = 0; i < all.length; i++) { + const { stats: { active: { requests: aReq, subscriptions: aSub } }, when } = all[i]; + + const time = new Date(when).toLocaleTimeString(); + + requests.labels.push(time); + requests.values[0].push(aReq + aSub); + requests.values[1].push(aReq); + requests.values[2].push(aSub); + requests.values[3].push(reqBase / all.length); + } + + return { requests }; +} + +function Api ({ className }: Props): React.ReactElement { + const { t } = useTranslation(); + const stats = useContext(ApiStatsContext); + + const { requestsLegend } = useMemo( + () => ({ + requestsLegend: [t('total'), t('requests'), t('subscriptions'), t('average')] + }), [t] + ); + + const { requests } = useMemo( + () => getPoints(stats), + [stats] + ); + + if (stats.length <= 2) { + return ; + } + + const { stats: { total: { bytesRecv, bytesSent, requests: tReq, subscriptions: tSub } } } = stats[stats.length - 1]; + + return ( +
+ +
+ ('sent')}>{formatNumber(bytesSent / 1024)}kB + ('recv')}>{formatNumber(bytesRecv / 1024)}kB +
+
+ ('total req')}>{formatNumber(tReq)} + ('total sub')}>{formatNumber(tSub)} +
+
+
+

{t('requests & subscriptions')}

+ +
+
+ ); +} + +export default React.memo(styled(Api)` + .container { + background: var(--bg-table); + border: 1px solid var(--border-table); + border-radius: 0.25rem; + padding: 1rem 1.5rem; + } + + .container+.container { + margin-top: 1rem; + } +`); diff --git a/packages/page-explorer/src/Latency/useLatency.ts b/packages/page-explorer/src/Latency/useLatency.ts index a5e6c66939..2287e841b7 100644 --- a/packages/page-explorer/src/Latency/useLatency.ts +++ b/packages/page-explorer/src/Latency/useLatency.ts @@ -9,7 +9,7 @@ import type { Detail, Result } from './types'; import { useEffect, useMemo, useRef, useState } from 'react'; -import { useApi, useCall } from '@polkadot/react-hooks'; +import { createNamedHook, useApi, useCall } from '@polkadot/react-hooks'; const INITIAL_ITEMS = 50; const MAX_ITEMS = INITIAL_ITEMS; @@ -114,7 +114,7 @@ async function getNext (api: ApiPromise, { block: { number: start } }: Detail, { return getBlocks(api, blockNumbers); } -export default function useLatency (): Result { +function useLatencyImpl (): Result { const { api } = useApi(); const [details, setDetails] = useState([]); const signedBlock = useCall(api.derive.chain.subscribeNewBlocks, []); @@ -178,3 +178,5 @@ export default function useLatency (): Result { }; }, [details]); } + +export default createNamedHook('useLatency', useLatencyImpl); diff --git a/packages/page-explorer/src/index.tsx b/packages/page-explorer/src/index.tsx index 92d3521459..dc0fbc0599 100644 --- a/packages/page-explorer/src/index.tsx +++ b/packages/page-explorer/src/index.tsx @@ -11,7 +11,9 @@ import { Route, Switch } from 'react-router'; import { Tabs } from '@polkadot/react-components'; import { useApi } from '@polkadot/react-hooks'; import { BlockAuthorsContext, EventsContext } from '@polkadot/react-query'; +import { isFunction } from '@polkadot/util'; +import Api from './Api'; import BlockInfo from './BlockInfo'; import Forks from './Forks'; import Latency from './Latency'; @@ -27,6 +29,7 @@ interface Props { function createPathRef (basePath: string): Record { return { + api: `${basePath}/api`, forks: `${basePath}/forks`, latency: `${basePath}/latency`, node: `${basePath}/node`, @@ -60,6 +63,11 @@ function createItemsRef (t: TFunction): TabItem[] { { name: 'node', text: t('Node info') + }, + { + isHidden: true, + name: 'api', + text: t('API stats') } ]; } @@ -71,7 +79,7 @@ function ExplorerApp ({ basePath, className }: Props): React.ReactElement const { eventCount, events } = useContext(EventsContext); const itemsRef = useRef(createItemsRef(t)); const pathRef = useRef(createPathRef(basePath)); - const hidden = useState(() => api.query.babe ? [] : ['forks']); + const hidden = useState(() => isFunction(api.query.babe?.authorities) ? [] : ['forks']); return (
@@ -81,6 +89,7 @@ function ExplorerApp ({ basePath, className }: Props): React.ReactElement items={itemsRef.current} /> + diff --git a/packages/react-api/src/Api.tsx b/packages/react-api/src/Api.tsx index 25d4781066..34a275ce47 100644 --- a/packages/react-api/src/Api.tsx +++ b/packages/react-api/src/Api.tsx @@ -3,7 +3,7 @@ import type { LinkOption } from '@polkadot/apps-config/endpoints/types'; import type { InjectedExtension } from '@polkadot/extension-inject/types'; -import type { ProviderInterface } from '@polkadot/rpc-provider/types'; +import type { ProviderInterface, ProviderStats } from '@polkadot/rpc-provider/types'; import type { ChainProperties, ChainType } from '@polkadot/types/interfaces'; import type { KeyringStore } from '@polkadot/ui-keyring/types'; import type { ApiProps, ApiState } from './types'; @@ -110,6 +110,44 @@ function createLink (baseApiUrl: string, isElectron: boolean): (path: string) => }?rpc=${encodeURIComponent(apiUrl || baseApiUrl)}#${path}`; } +function getStats (...apis: ApiPromise[]): [ProviderStats, number] { + const stats = apis.reduce((r, api) => { + if (api) { + const stats = api.stats; + + if (stats) { + r.active.requests += stats.active.requests; + r.active.subscriptions += stats.active.subscriptions; + r.total.bytesRecv += stats.total.bytesRecv; + r.total.bytesSent += stats.total.bytesSent; + r.total.cached += stats.total.cached; + r.total.errors += stats.total.errors; + r.total.requests += stats.total.requests; + r.total.subscriptions += stats.total.subscriptions; + r.total.timeout += stats.total.timeout; + } + } + + return r; + }, { + active: { + requests: 0, + subscriptions: 0 + }, + total: { + bytesRecv: 0, + bytesSent: 0, + cached: 0, + errors: 0, + requests: 0, + subscriptions: 0, + timeout: 0 + } + }); + + return [stats, Date.now()]; +} + async function retrieve (api: ApiPromise, injectedPromise: Promise): Promise { const [systemChain, systemChainType, systemName, systemVersion, injectedAccounts] = await Promise.all([ api.rpc.system.chain(), @@ -239,17 +277,6 @@ async function createApi (apiUrl: string, signer: ApiSigner, onError: (error: un await providers[i].connect(); } } - - // DEBUG Overall provider stats logs. In a perfect world we would like to - // expose this "somewhere", but it is mostly useful on a per-page basis, - // instead in some corner where it has no active queries - // setInterval( - // () => console.log(JSON.stringify({ - // api: api.stats, - // sys: apiSystem.stats - // }, null, 2)), - // 5000 - // ); } catch (error) { onError(error); } @@ -273,7 +300,7 @@ function Api ({ apiUrl, children, isElectron, store }: Props): React.ReactElemen ); const apiRelay = useApiUrl(relayUrls); const value = useMemo( - () => objectSpread({}, state, { api, apiEndpoint, apiError, apiRelay, apiSystem, apiUrl, createLink: createLink(apiUrl, isElectron), extensions, isApiConnected, isApiInitialized, isElectron, isWaitingInjected: !extensions }), + () => objectSpread({}, state, { api, apiEndpoint, apiError, apiRelay, apiSystem, apiUrl, createLink: createLink(apiUrl, isElectron), extensions, getStats, isApiConnected, isApiInitialized, isElectron, isWaitingInjected: !extensions }), [apiError, extensions, isApiConnected, isApiInitialized, isElectron, state, apiEndpoint, apiRelay, apiUrl] ); diff --git a/packages/react-api/src/types.ts b/packages/react-api/src/types.ts index 21c200d7cc..97462c78f9 100644 --- a/packages/react-api/src/types.ts +++ b/packages/react-api/src/types.ts @@ -1,11 +1,11 @@ // Copyright 2017-2022 @polkadot/react-api authors & contributors // SPDX-License-Identifier: Apache-2.0 +import type { ApiPromise } from '@polkadot/api'; import type { SubmittableExtrinsicFunction } from '@polkadot/api/promise/types'; import type { LinkOption } from '@polkadot/apps-config/settings/types'; import type { InjectedExtension } from '@polkadot/extension-inject/types'; - -import { ApiPromise } from '@polkadot/api'; +import type { ProviderStats } from '@polkadot/rpc-provider/types'; // helpers for HOC props export type OmitProps = Pick>; @@ -39,6 +39,7 @@ export interface ApiProps extends ApiState { apiUrl?: string; createLink: (path: string, apiUrl?: string) => string; extensions?: InjectedExtension[]; + getStats: (...apis: ApiPromise[]) => [ProviderStats, number]; isApiConnected: boolean; isApiInitialized: boolean; isElectron: boolean; diff --git a/packages/react-components/src/ApiStats/Context.ts b/packages/react-components/src/ApiStats/Context.ts new file mode 100644 index 0000000000..d41e712291 --- /dev/null +++ b/packages/react-components/src/ApiStats/Context.ts @@ -0,0 +1,17 @@ +// Copyright 2017-2022 @polkadot/react-components authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { Stats } from './types'; + +import React from 'react'; + +const ApiStatsContext: React.Context = React.createContext([]); +const ApiStatsConsumer: React.Consumer = ApiStatsContext.Consumer; +const ApiStatsProvider: React.Provider = ApiStatsContext.Provider; + +export default ApiStatsContext; + +export { + ApiStatsConsumer, + ApiStatsProvider +}; diff --git a/packages/react-components/src/ApiStats/index.tsx b/packages/react-components/src/ApiStats/index.tsx new file mode 100644 index 0000000000..ef69ee3fd6 --- /dev/null +++ b/packages/react-components/src/ApiStats/index.tsx @@ -0,0 +1,23 @@ +// Copyright 2017-2022 @polkadot/react-components authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; + +import ApiStatsContext, { ApiStatsProvider } from './Context'; +import useApiStats from './useApiStats'; + +export { ApiStatsContext }; + +interface Props { + children?: React.ReactNode; +} + +export default function ApiStats ({ children }: Props): React.ReactElement { + const stats = useApiStats(); + + return ( + + {children} + + ); +} diff --git a/packages/react-components/src/ApiStats/types.ts b/packages/react-components/src/ApiStats/types.ts new file mode 100644 index 0000000000..4047d5be6b --- /dev/null +++ b/packages/react-components/src/ApiStats/types.ts @@ -0,0 +1,16 @@ +// Copyright 2017-2022 @polkadot/react-components authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { ProviderStats } from '@polkadot/rpc-provider/types'; + +export interface StatsExtended extends ProviderStats { + max: { + requests: number; + subscriptions: number; + }; +} + +export interface Stats { + stats: StatsExtended; + when: number; +} diff --git a/packages/react-components/src/ApiStats/useApiStats.ts b/packages/react-components/src/ApiStats/useApiStats.ts new file mode 100644 index 0000000000..1814540a8d --- /dev/null +++ b/packages/react-components/src/ApiStats/useApiStats.ts @@ -0,0 +1,63 @@ +// Copyright 2017-2022 @polkadot/react-components authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { Stats } from './types'; + +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { createNamedHook, useApi } from '@polkadot/react-hooks'; + +const MAX_NUM = 100; +const INTERVAL = 5_000; + +function useApiStatsImpl (): Stats[] { + const { api, apiSystem, getStats } = useApi(); + const [stats, setStats] = useState([]); + const timerId = useRef(null); + + const fireTimer = useCallback( + (): void => { + timerId.current = null; + + const [stats, when] = getStats(api, apiSystem); + + setStats((prev): Stats[] => { + if (prev.length === 0) { + return [{ stats: { ...stats, max: { requests: stats.active.requests, subscriptions: stats.active.subscriptions } }, when }]; + } + + const last = prev[prev.length - 1].stats; + const curr = { + stats: { + ...stats, + max: { + requests: Math.max(stats.active.requests, last.max.requests), + subscriptions: Math.max(stats.active.subscriptions, last.max.subscriptions) + } + }, + when + }; + + return prev.length === MAX_NUM + ? prev.concat(curr).slice(-MAX_NUM) + : prev.concat(curr); + }); + + timerId.current = setTimeout(() => fireTimer(), INTERVAL); + }, + [api, apiSystem, getStats, timerId] + ); + + useEffect((): () => void => { + fireTimer(); + + return (): void => { + timerId.current && clearTimeout(timerId.current); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return stats; +} + +export default createNamedHook('useApiStats', useApiStatsImpl); diff --git a/packages/react-components/src/Tabs/index.tsx b/packages/react-components/src/Tabs/index.tsx index 4b19e2078d..e1871b7708 100644 --- a/packages/react-components/src/Tabs/index.tsx +++ b/packages/react-components/src/Tabs/index.tsx @@ -63,7 +63,10 @@ function Tabs ({ basePath, className = '', hidden, items }: Props): React.ReactE
    {filtered.map((tab, index) => ( -
  • +