diff --git a/apps/trading/components/footer/footer.spec.tsx b/apps/trading/components/footer/footer.spec.tsx index b743a4fd2..0e9c5553e 100644 --- a/apps/trading/components/footer/footer.spec.tsx +++ b/apps/trading/components/footer/footer.spec.tsx @@ -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( - - ); + render(, { 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(); + render(); expect(screen.getByTestId('indicator')).toHaveClass(elem.classname); expect(screen.getByText(elem.text)).toBeInTheDocument(); } diff --git a/apps/trading/components/footer/footer.tsx b/apps/trading/components/footer/footer.tsx index 62ab51491..a2b41a67c 100644 --- a/apps/trading/components/footer/footer.tsx +++ b/apps/trading/components/footer/footer.tsx @@ -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 (
{/* Pull left to align with top nav, due to button padding */}
- {VEGA_URL && ( - setNodeSwitcher(true)} - /> - )} +
); }; -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 ? ( - + - + - {blockHeight} + {datanodeBlockHeight} - ); + ) : 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 ( diff --git a/libs/apollo-client/src/lib/apollo-client.ts b/libs/apollo-client/src/lib/apollo-client.ts index 94b74aaa8..77d0d023c 100644 --- a/libs/apollo-client/src/lib/apollo-client.ts +++ b/libs/apollo-client/src/lib/apollo-client.ts @@ -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; }); diff --git a/libs/environment/src/hooks/use-node-health.spec.tsx b/libs/environment/src/hooks/use-node-health.spec.tsx index 13e19b304..9a280e1fe 100644 --- a/libs/environment/src/hooks/use-node-health.spec.tsx +++ b/libs/environment/src/hooks/use-node-health.spec.tsx @@ -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(); + }); }); diff --git a/libs/environment/src/hooks/use-node-health.ts b/libs/environment/src/hooks/use-node-health.ts index 299f8faea..1cffd7e6f 100644 --- a/libs/environment/src/hooks/use-node-health.ts +++ b/libs/environment/src/hooks/use-node-health.ts @@ -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, }; };