chore(trading): display latency in block height progressing as warn or error (#3169)

This commit is contained in:
Maciek 2023-03-15 12:49:31 +01:00 committed by GitHub
parent 67e602ebb1
commit da7c0b84f7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 185 additions and 110 deletions

View File

@ -1,20 +1,30 @@
import { render, screen } from '@testing-library/react'; import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { NodeHealth, NodeUrl, HealthIndicator } from './footer'; import { NodeHealth, NodeUrl, HealthIndicator } from './footer';
import { MockedProvider } from '@apollo/client/testing';
import { Intent } from '@vegaprotocol/ui-toolkit';
jest.mock('@vegaprotocol/environment', () => ({
...jest.requireActual('@vegaprotocol/environment'),
useEnvironment: jest
.fn()
.mockImplementation(() => ({ VEGA_URL: 'https://vega-url.wtf' })),
}));
const mockSetNodeSwitcher = jest.fn();
jest.mock('../../stores', () => ({
...jest.requireActual('../../stores'),
useGlobalStore: () => mockSetNodeSwitcher,
}));
describe('NodeHealth', () => { describe('NodeHealth', () => {
it('controls the node switcher dialog', async () => { it('controls the node switcher dialog', async () => {
const mockOnClick = jest.fn(); render(<NodeHealth />, { wrapper: MockedProvider });
render( await waitFor(() => {
<NodeHealth expect(screen.getByRole('button')).toBeInTheDocument();
onClick={mockOnClick} });
url={'https://api.n99.somenetwork.vega.xyz'}
blockHeight={100}
blockDiff={0}
/>
);
await userEvent.click(screen.getByRole('button')); await userEvent.click(screen.getByRole('button'));
expect(mockOnClick).toHaveBeenCalled(); expect(mockSetNodeSwitcher).toHaveBeenCalled();
}); });
}); });
@ -31,14 +41,22 @@ describe('NodeUrl', () => {
describe('HealthIndicator', () => { describe('HealthIndicator', () => {
const cases = [ const cases = [
{ diff: 0, classname: 'bg-vega-green-550', text: 'Operational' }, {
{ diff: 5, classname: 'bg-warning', text: '5 Blocks behind' }, intent: Intent.Success,
{ diff: null, classname: 'bg-danger', text: 'Non operational' }, text: 'Operational',
classname: 'bg-vega-green-550',
},
{
intent: Intent.Warning,
text: '5 Blocks behind',
classname: 'bg-warning',
},
{ intent: Intent.Danger, text: 'Non operational', classname: 'bg-danger' },
]; ];
it.each(cases)( it.each(cases)(
'renders correct text and indicator color for $diff block difference', 'renders correct text and indicator color for $diff block difference',
(elem) => { (elem) => {
render(<HealthIndicator blockDiff={elem.diff} />); render(<HealthIndicator text={elem.text} intent={elem.intent} />);
expect(screen.getByTestId('indicator')).toHaveClass(elem.classname); expect(screen.getByTestId('indicator')).toHaveClass(elem.classname);
expect(screen.getByText(elem.text)).toBeInTheDocument(); expect(screen.getByText(elem.text)).toBeInTheDocument();
} }

View File

@ -1,60 +1,45 @@
import { useCallback } from 'react';
import { useEnvironment, useNodeHealth } from '@vegaprotocol/environment'; import { useEnvironment, useNodeHealth } from '@vegaprotocol/environment';
import { useNavigatorOnline } from '@vegaprotocol/react-helpers';
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import { Indicator, Intent } from '@vegaprotocol/ui-toolkit'; import type { Intent } from '@vegaprotocol/ui-toolkit';
import { Indicator } from '@vegaprotocol/ui-toolkit';
import classNames from 'classnames'; import classNames from 'classnames';
import type { ButtonHTMLAttributes, ReactNode } from 'react'; import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { useGlobalStore } from '../../stores'; import { useGlobalStore } from '../../stores';
export const Footer = () => { export const Footer = () => {
const { VEGA_URL } = useEnvironment();
const setNodeSwitcher = useGlobalStore(
(store) => (open: boolean) => store.update({ nodeSwitcherDialog: open })
);
const { blockDiff, datanodeBlockHeight } = useNodeHealth();
return ( return (
<footer className="px-4 py-1 text-xs border-t border-default text-vega-light-300 dark:text-vega-dark-300 lg:fixed bottom-0 left-0 border-r bg-white dark:bg-black"> <footer className="px-4 py-1 text-xs border-t border-default text-vega-light-300 dark:text-vega-dark-300 lg:fixed bottom-0 left-0 border-r bg-white dark:bg-black">
{/* Pull left to align with top nav, due to button padding */} {/* Pull left to align with top nav, due to button padding */}
<div className="-ml-2"> <div className="-ml-2">
{VEGA_URL && ( <NodeHealth />
<NodeHealth
url={VEGA_URL}
blockHeight={datanodeBlockHeight}
blockDiff={blockDiff}
onClick={() => setNodeSwitcher(true)}
/>
)}
</div> </div>
</footer> </footer>
); );
}; };
interface NodeHealthProps {
url: string;
blockHeight: number | undefined;
blockDiff: number | null;
onClick: () => void;
}
export const NodeHealth = ({ export const NodeHealth = () => {
url, const { VEGA_URL } = useEnvironment();
blockHeight, const setNodeSwitcher = useGlobalStore(
blockDiff, (store) => (open: boolean) => store.update({ nodeSwitcherDialog: open })
onClick, );
}: NodeHealthProps) => { const { datanodeBlockHeight, text, intent } = useNodeHealth();
return ( const onClick = useCallback(() => {
setNodeSwitcher(true);
}, [setNodeSwitcher]);
return VEGA_URL ? (
<FooterButton onClick={onClick} data-testid="node-health"> <FooterButton onClick={onClick} data-testid="node-health">
<FooterButtonPart> <FooterButtonPart>
<HealthIndicator blockDiff={blockDiff} /> <HealthIndicator text={text} intent={intent} />
</FooterButtonPart> </FooterButtonPart>
<FooterButtonPart> <FooterButtonPart>
<NodeUrl url={url} /> <NodeUrl url={VEGA_URL} />
</FooterButtonPart> </FooterButtonPart>
<FooterButtonPart> <FooterButtonPart>
<span title={t('Block height')}>{blockHeight}</span> <span title={t('Block height')}>{datanodeBlockHeight}</span>
</FooterButtonPart> </FooterButtonPart>
</FooterButton> </FooterButton>
); ) : null;
}; };
interface NodeUrlProps { interface NodeUrlProps {
@ -69,31 +54,11 @@ export const NodeUrl = ({ url }: NodeUrlProps) => {
}; };
interface HealthIndicatorProps { interface HealthIndicatorProps {
blockDiff: number | null; text: string;
intent: Intent;
} }
// How many blocks behind the most advanced block that is export const HealthIndicator = ({ text, intent }: HealthIndicatorProps) => {
// deemed acceptable for "Good" status
const BLOCK_THRESHOLD = 3;
export const HealthIndicator = ({ blockDiff }: HealthIndicatorProps) => {
const online = useNavigatorOnline();
let intent = Intent.Success;
let text = 'Operational';
if (!online) {
text = t('Offline');
intent = Intent.Danger;
} else if (blockDiff === null) {
// Block height query failed and null was returned
text = t('Non operational');
intent = Intent.Danger;
} else if (blockDiff >= BLOCK_THRESHOLD) {
text = t(`${blockDiff} Blocks behind`);
intent = Intent.Warning;
}
return ( return (
<span title={t('Node health')}> <span title={t('Node health')}>
<Indicator variant={intent} /> <Indicator variant={intent} />

View File

@ -59,6 +59,11 @@ export function createClient({
const timestamp = r?.headers.get('x-block-timestamp'); const timestamp = r?.headers.get('x-block-timestamp');
if (blockHeight && timestamp) { if (blockHeight && timestamp) {
const state = useHeaderStore.getState(); const state = useHeaderStore.getState();
const urlState = state[r.url];
if (
!urlState?.blockHeight ||
urlState.blockHeight !== blockHeight
) {
useHeaderStore.setState({ useHeaderStore.setState({
...state, ...state,
[r.url]: { [r.url]: {
@ -67,6 +72,7 @@ export function createClient({
}, },
}); });
} }
}
return response; return response;
}); });
}) })

View File

@ -5,6 +5,7 @@ import { MockedProvider } from '@apollo/react-testing';
import type { StatisticsQuery } from '../utils/__generated__/Node'; import type { StatisticsQuery } from '../utils/__generated__/Node';
import { StatisticsDocument } from '../utils/__generated__/Node'; import { StatisticsDocument } from '../utils/__generated__/Node';
import { useHeaderStore } from '@vegaprotocol/apollo-client'; import { useHeaderStore } from '@vegaprotocol/apollo-client';
import { Intent } from '@vegaprotocol/ui-toolkit';
const vegaUrl = 'https://foo.bar.com'; const vegaUrl = 'https://foo.bar.com';
@ -55,9 +56,24 @@ function setup(
describe('useNodeHealth', () => { describe('useNodeHealth', () => {
it.each([ it.each([
{ core: 1, node: 1, expected: 0 }, {
{ core: 1, node: 5, expected: -4 }, core: 1,
{ core: 10, node: 5, expected: 5 }, node: 1,
expectedText: 'Operational',
expectedIntent: Intent.Success,
},
{
core: 1,
node: 5,
expectedText: 'Operational',
expectedIntent: Intent.Success,
},
{
core: 10,
node: 5,
expectedText: '5 Blocks behind',
expectedIntent: Intent.Warning,
},
])( ])(
'provides difference core block $core and node block $node', 'provides difference core block $core and node block $node',
async (cases) => { async (cases) => {
@ -65,12 +81,12 @@ describe('useNodeHealth', () => {
blockHeight: cases.node, blockHeight: cases.node,
timestamp: new Date(), timestamp: new Date(),
}); });
expect(result.current.blockDiff).toEqual(null); expect(result.current.text).toEqual('Non operational');
expect(result.current.coreBlockHeight).toEqual(undefined); expect(result.current.intent).toEqual(Intent.Danger);
expect(result.current.datanodeBlockHeight).toEqual(cases.node); expect(result.current.datanodeBlockHeight).toEqual(cases.node);
await waitFor(() => { await waitFor(() => {
expect(result.current.blockDiff).toEqual(cases.expected); expect(result.current.text).toEqual(cases.expectedText);
expect(result.current.coreBlockHeight).toEqual(cases.core); expect(result.current.intent).toEqual(cases.expectedIntent);
expect(result.current.datanodeBlockHeight).toEqual(cases.node); expect(result.current.datanodeBlockHeight).toEqual(cases.node);
}); });
} }
@ -90,25 +106,64 @@ describe('useNodeHealth', () => {
blockHeight: 1, blockHeight: 1,
timestamp: new Date(), timestamp: new Date(),
}); });
expect(result.current.blockDiff).toEqual(null); expect(result.current.text).toEqual('Non operational');
expect(result.current.coreBlockHeight).toEqual(undefined); expect(result.current.intent).toEqual(Intent.Danger);
expect(result.current.datanodeBlockHeight).toEqual(1); expect(result.current.datanodeBlockHeight).toEqual(1);
await waitFor(() => { await waitFor(() => {
expect(result.current.blockDiff).toEqual(null); expect(result.current.text).toEqual('Non operational');
expect(result.current.coreBlockHeight).toEqual(undefined); expect(result.current.intent).toEqual(Intent.Danger);
expect(result.current.datanodeBlockHeight).toEqual(1); expect(result.current.datanodeBlockHeight).toEqual(1);
}); });
}); });
it('returns 0 if no headers are found (waits until stats query resolves)', async () => { it('returns 0 if no headers are found (waits until stats query resolves)', async () => {
const { result } = setup(createStatsMock(1), undefined); const { result } = setup(createStatsMock(1), undefined);
expect(result.current.blockDiff).toEqual(null); expect(result.current.text).toEqual('Non operational');
expect(result.current.coreBlockHeight).toEqual(undefined); expect(result.current.intent).toEqual(Intent.Danger);
expect(result.current.datanodeBlockHeight).toEqual(undefined); expect(result.current.datanodeBlockHeight).toEqual(undefined);
await waitFor(() => { await waitFor(() => {
expect(result.current.blockDiff).toEqual(0); expect(result.current.text).toEqual('Operational');
expect(result.current.coreBlockHeight).toEqual(1); expect(result.current.intent).toEqual(Intent.Success);
expect(result.current.datanodeBlockHeight).toEqual(undefined); expect(result.current.datanodeBlockHeight).toEqual(undefined);
}); });
}); });
it('Warning latency', async () => {
const now = 1678800900087;
const headerTimestamp = now - 4000;
const dateNow = new Date(now);
const dateHeaderTimestamp = new Date(headerTimestamp);
jest.useFakeTimers().setSystemTime(dateNow);
const { result } = setup(createStatsMock(2), {
blockHeight: 2,
timestamp: dateHeaderTimestamp,
});
await waitFor(() => {
expect(result.current.text).toEqual('Warning delay ( >3 sec): 4.05 sec');
expect(result.current.intent).toEqual(Intent.Warning);
expect(result.current.datanodeBlockHeight).toEqual(2);
});
});
it('Erroneous latency', async () => {
const now = 1678800900087;
const headerTimestamp = now - 11000;
const dateNow = new Date(now);
const dateHeaderTimestamp = new Date(headerTimestamp);
jest.useFakeTimers().setSystemTime(dateNow);
const { result } = setup(createStatsMock(2), {
blockHeight: 2,
timestamp: dateHeaderTimestamp,
});
await waitFor(() => {
expect(result.current.text).toEqual(
'Erroneous latency ( >10 sec): 11.05 sec'
);
expect(result.current.intent).toEqual(Intent.Danger);
expect(result.current.datanodeBlockHeight).toEqual(2);
});
jest.useRealTimers();
});
}); });

View File

@ -2,16 +2,21 @@ import { useEffect, useMemo } from 'react';
import { useStatisticsQuery } from '../utils/__generated__/Node'; import { useStatisticsQuery } from '../utils/__generated__/Node';
import { useHeaderStore } from '@vegaprotocol/apollo-client'; import { useHeaderStore } from '@vegaprotocol/apollo-client';
import { useEnvironment } from './use-environment'; import { useEnvironment } from './use-environment';
import { fromNanoSeconds } from '@vegaprotocol/utils'; import { useNavigatorOnline } from '@vegaprotocol/react-helpers';
import { Intent } from '@vegaprotocol/ui-toolkit';
import { t } from '@vegaprotocol/i18n';
const POLL_INTERVAL = 1000; const POLL_INTERVAL = 1000;
const BLOCK_THRESHOLD = 3;
const ERROR_LATENCY = 10000;
const WARNING_LATENCY = 3000;
export const useNodeHealth = () => { export const useNodeHealth = () => {
const online = useNavigatorOnline();
const url = useEnvironment((store) => store.VEGA_URL); const url = useEnvironment((store) => store.VEGA_URL);
const headerStore = useHeaderStore(); const headerStore = useHeaderStore();
const headers = url ? headerStore[url] : undefined; const headers = url ? headerStore[url] : undefined;
const { data, error, loading, startPolling, stopPolling } = const { data, error, startPolling, stopPolling } = useStatisticsQuery({
useStatisticsQuery({
fetchPolicy: 'no-cache', fetchPolicy: 'no-cache',
}); });
@ -20,12 +25,12 @@ export const useNodeHealth = () => {
return null; return null;
} }
if (!headers) { if (!headers?.blockHeight) {
return 0; return 0;
} }
return Number(data.statistics.blockHeight) - headers.blockHeight; return Number(data.statistics.blockHeight) - headers.blockHeight;
}, [data, headers]); }, [data?.statistics.blockHeight, headers?.blockHeight]);
useEffect(() => { useEffect(() => {
if (error) { if (error) {
@ -38,17 +43,43 @@ export const useNodeHealth = () => {
} }
}, [error, startPolling, stopPolling]); }, [error, startPolling, stopPolling]);
const blockUpdateMsLatency = headers?.timestamp
? Date.now() - headers.timestamp.getTime()
: 0;
const [text, intent] = useMemo(() => {
let intent = Intent.Success;
let text = 'Operational';
if (!online) {
text = t('Offline');
intent = Intent.Danger;
} else if (blockDiff === null) {
// Block height query failed and null was returned
text = t('Non operational');
intent = Intent.Danger;
} else if (blockUpdateMsLatency > ERROR_LATENCY) {
text = t('Erroneous latency ( >%s sec): %s sec', [
(ERROR_LATENCY / 1000).toString(),
(blockUpdateMsLatency / 1000).toFixed(2),
]);
intent = Intent.Danger;
} else if (blockDiff >= BLOCK_THRESHOLD) {
text = t(`%s Blocks behind`, String(blockDiff));
intent = Intent.Warning;
} else if (blockUpdateMsLatency > WARNING_LATENCY) {
text = t('Warning delay ( >%s sec): %s sec', [
(WARNING_LATENCY / 1000).toString(),
(blockUpdateMsLatency / 1000).toFixed(2),
]);
intent = Intent.Warning;
}
return [text, intent];
}, [online, blockDiff, blockUpdateMsLatency]);
return { return {
error,
loading,
coreBlockHeight: data?.statistics
? Number(data.statistics.blockHeight)
: undefined,
coreVegaTime: data?.statistics
? fromNanoSeconds(data?.statistics.vegaTime)
: undefined,
datanodeBlockHeight: headers?.blockHeight, datanodeBlockHeight: headers?.blockHeight,
datanodeVegaTime: headers?.timestamp, text,
blockDiff, intent,
}; };
}; };