fix(environment): missing block height in node switcher ()

This commit is contained in:
Art 2023-11-08 09:47:23 +01:00 committed by GitHub
parent 7aee2a3a7b
commit 607ad06971
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 228 additions and 149 deletions
libs/environment/src/components/node-switcher

View File

@ -1,6 +1,12 @@
import type { MockedResponse } from '@apollo/react-testing'; import type { MockedResponse } from '@apollo/react-testing';
import { MockedProvider } from '@apollo/react-testing'; import { MockedProvider } from '@apollo/react-testing';
import { act, render, screen, waitFor } from '@testing-library/react'; import {
act,
render,
renderHook,
screen,
waitFor,
} from '@testing-library/react';
import { RadioGroup } from '@vegaprotocol/ui-toolkit'; import { RadioGroup } from '@vegaprotocol/ui-toolkit';
import type { import type {
NodeCheckTimeUpdateSubscription, NodeCheckTimeUpdateSubscription,
@ -11,30 +17,33 @@ import {
NodeCheckTimeUpdateDocument, NodeCheckTimeUpdateDocument,
} from '../../utils/__generated__/NodeCheck'; } from '../../utils/__generated__/NodeCheck';
import type { RowDataProps } from './row-data'; import type { RowDataProps } from './row-data';
import { POLL_INTERVAL } from './row-data'; import {
POLL_INTERVAL,
Result,
SUBSCRIPTION_TIMEOUT,
useNodeBasicStatus,
useNodeSubscriptionStatus,
useResponseTime,
} from './row-data';
import { BLOCK_THRESHOLD, RowData } from './row-data'; import { BLOCK_THRESHOLD, RowData } from './row-data';
import type { HeaderEntry } from '@vegaprotocol/apollo-client';
import { useHeaderStore } from '@vegaprotocol/apollo-client';
import { CUSTOM_NODE_KEY } from '../../types'; import { CUSTOM_NODE_KEY } from '../../types';
jest.mock('@vegaprotocol/apollo-client', () => ({ const mockStatsQuery = (
useHeaderStore: jest.fn().mockReturnValue({}), blockHeight = '1234'
})); ): MockedResponse<NodeCheckQuery> => ({
const statsQueryMock: MockedResponse<NodeCheckQuery> = {
request: { request: {
query: NodeCheckDocument, query: NodeCheckDocument,
}, },
result: { result: {
data: { data: {
statistics: { statistics: {
blockHeight: '1234', // the actual value used in the component is the value from the header store blockHeight,
vegaTime: new Date().toISOString(), vegaTime: new Date().toISOString(),
chainId: 'test-chain-id', chainId: 'test-chain-id',
}, },
}, },
}, },
}; });
const subMock: MockedResponse<NodeCheckTimeUpdateSubscription> = { const subMock: MockedResponse<NodeCheckTimeUpdateSubscription> = {
request: { request: {
@ -59,18 +68,6 @@ global.performance.getEntriesByName = jest.fn().mockReturnValue([
}, },
]); ]);
const mockHeaders = (
url: string,
headers: Partial<HeaderEntry> = {
blockHeight: 100,
timestamp: new Date(),
}
) => {
(useHeaderStore as unknown as jest.Mock).mockReturnValue({
[url]: headers,
});
};
const renderComponent = ( const renderComponent = (
props: RowDataProps, props: RowDataProps,
queryMock: MockedResponse<NodeCheckQuery>, queryMock: MockedResponse<NodeCheckQuery>,
@ -86,6 +83,98 @@ const renderComponent = (
); );
}; };
describe('useNodeSubscriptionStatus', () => {
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
const mockWrapper =
(withData = false) =>
({ children }: { children: React.ReactNode }) =>
(
<MockedProvider mocks={withData ? [subMock, subMock, subMock] : []}>
{children}
</MockedProvider>
);
it('results initially as loading', async () => {
const { result } = renderHook(() => useNodeSubscriptionStatus(), {
wrapper: mockWrapper(true),
});
expect(result.current.status).toBe(Result.Loading);
});
it('results as successful when data received', async () => {
const { result } = renderHook(() => useNodeSubscriptionStatus(), {
wrapper: mockWrapper(true),
});
expect(result.current.status).toBe(Result.Loading);
await act(() => {
jest.advanceTimersByTime(SUBSCRIPTION_TIMEOUT);
});
expect(result.current.status).toBe(Result.Successful);
});
it('result as failed when no data received', async () => {
const { result } = renderHook(() => useNodeSubscriptionStatus(), {
wrapper: mockWrapper(false),
});
expect(result.current.status).toBe(Result.Loading);
await act(() => {
jest.advanceTimersByTime(SUBSCRIPTION_TIMEOUT);
});
expect(result.current.status).toBe(Result.Failed);
});
});
describe('useNodeBasicStatus', () => {
const mockWrapper =
(withData = false) =>
({ children }: { children: React.ReactNode }) =>
(
<MockedProvider mocks={withData ? [mockStatsQuery('1234')] : []}>
{children}
</MockedProvider>
);
it('results initially as loading', async () => {
const { result } = renderHook(() => useNodeBasicStatus(), {
wrapper: mockWrapper(true),
});
expect(result.current.status).toBe(Result.Loading);
expect(result.current.currentBlockHeight).toBeNaN();
});
it('results as successful when data received', async () => {
const { result } = renderHook(() => useNodeBasicStatus(), {
wrapper: mockWrapper(true),
});
await waitFor(() => {
expect(result.current.status).toBe(Result.Successful);
expect(result.current.currentBlockHeight).toBe(1234);
});
});
it('result as failed when no data received', async () => {
const { result } = renderHook(() => useNodeBasicStatus(), {
wrapper: mockWrapper(false),
});
await waitFor(() => {
expect(result.current.status).toBe(Result.Failed);
expect(result.current.currentBlockHeight).toBeNaN();
});
});
});
describe('useResponseTime', () => {
it('returns response time when url is valid', () => {
const { result } = renderHook(() =>
useResponseTime('https://localhost:1234')
);
expect(result.current.responseTime).toBe(50);
});
it('does not return response time when url is invalid', () => {
const { result } = renderHook(() => useResponseTime('nope'));
expect(result.current.responseTime).toBeUndefined();
});
});
describe('RowData', () => { describe('RowData', () => {
const props = { const props = {
id: '0', id: '0',
@ -94,9 +183,13 @@ describe('RowData', () => {
onBlockHeight: jest.fn(), onBlockHeight: jest.fn(),
}; };
afterAll(() => {
jest.useRealTimers();
jest.resetAllMocks();
});
it('radio button enabled after stats query successful', async () => { it('radio button enabled after stats query successful', async () => {
mockHeaders(props.url); render(renderComponent(props, mockStatsQuery('100'), subMock));
render(renderComponent(props, statsQueryMock, subMock));
// radio should be enabled until query resolves // radio should be enabled until query resolves
expect( expect(
@ -127,8 +220,6 @@ describe('RowData', () => {
}); });
it('radio button still enabled if query fails', async () => { it('radio button still enabled if query fails', async () => {
mockHeaders(props.url, {});
const failedQueryMock: MockedResponse<NodeCheckQuery> = { const failedQueryMock: MockedResponse<NodeCheckQuery> = {
request: { request: {
query: NodeCheckDocument, query: NodeCheckDocument,
@ -178,12 +269,11 @@ describe('RowData', () => {
it('highlights rows with a slow block height', async () => { it('highlights rows with a slow block height', async () => {
const blockHeight = 100; const blockHeight = 100;
mockHeaders(props.url, { blockHeight });
const { rerender } = render( const { rerender } = render(
renderComponent( renderComponent(
{ ...props, highestBlock: blockHeight + BLOCK_THRESHOLD }, { ...props, highestBlock: blockHeight + BLOCK_THRESHOLD },
statsQueryMock, mockStatsQuery(String(blockHeight)),
subMock subMock
) )
); );
@ -201,7 +291,7 @@ describe('RowData', () => {
rerender( rerender(
renderComponent( renderComponent(
{ ...props, highestBlock: blockHeight + BLOCK_THRESHOLD + 1 }, { ...props, highestBlock: blockHeight + BLOCK_THRESHOLD + 1 },
statsQueryMock, mockStatsQuery(String(blockHeight)),
subMock subMock
) )
); );
@ -216,7 +306,7 @@ describe('RowData', () => {
...props, ...props,
id: CUSTOM_NODE_KEY, id: CUSTOM_NODE_KEY,
}, },
statsQueryMock, mockStatsQuery('1234'),
subMock subMock
) )
); );
@ -230,17 +320,18 @@ describe('RowData', () => {
it('updates highest block after new header received', async () => { it('updates highest block after new header received', async () => {
const mockOnBlockHeight = jest.fn(); const mockOnBlockHeight = jest.fn();
const blockHeight = 200; const blockHeight = 200;
mockHeaders(props.url, { blockHeight });
render( render(
renderComponent( renderComponent(
{ ...props, onBlockHeight: mockOnBlockHeight }, { ...props, onBlockHeight: mockOnBlockHeight },
statsQueryMock, mockStatsQuery(String(blockHeight)),
subMock subMock
) )
); );
await waitFor(() => {
expect(mockOnBlockHeight).toHaveBeenCalledWith(blockHeight); expect(mockOnBlockHeight).toHaveBeenCalledWith(blockHeight);
}); });
});
it('should poll the query unless an errors is returned', async () => { it('should poll the query unless an errors is returned', async () => {
jest.useFakeTimers(); jest.useFakeTimers();
@ -275,7 +366,6 @@ describe('RowData', () => {
}; };
}; };
mockHeaders(props.url);
const statsQueryMock1 = createStatsQueryMock('1234'); const statsQueryMock1 = createStatsQueryMock('1234');
const statsQueryMock2 = createStatsQueryMock('1235'); const statsQueryMock2 = createStatsQueryMock('1235');
const statsQueryMock3 = createFailedStatsQueryMock(); const statsQueryMock3 = createFailedStatsQueryMock();

View File

@ -1,5 +1,3 @@
import type { ApolloError } from '@apollo/client';
import { useHeaderStore } from '@vegaprotocol/apollo-client';
import { isValidUrl } from '@vegaprotocol/utils'; import { isValidUrl } from '@vegaprotocol/utils';
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import { TradingRadio } from '@vegaprotocol/ui-toolkit'; import { TradingRadio } from '@vegaprotocol/ui-toolkit';
@ -12,6 +10,7 @@ import {
import { LayoutCell } from './layout-cell'; import { LayoutCell } from './layout-cell';
export const POLL_INTERVAL = 1000; export const POLL_INTERVAL = 1000;
export const SUBSCRIPTION_TIMEOUT = 3000;
export const BLOCK_THRESHOLD = 3; export const BLOCK_THRESHOLD = 3;
export interface RowDataProps { export interface RowDataProps {
@ -21,15 +20,39 @@ export interface RowDataProps {
onBlockHeight: (blockHeight: number) => void; onBlockHeight: (blockHeight: number) => void;
} }
export const RowData = ({ export enum Result {
id, Successful,
url, Failed,
highestBlock, Loading,
onBlockHeight, }
}: RowDataProps) => {
const [subFailed, setSubFailed] = useState(false); export const useNodeSubscriptionStatus = () => {
const [time, setTime] = useState<number>(); const [status, setStatus] = useState<Result>(Result.Loading);
// no use of data here as we need the data nodes reference to block height const { data, error } = useNodeCheckTimeUpdateSubscription();
useEffect(() => {
if (error) {
setStatus(Result.Failed);
}
if (data?.busEvents && data.busEvents.length > 0) {
setStatus(Result.Successful);
}
// set as failed when no data received after SUBSCRIPTION_TIMEOUT ms
const timeout = setTimeout(() => {
if (!data || error) {
setStatus(Result.Failed);
}
}, SUBSCRIPTION_TIMEOUT);
return () => {
clearTimeout(timeout);
};
}, [data, error]);
return { status };
};
export const useNodeBasicStatus = () => {
const [status, setStatus] = useState<Result>(Result.Loading);
const { data, error, loading, startPolling, stopPolling } = useNodeCheckQuery( const { data, error, loading, startPolling, stopPolling } = useNodeCheckQuery(
{ {
pollInterval: POLL_INTERVAL, pollInterval: POLL_INTERVAL,
@ -38,28 +61,7 @@ export const RowData = ({
ssr: false, ssr: false,
} }
); );
const headerStore = useHeaderStore();
const headers = headerStore[url];
const {
data: subData,
error: subError,
loading: subLoading,
} = useNodeCheckTimeUpdateSubscription();
useEffect(() => {
const timeout = setTimeout(() => {
if (!subData) {
setSubFailed(true);
}
}, 3000);
return () => {
clearTimeout(timeout);
};
}, [subData]);
// handle polling
useEffect(() => { useEffect(() => {
const handleStartPoll = () => { const handleStartPoll = () => {
if (error) return; if (error) return;
@ -83,56 +85,57 @@ export const RowData = ({
}; };
}, [startPolling, stopPolling, error]); }, [startPolling, stopPolling, error]);
// measure response time const currentBlockHeight = parseInt(
data?.statistics.blockHeight || 'NONE',
10
);
useEffect(() => {
if (loading) {
setStatus(Result.Loading);
return;
}
if (!error && !isNaN(currentBlockHeight)) {
setStatus(Result.Successful);
return;
}
setStatus(Result.Failed);
}, [currentBlockHeight, error, loading]);
return {
status,
currentBlockHeight,
};
};
export const useResponseTime = (url: string, trigger?: unknown) => {
const [responseTime, setResponseTime] = useState<number>();
useEffect(() => { useEffect(() => {
if (!isValidUrl(url)) return; if (!isValidUrl(url)) return;
if (typeof window.performance.getEntriesByName !== 'function') return; // protection for test environment if (typeof window.performance.getEntriesByName !== 'function') return; // protection for test environment
// every time we get data measure response speed
const requestUrl = new URL(url); const requestUrl = new URL(url);
const requests = window.performance.getEntriesByName(requestUrl.href); const requests = window.performance.getEntriesByName(requestUrl.href);
const { duration } = const { duration } =
(requests.length && requests[requests.length - 1]) || {}; (requests.length && requests[requests.length - 1]) || {};
setTime(duration); setResponseTime(duration);
}, [url, data]); }, [url, trigger]);
return { responseTime };
};
export const RowData = ({
id,
url,
highestBlock,
onBlockHeight,
}: RowDataProps) => {
const { status: subStatus } = useNodeSubscriptionStatus();
const { status, currentBlockHeight } = useNodeBasicStatus();
const { responseTime } = useResponseTime(url, currentBlockHeight); // measure response time (ms) every time we get data (block height)
useEffect(() => { useEffect(() => {
if (headers?.blockHeight) { if (!isNaN(currentBlockHeight)) {
onBlockHeight(headers.blockHeight); onBlockHeight(currentBlockHeight);
} }
}, [headers?.blockHeight, onBlockHeight]); }, [currentBlockHeight, onBlockHeight]);
const getHasError = () => {
// the stats query errored
if (error) {
return true;
}
// if we are still awaiting a header entry its not an error
// we are still waiting for the query to resolve
if (!headers) {
return false;
}
// highlight this node as 'error' if its more than BLOCK_THRESHOLD blocks behind the most
// advanced node
if (
highestBlock !== null &&
headers.blockHeight < highestBlock - BLOCK_THRESHOLD
) {
return true;
}
return false;
};
const getSubFailed = (
subError: ApolloError | undefined,
subFailed: boolean
) => {
if (subError) return true;
if (subFailed) return true;
return false;
};
return ( return (
<> <>
@ -143,72 +146,58 @@ export const RowData = ({
)} )}
<LayoutCell <LayoutCell
label={t('Response time')} label={t('Response time')}
isLoading={!error && loading} isLoading={status === Result.Loading}
hasError={Boolean(error)} hasError={status === Result.Failed}
dataTestId="response-time-cell" dataTestId="response-time-cell"
> >
{getResponseTimeDisplayValue(time, error)} {display(status, formatResponseTime(responseTime))}
</LayoutCell> </LayoutCell>
<LayoutCell <LayoutCell
label={t('Block')} label={t('Block')}
isLoading={loading} isLoading={status === Result.Loading}
hasError={getHasError()} hasError={
status === Result.Failed ||
(highestBlock != null &&
!isNaN(currentBlockHeight) &&
currentBlockHeight < highestBlock - BLOCK_THRESHOLD)
}
dataTestId="block-height-cell" dataTestId="block-height-cell"
> >
<span <span
data-testid="query-block-height" data-testid="query-block-height"
data-query-block-height={ data-query-block-height={
error ? 'failed' : data?.statistics.blockHeight status === Result.Failed ? 'failed' : currentBlockHeight
} }
> >
{getBlockDisplayValue(headers?.blockHeight, error)} {display(status, currentBlockHeight)}
</span> </span>
</LayoutCell> </LayoutCell>
<LayoutCell <LayoutCell
label={t('Subscription')} label={t('Subscription')}
isLoading={subFailed ? false : subLoading} isLoading={subStatus === Result.Loading}
hasError={getSubFailed(subError, subFailed)} hasError={subStatus === Result.Failed}
dataTestId="subscription-cell" dataTestId="subscription-cell"
> >
{getSubscriptionDisplayValue(subFailed, subData?.busEvents, subError)} {display(subStatus, t('Yes'), t('No'))}
</LayoutCell> </LayoutCell>
</> </>
); );
}; };
const getResponseTimeDisplayValue = ( const formatResponseTime = (time: number | undefined) =>
responseTime?: number, time != null ? `${Number(time).toFixed(2)}ms` : '-';
error?: ApolloError
) => {
if (error) {
return t('n/a');
}
if (typeof responseTime === 'number') {
return `${Number(responseTime).toFixed(2)}ms`;
}
return '-';
};
const getBlockDisplayValue = (block?: number, error?: ApolloError) => { const display = (
if (error) { status: Result,
return t('n/a'); yes: string | number | undefined,
} no = t('n/a')
if (block) {
return block;
}
return '-';
};
const getSubscriptionDisplayValue = (
subFailed: boolean,
events?: { id: string }[] | null,
error?: ApolloError
) => { ) => {
if (subFailed || error) { switch (status) {
return t('No'); case Result.Successful:
} return yes;
if (events?.length) { case Result.Failed:
return t('Yes'); return no;
} default:
return '-'; return '-';
}
}; };