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 { 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', () => {
it('controls the node switcher dialog', async () => {
const mockOnClick = jest.fn();
render(
<NodeHealth
onClick={mockOnClick}
url={'https://api.n99.somenetwork.vega.xyz'}
blockHeight={100}
blockDiff={0}
/>
);
render(<NodeHealth />, { wrapper: MockedProvider });
await waitFor(() => {
expect(screen.getByRole('button')).toBeInTheDocument();
});
await userEvent.click(screen.getByRole('button'));
expect(mockOnClick).toHaveBeenCalled();
expect(mockSetNodeSwitcher).toHaveBeenCalled();
});
});
@ -31,14 +41,22 @@ describe('NodeUrl', () => {
describe('HealthIndicator', () => {
const cases = [
{ diff: 0, classname: 'bg-vega-green-550', text: 'Operational' },
{ diff: 5, classname: 'bg-warning', text: '5 Blocks behind' },
{ diff: null, classname: 'bg-danger', text: 'Non operational' },
{
intent: Intent.Success,
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)(
'renders correct text and indicator color for $diff block difference',
(elem) => {
render(<HealthIndicator blockDiff={elem.diff} />);
render(<HealthIndicator text={elem.text} intent={elem.intent} />);
expect(screen.getByTestId('indicator')).toHaveClass(elem.classname);
expect(screen.getByText(elem.text)).toBeInTheDocument();
}

View File

@ -1,60 +1,45 @@
import { useCallback } from 'react';
import { useEnvironment, useNodeHealth } from '@vegaprotocol/environment';
import { useNavigatorOnline } from '@vegaprotocol/react-helpers';
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 type { ButtonHTMLAttributes, ReactNode } from 'react';
import { useGlobalStore } from '../../stores';
export const Footer = () => {
const { VEGA_URL } = useEnvironment();
const setNodeSwitcher = useGlobalStore(
(store) => (open: boolean) => store.update({ nodeSwitcherDialog: open })
);
const { blockDiff, datanodeBlockHeight } = useNodeHealth();
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">
{/* Pull left to align with top nav, due to button padding */}
<div className="-ml-2">
{VEGA_URL && (
<NodeHealth
url={VEGA_URL}
blockHeight={datanodeBlockHeight}
blockDiff={blockDiff}
onClick={() => setNodeSwitcher(true)}
/>
)}
<NodeHealth />
</div>
</footer>
);
};
interface NodeHealthProps {
url: string;
blockHeight: number | undefined;
blockDiff: number | null;
onClick: () => void;
}
export const NodeHealth = ({
url,
blockHeight,
blockDiff,
onClick,
}: NodeHealthProps) => {
return (
export const NodeHealth = () => {
const { VEGA_URL } = useEnvironment();
const setNodeSwitcher = useGlobalStore(
(store) => (open: boolean) => store.update({ nodeSwitcherDialog: open })
);
const { datanodeBlockHeight, text, intent } = useNodeHealth();
const onClick = useCallback(() => {
setNodeSwitcher(true);
}, [setNodeSwitcher]);
return VEGA_URL ? (
<FooterButton onClick={onClick} data-testid="node-health">
<FooterButtonPart>
<HealthIndicator blockDiff={blockDiff} />
<HealthIndicator text={text} intent={intent} />
</FooterButtonPart>
<FooterButtonPart>
<NodeUrl url={url} />
<NodeUrl url={VEGA_URL} />
</FooterButtonPart>
<FooterButtonPart>
<span title={t('Block height')}>{blockHeight}</span>
<span title={t('Block height')}>{datanodeBlockHeight}</span>
</FooterButtonPart>
</FooterButton>
);
) : null;
};
interface NodeUrlProps {
@ -69,31 +54,11 @@ export const NodeUrl = ({ url }: NodeUrlProps) => {
};
interface HealthIndicatorProps {
blockDiff: number | null;
text: string;
intent: Intent;
}
// How many blocks behind the most advanced block that is
// 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;
}
export const HealthIndicator = ({ text, intent }: HealthIndicatorProps) => {
return (
<span title={t('Node health')}>
<Indicator variant={intent} />

View File

@ -59,13 +59,19 @@ export function createClient({
const timestamp = r?.headers.get('x-block-timestamp');
if (blockHeight && timestamp) {
const state = useHeaderStore.getState();
useHeaderStore.setState({
...state,
[r.url]: {
blockHeight: Number(blockHeight),
timestamp: new Date(Number(timestamp.slice(0, -6))),
},
});
const urlState = state[r.url];
if (
!urlState?.blockHeight ||
urlState.blockHeight !== blockHeight
) {
useHeaderStore.setState({
...state,
[r.url]: {
blockHeight: Number(blockHeight),
timestamp: new Date(Number(timestamp.slice(0, -6))),
},
});
}
}
return response;
});

View File

@ -5,6 +5,7 @@ import { MockedProvider } from '@apollo/react-testing';
import type { StatisticsQuery } from '../utils/__generated__/Node';
import { StatisticsDocument } from '../utils/__generated__/Node';
import { useHeaderStore } from '@vegaprotocol/apollo-client';
import { Intent } from '@vegaprotocol/ui-toolkit';
const vegaUrl = 'https://foo.bar.com';
@ -55,9 +56,24 @@ function setup(
describe('useNodeHealth', () => {
it.each([
{ core: 1, node: 1, expected: 0 },
{ core: 1, node: 5, expected: -4 },
{ core: 10, node: 5, expected: 5 },
{
core: 1,
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',
async (cases) => {
@ -65,12 +81,12 @@ describe('useNodeHealth', () => {
blockHeight: cases.node,
timestamp: new Date(),
});
expect(result.current.blockDiff).toEqual(null);
expect(result.current.coreBlockHeight).toEqual(undefined);
expect(result.current.text).toEqual('Non operational');
expect(result.current.intent).toEqual(Intent.Danger);
expect(result.current.datanodeBlockHeight).toEqual(cases.node);
await waitFor(() => {
expect(result.current.blockDiff).toEqual(cases.expected);
expect(result.current.coreBlockHeight).toEqual(cases.core);
expect(result.current.text).toEqual(cases.expectedText);
expect(result.current.intent).toEqual(cases.expectedIntent);
expect(result.current.datanodeBlockHeight).toEqual(cases.node);
});
}
@ -90,25 +106,64 @@ describe('useNodeHealth', () => {
blockHeight: 1,
timestamp: new Date(),
});
expect(result.current.blockDiff).toEqual(null);
expect(result.current.coreBlockHeight).toEqual(undefined);
expect(result.current.text).toEqual('Non operational');
expect(result.current.intent).toEqual(Intent.Danger);
expect(result.current.datanodeBlockHeight).toEqual(1);
await waitFor(() => {
expect(result.current.blockDiff).toEqual(null);
expect(result.current.coreBlockHeight).toEqual(undefined);
expect(result.current.text).toEqual('Non operational');
expect(result.current.intent).toEqual(Intent.Danger);
expect(result.current.datanodeBlockHeight).toEqual(1);
});
});
it('returns 0 if no headers are found (waits until stats query resolves)', async () => {
const { result } = setup(createStatsMock(1), undefined);
expect(result.current.blockDiff).toEqual(null);
expect(result.current.coreBlockHeight).toEqual(undefined);
expect(result.current.text).toEqual('Non operational');
expect(result.current.intent).toEqual(Intent.Danger);
expect(result.current.datanodeBlockHeight).toEqual(undefined);
await waitFor(() => {
expect(result.current.blockDiff).toEqual(0);
expect(result.current.coreBlockHeight).toEqual(1);
expect(result.current.text).toEqual('Operational');
expect(result.current.intent).toEqual(Intent.Success);
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,30 +2,35 @@ import { useEffect, useMemo } from 'react';
import { useStatisticsQuery } from '../utils/__generated__/Node';
import { useHeaderStore } from '@vegaprotocol/apollo-client';
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 BLOCK_THRESHOLD = 3;
const ERROR_LATENCY = 10000;
const WARNING_LATENCY = 3000;
export const useNodeHealth = () => {
const online = useNavigatorOnline();
const url = useEnvironment((store) => store.VEGA_URL);
const headerStore = useHeaderStore();
const headers = url ? headerStore[url] : undefined;
const { data, error, loading, startPolling, stopPolling } =
useStatisticsQuery({
fetchPolicy: 'no-cache',
});
const { data, error, startPolling, stopPolling } = useStatisticsQuery({
fetchPolicy: 'no-cache',
});
const blockDiff = useMemo(() => {
if (!data?.statistics.blockHeight) {
return null;
}
if (!headers) {
if (!headers?.blockHeight) {
return 0;
}
return Number(data.statistics.blockHeight) - headers.blockHeight;
}, [data, headers]);
}, [data?.statistics.blockHeight, headers?.blockHeight]);
useEffect(() => {
if (error) {
@ -38,17 +43,43 @@ export const useNodeHealth = () => {
}
}, [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 {
error,
loading,
coreBlockHeight: data?.statistics
? Number(data.statistics.blockHeight)
: undefined,
coreVegaTime: data?.statistics
? fromNanoSeconds(data?.statistics.vegaTime)
: undefined,
datanodeBlockHeight: headers?.blockHeight,
datanodeVegaTime: headers?.timestamp,
blockDiff,
text,
intent,
};
};