API stats charts (#7671)
This commit is contained in:
parent
3c7d11ccf3
commit
fed6962ef5
@ -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>
|
||||
|
||||
117
packages/page-explorer/src/Api/index.tsx
Normal file
117
packages/page-explorer/src/Api/index.tsx
Normal 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;
|
||||
}
|
||||
`);
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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]
|
||||
);
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
17
packages/react-components/src/ApiStats/Context.ts
Normal file
17
packages/react-components/src/ApiStats/Context.ts
Normal 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
|
||||
};
|
||||
23
packages/react-components/src/ApiStats/index.tsx
Normal file
23
packages/react-components/src/ApiStats/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
packages/react-components/src/ApiStats/types.ts
Normal file
16
packages/react-components/src/ApiStats/types.ts
Normal 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;
|
||||
}
|
||||
63
packages/react-components/src/ApiStats/useApiStats.ts
Normal file
63
packages/react-components/src/ApiStats/useApiStats.ts
Normal 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);
|
||||
@ -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}
|
||||
|
||||
@ -10,6 +10,7 @@ export interface TabItem {
|
||||
count?: number;
|
||||
hasParams?: boolean;
|
||||
isExact?: boolean;
|
||||
isHidden?: boolean;
|
||||
isRoot?: boolean;
|
||||
name: string;
|
||||
text: React.ReactNode;
|
||||
|
||||
@ -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';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user