API stats charts (#7671)

This commit is contained in:
Jaco 2022-06-01 15:25:08 +02:00 committed by GitHub
parent 3c7d11ccf3
commit fed6962ef5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 311 additions and 28 deletions

View File

@ -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<Props> {
isElectron={isElectron}
store={store}
>
<BlockAuthors>
<Events>
<HashRouter>
<WindowDimensions>
<Apps />
</WindowDimensions>
</HashRouter>
</Events>
</BlockAuthors>
<ApiStats>
<BlockAuthors>
<Events>
<HashRouter>
<WindowDimensions>
<Apps />
</WindowDimensions>
</HashRouter>
</Events>
</BlockAuthors>
</ApiStats>
</Api>
</Queue>
</ThemeProvider>

View File

@ -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<Props> {
const { t } = useTranslation();
const stats = useContext(ApiStatsContext);
const { requestsLegend } = useMemo(
() => ({
requestsLegend: [t<string>('total'), t<string>('requests'), t<string>('subscriptions'), t<string>('average')]
}), [t]
);
const { requests } = useMemo(
() => getPoints(stats),
[stats]
);
if (stats.length <= 2) {
return <Spinner />;
}
const { stats: { total: { bytesRecv, bytesSent, requests: tReq, subscriptions: tSub } } } = stats[stats.length - 1];
return (
<div className={className}>
<SummaryBox>
<section>
<CardSummary label={t<string>('sent')}>{formatNumber(bytesSent / 1024)}kB</CardSummary>
<CardSummary label={t<string>('recv')}>{formatNumber(bytesRecv / 1024)}kB</CardSummary>
</section>
<section>
<CardSummary label={t<string>('total req')}>{formatNumber(tReq)}</CardSummary>
<CardSummary label={t<string>('total sub')}>{formatNumber(tSub)}</CardSummary>
</section>
</SummaryBox>
<div className='container'>
<h1>{t<string>('requests & subscriptions')}</h1>
<Chart.Line
colors={COLORS_REQUESTS}
labels={requests.labels}
legends={requestsLegend}
options={OPTIONS}
values={requests.values}
/>
</div>
</div>
);
}
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;
}
`);

View File

@ -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<Detail[]>([]);
const signedBlock = useCall<SignedBlockExtended>(api.derive.chain.subscribeNewBlocks, []);
@ -178,3 +178,5 @@ export default function useLatency (): Result {
};
}, [details]);
}
export default createNamedHook('useLatency', useLatencyImpl);

View File

@ -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<string, string | string[]> {
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<string>('Node info')
},
{
isHidden: true,
name: 'api',
text: t<string>('API stats')
}
];
}
@ -71,7 +79,7 @@ function ExplorerApp ({ basePath, className }: Props): React.ReactElement<Props>
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 (
<main className={className}>
@ -81,6 +89,7 @@ function ExplorerApp ({ basePath, className }: Props): React.ReactElement<Props>
items={itemsRef.current}
/>
<Switch>
<Route path={pathRef.current.api}><Api /></Route>
<Route path={pathRef.current.forks}><Forks /></Route>
<Route path={pathRef.current.latency}><Latency /></Route>
<Route path={pathRef.current.query}><BlockInfo /></Route>

View File

@ -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<ProviderStats>((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<InjectedExtension[]>): Promise<ChainData> {
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<ApiProps>(
() => 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]
);

View File

@ -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<T, K> = Pick<T, Exclude<keyof T, K>>;
@ -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;

View File

@ -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<Stats[]> = React.createContext<Stats[]>([]);
const ApiStatsConsumer: React.Consumer<Stats[]> = ApiStatsContext.Consumer;
const ApiStatsProvider: React.Provider<Stats[]> = ApiStatsContext.Provider;
export default ApiStatsContext;
export {
ApiStatsConsumer,
ApiStatsProvider
};

View File

@ -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<Props> {
const stats = useApiStats();
return (
<ApiStatsProvider value={stats}>
{children}
</ApiStatsProvider>
);
}

View File

@ -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;
}

View File

@ -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<Stats[]>([]);
const timerId = useRef<NodeJS.Timeout | null>(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);

View File

@ -63,7 +63,10 @@ function Tabs ({ basePath, className = '', hidden, items }: Props): React.ReactE
<Delimiter />
<ul className='ui--TabsList'>
{filtered.map((tab, index) => (
<li key={index}>
<li
className={tab.isHidden ? '--hidden' : ''}
key={index}
>
<Tab
{...tab}
basePath={basePath}

View File

@ -10,6 +10,7 @@ export interface TabItem {
count?: number;
hasParams?: boolean;
isExact?: boolean;
isHidden?: boolean;
isRoot?: boolean;
name: string;
text: React.ReactNode;

View File

@ -10,6 +10,7 @@ export { default as AddressMini } from './AddressMini';
export { default as AddressRow } from './AddressRow';
export { default as AddressSmall } from './AddressSmall';
export { default as AddressToggle } from './AddressToggle';
export { default as ApiStats, ApiStatsContext } from './ApiStats';
export { default as Available } from './Available';
export { default as AvatarItem } from './AvatarItem';
export { default as Badge } from './Badge';