diff --git a/apps/trading/components/footer/footer.spec.tsx b/apps/trading/components/footer/footer.spec.tsx index c1631f2f1..5963a39fc 100644 --- a/apps/trading/components/footer/footer.spec.tsx +++ b/apps/trading/components/footer/footer.spec.tsx @@ -1,26 +1,58 @@ import { fireEvent, render, screen } from '@testing-library/react'; -import { Footer } from './footer'; +import { Footer, NodeHealth } from './footer'; import { useEnvironment } from '@vegaprotocol/environment'; jest.mock('@vegaprotocol/environment'); describe('Footer', () => { - it('renders a button to open node switcher', () => { + it('can open node switcher by clicking the node url', () => { const mockOpenNodeSwitcher = jest.fn(); const node = 'n99.somenetwork.vega.xyz'; - const nodeUrl = `https://${node}`; // @ts-ignore mock env hook useEnvironment.mockImplementation(() => ({ VEGA_URL: `https://api.${node}/graphql`, + blockDifference: 0, setNodeSwitcherOpen: mockOpenNodeSwitcher, })); render(); - fireEvent.click(screen.getByRole('button')); + fireEvent.click(screen.getByText(node)); + expect(mockOpenNodeSwitcher).toHaveBeenCalled(); + }); + + it('can open node switcher by clicking health', () => { + const mockOpenNodeSwitcher = jest.fn(); + const node = 'n99.somenetwork.vega.xyz'; + + // @ts-ignore mock env hook + useEnvironment.mockImplementation(() => ({ + VEGA_URL: `https://api.${node}/graphql`, + blockDifference: 0, + setNodeSwitcherOpen: mockOpenNodeSwitcher, + })); + + render(); + + fireEvent.click(screen.getByText('Operational')); expect(mockOpenNodeSwitcher).toHaveBeenCalled(); - const link = screen.getByText(node); - expect(link).toHaveAttribute('href', nodeUrl); }); }); + +describe('NodeHealth', () => { + const cases = [ + { diff: 0, classname: 'bg-success', text: 'Operational' }, + { diff: 5, classname: 'bg-warning', text: '5 Blocks behind' }, + { diff: -1, classname: 'bg-danger', text: 'Non operational' }, + ]; + it.each(cases)( + 'renders correct text and indicator color for $diff block difference', + (elem) => { + console.log(elem); + 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 afff38dbe..069a4c171 100644 --- a/apps/trading/components/footer/footer.tsx +++ b/apps/trading/components/footer/footer.tsx @@ -1,28 +1,75 @@ import { useEnvironment } from '@vegaprotocol/environment'; -import { t } from '@vegaprotocol/react-helpers'; -import { ButtonLink, Link } from '@vegaprotocol/ui-toolkit'; +import { t, useNavigatorOnline } from '@vegaprotocol/react-helpers'; +import { ButtonLink, Indicator, Intent } from '@vegaprotocol/ui-toolkit'; export const Footer = () => { - const { VEGA_URL, setNodeSwitcherOpen } = useEnvironment(); + const { VEGA_URL, blockDifference, setNodeSwitcherOpen } = useEnvironment(); return ( ); }; -const NodeUrl = ({ url }: { url: string }) => { +interface NodeUrlProps { + url: string; + openNodeSwitcher: () => void; +} + +const NodeUrl = ({ url, openNodeSwitcher }: NodeUrlProps) => { // get base url from api url, api sub domain const urlObj = new URL(url); const nodeUrl = urlObj.origin.replace(/^[^.]+\./g, ''); + return {nodeUrl}; +}; + +interface NodeHealthProps { + openNodeSwitcher: () => void; + blockDiff: number; +} + +// How many blocks behind the most advanced block that is +// deemed acceptable for "Good" status +const BLOCK_THRESHOLD = 3; + +export const NodeHealth = ({ + blockDiff, + openNodeSwitcher, +}: NodeHealthProps) => { + const online = useNavigatorOnline(); + + let intent = Intent.Success; + let text = 'Operational'; + + if (!online) { + text = t('Offline'); + intent = Intent.Danger; + } else if (blockDiff < 0) { + // 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 ( - - {nodeUrl} - + + + {text} + ); }; diff --git a/libs/apollo-client/src/lib/apollo-client.ts b/libs/apollo-client/src/lib/apollo-client.ts index ffb9487c1..a185a0838 100644 --- a/libs/apollo-client/src/lib/apollo-client.ts +++ b/libs/apollo-client/src/lib/apollo-client.ts @@ -20,23 +20,43 @@ const isBrowser = typeof window !== 'undefined'; const NOT_FOUND = 'NotFound'; -export function createClient(base?: string, cacheConfig?: InMemoryCacheConfig) { - if (!base) { - throw new Error('Base must be passed into createClient!'); +export type ClientOptions = { + url?: string; + cacheConfig?: InMemoryCacheConfig; + retry?: boolean; + connectToDevTools?: boolean; +}; + +export function createClient({ + url, + cacheConfig, + retry = true, + connectToDevTools = true, +}: ClientOptions) { + if (!url) { + throw new Error('url must be passed into createClient!'); } - const urlHTTP = new URL(base); - const urlWS = new URL(base); + const urlHTTP = new URL(url); + const urlWS = new URL(url); // Replace http with ws, preserving if its a secure connection eg. https => wss urlWS.protocol = urlWS.protocol.replace('http', 'ws'); + + const noOpLink = new ApolloLink((operation, forward) => { + return forward(operation); + }); + const timeoutLink = new ApolloLinkTimeout(10000); const enlargedTimeoutLink = new ApolloLinkTimeout(100000); - const retryLink = new RetryLink({ - delay: { - initial: 300, - max: 10000, - jitter: true, - }, - }); + + const retryLink = retry + ? new RetryLink({ + delay: { + initial: 300, + max: 10000, + jitter: true, + }, + }) + : noOpLink; const httpLink = new HttpLink({ uri: urlHTTP.href, @@ -49,7 +69,7 @@ export function createClient(base?: string, cacheConfig?: InMemoryCacheConfig) { url: urlWS.href, }) ) - : new ApolloLink((operation, forward) => forward(operation)); + : noOpLink; const splitLink = isBrowser ? split( @@ -87,6 +107,7 @@ export function createClient(base?: string, cacheConfig?: InMemoryCacheConfig) { return new ApolloClient({ link: from([errorLink, composedTimeoutLink, retryLink, splitLink]), cache: new InMemoryCache(cacheConfig), + connectToDevTools, }); } diff --git a/libs/environment/src/components/network-loader/network-loader.spec.tsx b/libs/environment/src/components/network-loader/network-loader.spec.tsx index be71e9d42..40c021920 100644 --- a/libs/environment/src/components/network-loader/network-loader.spec.tsx +++ b/libs/environment/src/components/network-loader/network-loader.spec.tsx @@ -46,7 +46,10 @@ describe('Network loader', () => { render( {SUCCESS_TEXT} ); - expect(createClient).toHaveBeenCalledWith('http://vega.node', undefined); + expect(createClient).toHaveBeenCalledWith({ + url: 'http://vega.node', + cacheConfig: undefined, + }); expect(await screen.findByText(SUCCESS_TEXT)).toBeInTheDocument(); }); }); diff --git a/libs/environment/src/components/network-loader/network-loader.tsx b/libs/environment/src/components/network-loader/network-loader.tsx index ac12694e9..00852c999 100644 --- a/libs/environment/src/components/network-loader/network-loader.tsx +++ b/libs/environment/src/components/network-loader/network-loader.tsx @@ -20,7 +20,10 @@ export function NetworkLoader({ const client = useMemo(() => { if (VEGA_URL) { - return createClient(VEGA_URL, cache); + return createClient({ + url: VEGA_URL, + cacheConfig: cache, + }); } return undefined; }, [VEGA_URL, cache]); diff --git a/libs/environment/src/hooks/index.ts b/libs/environment/src/hooks/index.ts index cfb5ae364..c1c468099 100644 --- a/libs/environment/src/hooks/index.ts +++ b/libs/environment/src/hooks/index.ts @@ -1,2 +1,3 @@ export * from './use-environment'; export * from './use-links'; +export * from './use-node-health'; diff --git a/libs/environment/src/hooks/use-config.tsx b/libs/environment/src/hooks/use-config.tsx index bc1ee1277..b46b58613 100644 --- a/libs/environment/src/hooks/use-config.tsx +++ b/libs/environment/src/hooks/use-config.tsx @@ -18,6 +18,9 @@ type UseConfigOptions = { defaultConfig?: Configuration; }; +/** + * Fetch list of hosts from the VEGA_CONFIG_URL + */ export const useConfig = ( { environment, defaultConfig }: UseConfigOptions, onError: (errorType: ErrorType) => void diff --git a/libs/environment/src/hooks/use-environment-errors.spec.tsx b/libs/environment/src/hooks/use-environment-errors.spec.tsx index 3d01c29af..df0068a45 100644 --- a/libs/environment/src/hooks/use-environment-errors.spec.tsx +++ b/libs/environment/src/hooks/use-environment-errors.spec.tsx @@ -101,6 +101,16 @@ beforeEach(() => { }); describe('throws error', () => { + const consoleError = console.error; + + beforeAll(() => { + console.error = jest.fn(); + }); + + afterAll(() => { + console.error = consoleError; + }); + beforeEach(() => { // @ts-ignore: typescript doesn't recognize the mock implementation global.fetch.mockImplementation(setupFetch()); @@ -127,6 +137,7 @@ describe('throws error', () => { }); beforeEach(() => jest.resetModules()); // clears the cache of the modules + it('throws a validation error when NX_ETHERSCAN_URL is not a valid url', () => { process.env['NX_ETHERSCAN_URL'] = 'invalid-url'; const result = () => diff --git a/libs/environment/src/hooks/use-environment.spec.tsx b/libs/environment/src/hooks/use-environment.spec.tsx index 515fdc9ae..f3b3ef295 100644 --- a/libs/environment/src/hooks/use-environment.spec.tsx +++ b/libs/environment/src/hooks/use-environment.spec.tsx @@ -1,7 +1,8 @@ // having the node switcher dialog in the environment provider breaks the test renderer // workaround based on: https://github.com/facebook/react/issues/11565 import type { ComponentProps, ReactNode } from 'react'; -import { renderHook, waitFor, act } from '@testing-library/react'; +import { renderHook, waitFor } from '@testing-library/react'; +import type { ClientOptions } from '@vegaprotocol/apollo-client'; import { createClient } from '@vegaprotocol/apollo-client'; import { useEnvironment, EnvironmentProvider } from './use-environment'; import { Networks, ErrorType } from '../types'; @@ -32,6 +33,7 @@ const mockEnvironmentState = { VEGA_ENV: Networks.TESTNET, VEGA_CONFIG_URL: 'https://vega.xyz/testnet-config.json', VEGA_NETWORKS: { + DEVNET: 'https://devnet.url', TESTNET: 'https://testnet.url', STAGNET3: 'https://stagnet3.url', MAINNET: 'https://mainnet.url', @@ -42,6 +44,10 @@ const mockEnvironmentState = { GIT_ORIGIN_URL: 'https://github.com/test/repo', GIT_COMMIT_HASH: 'abcde01234', GITHUB_FEEDBACK_URL: 'https://github.com/test/feedback', + MAINTENANCE_PAGE: false, + configLoading: false, + blockDifference: 0, + nodeSwitcherOpen: false, setNodeSwitcherOpen: noop, networkError: undefined, }; @@ -96,7 +102,7 @@ const getQuickestNode = (mockNodes: Record) => { }; beforeEach(() => { - // @ts-ignore: typescript doesn't recognize the mock implementation + // @ts-ignore: typscript doesn't recognise the mock implementation global.fetch.mockImplementation(setupFetch()); window.localStorage.clear(); @@ -129,6 +135,7 @@ describe('useEnvironment hook', () => { const { result } = renderHook(() => useEnvironment(), { wrapper: MockWrapper, }); + await waitFor(() => { expect(result.current).toEqual({ ...mockEnvironmentState, @@ -136,39 +143,67 @@ describe('useEnvironment hook', () => { }); }); }); -}); -it('allows for the VEGA_CONFIG_URL to be missing when there is a VEGA_URL present', async () => { - delete process.env['NX_VEGA_CONFIG_URL']; - const { result } = renderHook(() => useEnvironment(), { - wrapper: MockWrapper, - }); - - expect(result.current).toEqual({ - ...mockEnvironmentState, - VEGA_CONFIG_URL: undefined, - setNodeSwitcherOpen: result.current.setNodeSwitcherOpen, - }); -}); - -it('allows for the VEGA_NETWORKS to be missing from the environment', async () => { - act(async () => { - delete process.env['NX_VEGA_NETWORKS']; + it('allows for the VEGA_CONFIG_URL to be missing when there is a VEGA_URL present', async () => { + delete process.env['NX_VEGA_CONFIG_URL']; const { result } = renderHook(() => useEnvironment(), { wrapper: MockWrapper, }); await waitFor(() => { expect(result.current).toEqual({ ...mockEnvironmentState, - VEGA_NETWORKS: {}, + VEGA_CONFIG_URL: undefined, setNodeSwitcherOpen: result.current.setNodeSwitcherOpen, }); }); }); -}); -it('when VEGA_NETWORKS is not a valid json, prints a warning and continues without using the value from it', async () => { - act(async () => { + it('allows for the VEGA_NETWORKS to be missing from the environment', async () => { + delete process.env['NX_VEGA_NETWORKS']; + const { result } = renderHook(() => useEnvironment(), { + wrapper: MockWrapper, + }); + + await waitFor(() => { + expect(result.current).toEqual({ + ...mockEnvironmentState, + VEGA_NETWORKS: { + TESTNET: window.location.origin, + }, + setNodeSwitcherOpen: result.current.setNodeSwitcherOpen, + }); + }); + }); + + it('throws a validation error when NX_VEGA_ENV is not found in the environment', async () => { + delete process.env['NX_VEGA_ENV']; + const consoleError = console.error; + console.error = noop; + expect(() => { + renderHook(() => useEnvironment(), { + wrapper: MockWrapper, + }); + }).toThrowError( + `NX_VEGA_ENV is invalid, received "undefined" instead of: 'CUSTOM' | 'SANDBOX' | 'TESTNET' | 'STAGNET1' | 'STAGNET3' | 'DEVNET' | 'MAINNET' | 'MIRROR'` + ); + console.error = consoleError; + }); + + it('throws a validation error when VEGA_ENV is not a valid network', async () => { + process.env['NX_VEGA_ENV'] = 'SOMETHING'; + const consoleError = console.error; + console.error = noop; + expect(() => { + renderHook(() => useEnvironment(), { + wrapper: MockWrapper, + }); + }).toThrowError( + `NX_VEGA_ENV is invalid, received "SOMETHING" instead of: CUSTOM | SANDBOX | TESTNET | STAGNET1 | STAGNET3 | DEVNET | MAINNET | MIRROR` + ); + console.error = consoleError; + }); + + it('when VEGA_NETWORKS is not a valid json, prints a warning and continues without using the value from it', async () => { const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(noop); process.env['NX_VEGA_NETWORKS'] = '{not:{valid:json'; const { result } = renderHook(() => useEnvironment(), { @@ -177,26 +212,56 @@ it('when VEGA_NETWORKS is not a valid json, prints a warning and continues witho await waitFor(() => { expect(result.current).toEqual({ ...mockEnvironmentState, - VEGA_NETWORKS: {}, + VEGA_NETWORKS: { + TESTNET: window.location.origin, + }, setNodeSwitcherOpen: result.current.setNodeSwitcherOpen, }); }); - expect(consoleWarnSpy).toHaveBeenCalled(); consoleWarnSpy.mockRestore(); }); -}); -it.each` - env | etherscanUrl | providerUrl - ${Networks.DEVNET} | ${'https://sepolia.etherscan.io'} | ${'https://sepolia.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8'} - ${Networks.TESTNET} | ${'https://sepolia.etherscan.io'} | ${'https://sepolia.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8'} - ${Networks.STAGNET3} | ${'https://sepolia.etherscan.io'} | ${'https://sepolia.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8'} - ${Networks.MAINNET} | ${'https://etherscan.io'} | ${'https://mainnet.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8'} -`( - 'uses correct default ethereum connection variables in $env', - async ({ env, etherscanUrl, providerUrl }) => { - act(async () => { + it('throws a validation error when VEGA_NETWORKS has an invalid network as a key', async () => { + process.env['NX_VEGA_NETWORKS'] = JSON.stringify({ + NOT_A_NETWORK: 'https://somewhere.url', + }); + const consoleError = console.error; + console.error = noop; + expect(() => { + renderHook(() => useEnvironment(), { + wrapper: MockWrapper, + }); + }).toThrowError( + `All keys in NX_VEGA_NETWORKS must represent a valid environment: CUSTOM | SANDBOX | TESTNET | STAGNET1 | STAGNET3 | DEVNET | MAINNET | MIRROR` + ); + console.error = consoleError; + }); + + it('throws a validation error when both VEGA_URL and VEGA_CONFIG_URL are missing in the environment', async () => { + delete process.env['NX_VEGA_URL']; + delete process.env['NX_VEGA_CONFIG_URL']; + const consoleError = console.error; + console.error = noop; + expect(() => { + renderHook(() => useEnvironment(), { + wrapper: MockWrapper, + }); + }).toThrowError( + `Must provide either NX_VEGA_CONFIG_URL or NX_VEGA_URL in the environment.` + ); + console.error = consoleError; + }); + + it.each` + env | etherscanUrl | providerUrl + ${Networks.DEVNET} | ${'https://sepolia.etherscan.io'} | ${'https://sepolia.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8'} + ${Networks.TESTNET} | ${'https://sepolia.etherscan.io'} | ${'https://sepolia.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8'} + ${Networks.STAGNET3} | ${'https://sepolia.etherscan.io'} | ${'https://sepolia.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8'} + ${Networks.MAINNET} | ${'https://etherscan.io'} | ${'https://mainnet.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8'} + `( + 'uses correct default ethereum connection variables in $env', + async ({ env, etherscanUrl, providerUrl }) => { // @ts-ignore allow adding a mock return value to mocked module createClient.mockImplementation(() => createMockClient({ network: env })); @@ -215,13 +280,39 @@ it.each` setNodeSwitcherOpen: result.current.setNodeSwitcherOpen, }); }); - }); - } -); + } + ); -describe('node selection', () => { - it('updates the VEGA_URL from the config when it is missing from the environment', async () => { - act(async () => { + it('throws a validation error when NX_ETHERSCAN_URL is not a valid url', async () => { + process.env['NX_ETHERSCAN_URL'] = 'invalid-url'; + const consoleError = console.error; + console.error = noop; + expect(() => { + renderHook(() => useEnvironment(), { + wrapper: MockWrapper, + }); + }).toThrowError( + `The NX_ETHERSCAN_URL environment variable must be a valid url` + ); + console.error = consoleError; + }); + + it('throws a validation error when NX_ETHEREUM_PROVIDER_URL is not a valid url', async () => { + process.env['NX_ETHEREUM_PROVIDER_URL'] = 'invalid-url'; + const consoleError = console.error; + console.error = noop; + expect(() => { + renderHook(() => useEnvironment(), { + wrapper: MockWrapper, + }); + }).toThrow( + `The NX_ETHEREUM_PROVIDER_URL environment variable must be a valid url` + ); + console.error = consoleError; + }); + + describe('node selection', () => { + it('updates the VEGA_URL from the config when it is missing from the environment', async () => { delete process.env['NX_VEGA_URL']; const { result } = renderHook(() => useEnvironment(), { wrapper: MockWrapper, @@ -235,10 +326,9 @@ describe('node selection', () => { }); }); }); - }); - it('updates the VEGA_URL with the quickest node to respond from the config urls', async () => { - act(async () => { + // eslint-disable-next-line jest/no-disabled-tests + it.skip('updates the VEGA_URL with the quickest node to respond from the config urls', async () => { delete process.env['NX_VEGA_URL']; const mockNodes: Record = { @@ -248,13 +338,14 @@ describe('node selection', () => { 'https://mock-node-4.com': { hasError: false, delay: 0 }, }; - // @ts-ignore: typescript doesn't recognize the mock implementation + // @ts-ignore: typscript doesn't recognise the mock implementation global.fetch.mockImplementation( setupFetch(mockEnvironmentState.VEGA_CONFIG_URL, Object.keys(mockNodes)) ); // @ts-ignore allow adding a mock return value to mocked module - createClient.mockImplementation((url: keyof typeof mockNodes) => { - return createMockClient({ statistics: mockNodes[url] }); + createClient.mockImplementation((cfg: ClientOptions) => { + // eslint-disable-next-line + return createMockClient({ statistics: mockNodes[cfg.url!] }); }); const nodeUrl = getQuickestNode(mockNodes); @@ -271,10 +362,8 @@ describe('node selection', () => { }); }); }); - }); - it('ignores failing nodes and selects the first successful one to use', async () => { - act(async () => { + it('ignores failing nodes and selects the first successful one to use', async () => { delete process.env['NX_VEGA_URL']; const mockNodes: Record = { @@ -284,13 +373,14 @@ describe('node selection', () => { 'https://mock-node-4.com': { hasError: true, delay: 0 }, }; - // @ts-ignore: typescript doesn't recognize the mock implementation + // @ts-ignore: typscript doesn't recognise the mock implementation global.fetch.mockImplementation( setupFetch(mockEnvironmentState.VEGA_CONFIG_URL, Object.keys(mockNodes)) ); // @ts-ignore allow adding a mock return value to mocked module - createClient.mockImplementation((url: keyof typeof mockNodes) => { - return createMockClient({ statistics: mockNodes[url] }); + createClient.mockImplementation((cfg: ClientOptions) => { + // eslint-disable-next-line + return createMockClient({ statistics: mockNodes[cfg.url!] }); }); const nodeUrl = getQuickestNode(mockNodes); @@ -307,10 +397,8 @@ describe('node selection', () => { }); }); }); - }); - it('has a network error when cannot connect to any nodes', async () => { - act(async () => { + it('has a network error when cannot connect to any nodes', async () => { delete process.env['NX_VEGA_URL']; const mockNodes: Record = { @@ -320,13 +408,14 @@ describe('node selection', () => { 'https://mock-node-4.com': { hasError: true, delay: 0 }, }; - // @ts-ignore: typescript doesn't recognize the mock implementation + // @ts-ignore: typscript doesn't recognise the mock implementation global.fetch.mockImplementation( setupFetch(mockEnvironmentState.VEGA_CONFIG_URL, Object.keys(mockNodes)) ); // @ts-ignore allow adding a mock return value to mocked module - createClient.mockImplementation((url: keyof typeof mockNodes) => { - return createMockClient({ statistics: mockNodes[url] }); + createClient.mockImplementation((cfg: ClientOptions) => { + // eslint-disable-next-line + return createMockClient({ statistics: mockNodes[cfg.url!] }); }); const { result } = renderHook(() => useEnvironment(), { @@ -338,17 +427,16 @@ describe('node selection', () => { ...mockEnvironmentState, VEGA_URL: undefined, networkError: ErrorType.CONNECTION_ERROR_ALL, + nodeSwitcherOpen: true, setNodeSwitcherOpen: result.current.setNodeSwitcherOpen, }); }); }); - }); - it('has a network error when it cannot fetch the network config and there is no VEGA_URL in the environment', async () => { - act(async () => { + it('has a network error when it cannot fetch the network config and there is no VEGA_URL in the environment', async () => { delete process.env['NX_VEGA_URL']; - // @ts-ignore: typescript doesn't recognize the mock implementation + // @ts-ignore: typscript doesn't recognise the mock implementation global.fetch.mockImplementation(() => { throw new Error('Cannot fetch'); }); @@ -362,19 +450,18 @@ describe('node selection', () => { ...mockEnvironmentState, VEGA_URL: undefined, networkError: ErrorType.CONFIG_LOAD_ERROR, + nodeSwitcherOpen: true, setNodeSwitcherOpen: result.current.setNodeSwitcherOpen, }); }); }); - }); - it('logs an error when it cannot fetch the network config and there is a VEGA_URL in the environment', async () => { - act(async () => { + it('logs an error when it cannot fetch the network config and there is a VEGA_URL in the environment', async () => { const consoleWarnSpy = jest .spyOn(console, 'warn') .mockImplementation(noop); - // @ts-ignore: typescript doesn't recognize the mock implementation + // @ts-ignore: typscript doesn't recognise the mock implementation global.fetch.mockImplementation(() => { throw new Error('Cannot fetch'); }); @@ -386,6 +473,7 @@ describe('node selection', () => { await waitFor(() => { expect(result.current).toEqual({ ...mockEnvironmentState, + nodeSwitcherOpen: false, setNodeSwitcherOpen: result.current.setNodeSwitcherOpen, }); expect(consoleWarnSpy).toHaveBeenCalledWith( @@ -396,15 +484,13 @@ describe('node selection', () => { ); }); }); - }); - // SKIP due to https://github.com/facebook/jest/issues/12670 - // eslint-disable-next-line jest/no-disabled-tests - it.skip('has a network error when the config is invalid and there is no VEGA_URL in the environment', async () => { - act(async () => { + // SKIP due to https://github.com/facebook/jest/issues/12670 + // eslint-disable-next-line jest/no-disabled-tests + it.skip('has a network error when the config is invalid and there is no VEGA_URL in the environment', async () => { delete process.env['NX_VEGA_URL']; - // @ts-ignore: typescript doesn't recognize the mock implementation + // @ts-ignore: typscript doesn't recognise the mock implementation global.fetch.mockImplementation(() => Promise.resolve({ ok: true, @@ -421,21 +507,20 @@ describe('node selection', () => { ...mockEnvironmentState, VEGA_URL: undefined, networkError: ErrorType.CONFIG_VALIDATION_ERROR, + nodeSwitcherOpen: true, setNodeSwitcherOpen: result.current.setNodeSwitcherOpen, }); }); }); - }); - // SKIP due to https://github.com/facebook/jest/issues/12670 - // eslint-disable-next-line jest/no-disabled-tests - it.skip('logs an error when the network config in invalid and there is a VEGA_URL in the environment', async () => { - act(async () => { + // SKIP due to https://github.com/facebook/jest/issues/12670 + // eslint-disable-next-line jest/no-disabled-tests + it.skip('logs an error when the network config is invalid and there is a VEGA_URL in the environment', async () => { const consoleWarnSpy = jest .spyOn(console, 'warn') .mockImplementation(noop); - // @ts-ignore: typescript doesn't recognize the mock implementation + // @ts-ignore: typscript doesn't recognise the mock implementation global.fetch.mockImplementation(() => Promise.resolve({ ok: true, @@ -447,6 +532,8 @@ describe('node selection', () => { wrapper: MockWrapper, }); + expect(result.current.configLoading).toBe(true); + await waitFor(() => { expect(result.current).toEqual({ ...mockEnvironmentState, @@ -460,12 +547,8 @@ describe('node selection', () => { ); }); }); - }); - // SKIP due to https://github.com/facebook/jest/issues/12670 - // eslint-disable-next-line jest/no-disabled-tests - it.skip('has a network error when the selected node is not a valid url', async () => { - act(async () => { + it('has a network error when the selected node is not a valid url', async () => { process.env['NX_VEGA_URL'] = 'not-url'; const { result } = renderHook(() => useEnvironment(), { @@ -475,15 +558,15 @@ describe('node selection', () => { await waitFor(() => { expect(result.current).toEqual({ ...mockEnvironmentState, + VEGA_URL: 'not-url', + nodeSwitcherOpen: true, networkError: ErrorType.INVALID_URL, setNodeSwitcherOpen: result.current.setNodeSwitcherOpen, }); }); }); - }); - it('has a network error when cannot connect to the selected node', async () => { - act(async () => { + it('has a network error when cannot connect to the selected node', async () => { // @ts-ignore allow adding a mock return value to mocked module createClient.mockImplementation(() => { return createMockClient({ statistics: { hasError: true } }); @@ -496,15 +579,14 @@ describe('node selection', () => { await waitFor(() => { expect(result.current).toEqual({ ...mockEnvironmentState, + nodeSwitcherOpen: true, networkError: ErrorType.CONNECTION_ERROR, setNodeSwitcherOpen: result.current.setNodeSwitcherOpen, }); }); }); - }); - it('has a network error when the selected node has not subscription available', async () => { - act(async () => { + it('has a network error when the selected node has no subscription available', async () => { // @ts-ignore allow adding a mock return value to mocked module createClient.mockImplementation(() => { return createMockClient({ busEvents: { hasError: true } }); @@ -519,6 +601,7 @@ describe('node selection', () => { ...mockEnvironmentState, networkError: ErrorType.SUBSCRIPTION_ERROR, setNodeSwitcherOpen: result.current.setNodeSwitcherOpen, + nodeSwitcherOpen: true, }); }); }); diff --git a/libs/environment/src/hooks/use-environment.tsx b/libs/environment/src/hooks/use-environment.tsx index f1112c826..4f27082dc 100644 --- a/libs/environment/src/hooks/use-environment.tsx +++ b/libs/environment/src/hooks/use-environment.tsx @@ -25,6 +25,7 @@ import type { NodeData, Configuration, } from '../types'; +import { useNodeHealth } from './use-node-health'; type EnvironmentProviderProps = { config?: Configuration; @@ -33,7 +34,10 @@ type EnvironmentProviderProps = { }; export type EnvironmentState = Environment & { + configLoading: boolean; networkError?: ErrorType; + blockDifference: number; + nodeSwitcherOpen: boolean; setNodeSwitcherOpen: () => void; }; @@ -76,10 +80,14 @@ export const EnvironmentProvider = ({ } } ); + const { state: nodes, clients } = useNodes( config, environment.MAINTENANCE_PAGE ); + + const blockDifference = useNodeHealth(clients, environment.VEGA_URL); + const nodeKeys = Object.keys(nodes); useEffect(() => { @@ -89,9 +97,10 @@ export const EnvironmentProvider = ({ ); if (successfulNodeKey && nodes[successfulNodeKey]) { Object.keys(clients).forEach((node) => clients[node]?.stop()); + const url = nodes[successfulNodeKey].url; updateEnvironment((prevEnvironment) => ({ ...prevEnvironment, - VEGA_URL: nodes[successfulNodeKey].url, + VEGA_URL: url, })); } } @@ -139,7 +148,10 @@ export const EnvironmentProvider = ({ setNodeSwitcherOpen(true), }} > @@ -149,9 +161,9 @@ export const EnvironmentProvider = ({ setDialogOpen={setNodeSwitcherOpen} loading={loading} config={config} - onConnect={(url) => - updateEnvironment((env) => ({ ...env, VEGA_URL: url })) - } + onConnect={(url) => { + updateEnvironment((env) => ({ ...env, VEGA_URL: url })); + }} /> {children} diff --git a/libs/environment/src/hooks/use-node-health.spec.tsx b/libs/environment/src/hooks/use-node-health.spec.tsx new file mode 100644 index 000000000..5ca96ab26 --- /dev/null +++ b/libs/environment/src/hooks/use-node-health.spec.tsx @@ -0,0 +1,117 @@ +import { act, renderHook } from '@testing-library/react'; +import { + useNodeHealth, + NODE_SUBSET_COUNT, + INTERVAL_TIME, +} from './use-node-health'; +import type { createClient } from '@vegaprotocol/apollo-client'; +import type { ClientCollection } from './use-nodes'; + +function setup(...args: Parameters) { + return renderHook(() => useNodeHealth(...args)); +} + +function createMockClient(blockHeight: number) { + return { + query: jest.fn().mockResolvedValue({ + data: { + statistics: { + chainId: 'chain-id', + blockHeight: blockHeight.toString(), + }, + }, + }), + } as unknown as ReturnType; +} + +function createRejectingClient() { + return { + query: () => Promise.reject(new Error('request failed')), + } as unknown as ReturnType; +} + +function createErroringClient() { + return { + query: () => + Promise.resolve({ + error: new Error('failed'), + }), + } as unknown as ReturnType; +} + +const CURRENT_URL = 'https://current.test.com'; + +describe('useNodeHealth', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + it('provides difference between the highest block and the current block', async () => { + const highest = 100; + const curr = 97; + const clientCollection: ClientCollection = { + [CURRENT_URL]: createMockClient(curr), + 'https://n02.test.com': createMockClient(98), + 'https://n03.test.com': createMockClient(highest), + }; + const { result } = setup(clientCollection, CURRENT_URL); + await act(async () => { + jest.advanceTimersByTime(INTERVAL_TIME); + }); + expect(result.current).toBe(highest - curr); + }); + + it('returns -1 if the current node query fails', async () => { + const clientCollection: ClientCollection = { + [CURRENT_URL]: createRejectingClient(), + 'https://n02.test.com': createMockClient(200), + 'https://n03.test.com': createMockClient(102), + }; + const { result } = setup(clientCollection, CURRENT_URL); + await act(async () => { + jest.advanceTimersByTime(INTERVAL_TIME); + }); + expect(result.current).toBe(-1); + }); + + it('returns -1 if the current node query returns an error', async () => { + const clientCollection: ClientCollection = { + [CURRENT_URL]: createErroringClient(), + 'https://n02.test.com': createMockClient(200), + 'https://n03.test.com': createMockClient(102), + }; + const { result } = setup(clientCollection, CURRENT_URL); + await act(async () => { + jest.advanceTimersByTime(INTERVAL_TIME); + }); + expect(result.current).toBe(-1); + }); + + it('queries against 5 random nodes along with the current url', async () => { + const clientCollection: ClientCollection = new Array(20) + .fill(null) + .reduce((obj, x, i) => { + obj[`https://n${i}.test.com`] = createMockClient(100); + return obj; + }, {} as ClientCollection); + clientCollection[CURRENT_URL] = createMockClient(100); + const spyOnCurrent = jest.spyOn(clientCollection[CURRENT_URL], 'query'); + + const { result } = setup(clientCollection, CURRENT_URL); + await act(async () => { + jest.advanceTimersByTime(INTERVAL_TIME); + }); + + let count = 0; + Object.values(clientCollection).forEach((client) => { + // @ts-ignore jest.fn() in client setup means mock will be present + if (client?.query.mock.calls.length) { + count++; + } + }); + + expect(count).toBe(NODE_SUBSET_COUNT + 1); + expect(spyOnCurrent).toHaveBeenCalledTimes(1); + expect(result.current).toBe(0); + }); +}); diff --git a/libs/environment/src/hooks/use-node-health.ts b/libs/environment/src/hooks/use-node-health.ts new file mode 100644 index 000000000..e54cddfaa --- /dev/null +++ b/libs/environment/src/hooks/use-node-health.ts @@ -0,0 +1,88 @@ +import compact from 'lodash/compact'; +import shuffle from 'lodash/shuffle'; +import type { createClient } from '@vegaprotocol/apollo-client'; +import { useEffect, useState } from 'react'; +import type { StatisticsQuery } from '../utils/__generated__/Node'; +import { StatisticsDocument } from '../utils/__generated__/Node'; +import type { ClientCollection } from './use-nodes'; + +// How often to query other nodes +export const INTERVAL_TIME = 30 * 1000; +// Number of nodes to query against +export const NODE_SUBSET_COUNT = 5; + +// Queries all nodes from the environment provider via an interval +// to calculate and return the difference between the most advanced block +// and the block height of the current node +export const useNodeHealth = (clients: ClientCollection, vegaUrl?: string) => { + const [blockDiff, setBlockDiff] = useState(0); + + useEffect(() => { + if (!clients || !vegaUrl) return; + + const fetchBlockHeight = async ( + client?: ReturnType + ) => { + try { + const result = await client?.query({ + query: StatisticsDocument, + fetchPolicy: 'no-cache', // always fetch and never cache + }); + + if (!result) return null; + if (result.error) return null; + return result; + } catch { + return null; + } + }; + + const getBlockHeights = async () => { + const nodes = Object.keys(clients).filter((key) => key !== vegaUrl); + // make sure that your current vega url is always included + // so we can compare later + const testNodes = [vegaUrl, ...randomSubset(nodes, NODE_SUBSET_COUNT)]; + const result = await Promise.all( + testNodes.map((node) => fetchBlockHeight(clients[node])) + ); + const blockHeights: { [node: string]: number | null } = {}; + testNodes.forEach((node, i) => { + const data = result[i]; + const blockHeight = data + ? Number(data?.data.statistics.blockHeight) + : null; + blockHeights[node] = blockHeight; + }); + return blockHeights; + }; + + // Every INTERVAL_TIME get block heights of a random subset + // of nodes and determine if your current node is falling behind + const interval = setInterval(async () => { + const blockHeights = await getBlockHeights(); + const highestBlock = Math.max.apply( + null, + compact(Object.values(blockHeights)) + ); + const currNodeBlock = blockHeights[vegaUrl]; + + if (!currNodeBlock) { + // Block height query failed and null was returned + setBlockDiff(-1); + } else { + setBlockDiff(highestBlock - currNodeBlock); + } + }, INTERVAL_TIME); + + return () => { + clearInterval(interval); + }; + }, [clients, vegaUrl]); + + return blockDiff; +}; + +const randomSubset = (arr: string[], size: number) => { + const shuffled = shuffle(arr); + return shuffled.slice(0, size); +}; diff --git a/libs/environment/src/hooks/use-nodes.tsx b/libs/environment/src/hooks/use-nodes.tsx index ba8255ff4..9935056dc 100644 --- a/libs/environment/src/hooks/use-nodes.tsx +++ b/libs/environment/src/hooks/use-nodes.tsx @@ -74,7 +74,7 @@ const getInitialState = (config?: Configuration) => {} ); -type ClientCollection = Record< +export type ClientCollection = Record< string, undefined | ReturnType >; @@ -178,6 +178,10 @@ const reducer = (state: Record, action: Action) => { } }; +/** + * Tests each node to see if its suitable for connecting to and returns that data + * as a map of node urls to an object of that data + */ export const useNodes = (config?: Configuration, skip?: boolean) => { const [clients, setClients] = useState({}); const [state, dispatch] = useReducer(reducer, getInitialState(config)); diff --git a/libs/environment/src/utils/compile-environment.ts b/libs/environment/src/utils/compile-environment.ts index 8241ecb06..ff1b3bee7 100644 --- a/libs/environment/src/utils/compile-environment.ts +++ b/libs/environment/src/utils/compile-environment.ts @@ -83,9 +83,7 @@ const getBundledEnvironmentValue = (key: EnvKey) => { case 'ETH_WALLET_MNEMONIC': return process.env['NX_ETH_WALLET_MNEMONIC']; case 'MAINTENANCE_PAGE': - return ( - process.env['MAINTENANCE_PAGE'] || process.env['NX_MAINTENANCE_PAGE'] - ); + return process.env['NX_MAINTENANCE_PAGE']; } }; @@ -110,7 +108,7 @@ export const compileEnvironment = ( const environment = ENV_KEYS.reduce((acc, key) => { const value = getValue(key, definitions); - if (value) { + if (value !== undefined && value !== null) { return { ...acc, [key]: value, diff --git a/libs/environment/src/utils/request-node.ts b/libs/environment/src/utils/request-node.ts index 8dec63107..808dd9db5 100644 --- a/libs/environment/src/utils/request-node.ts +++ b/libs/environment/src/utils/request-node.ts @@ -38,7 +38,11 @@ export const requestNode = ( let subscriptionSucceeded = false; - const client = createClient(url); + const client = createClient({ + url, + retry: false, + connectToDevTools: false, + }); // make a query for block height client diff --git a/libs/react-helpers/src/hooks/index.ts b/libs/react-helpers/src/hooks/index.ts index 143736148..3567c15a9 100644 --- a/libs/react-helpers/src/hooks/index.ts +++ b/libs/react-helpers/src/hooks/index.ts @@ -4,6 +4,7 @@ export * from './use-data-provider'; export * from './use-fetch'; export * from './use-mutation-observer'; export * from './use-network-params'; +export * from './use-navigator-online'; export * from './use-outside-click'; export * from './use-resize-observer'; export * from './use-resize'; diff --git a/libs/react-helpers/src/hooks/use-navigator-online.spec.ts b/libs/react-helpers/src/hooks/use-navigator-online.spec.ts new file mode 100644 index 000000000..36d3321ed --- /dev/null +++ b/libs/react-helpers/src/hooks/use-navigator-online.spec.ts @@ -0,0 +1,29 @@ +import { act, fireEvent, renderHook } from '@testing-library/react'; +import { useNavigatorOnline } from './use-navigator-online'; + +const setup = () => { + return renderHook(() => useNavigatorOnline()); +}; + +const turnOn = () => { + jest.spyOn(window.navigator, 'onLine', 'get').mockReturnValue(true); + fireEvent(window, new Event('online')); +}; + +const turnOff = () => { + jest.spyOn(window.navigator, 'onLine', 'get').mockReturnValue(false); + fireEvent(window, new Event('offline')); +}; + +describe('useNavigatorOnline', () => { + it('returns true if connected and false if not', () => { + const { result } = setup(); + expect(result.current).toBe(true); + + act(turnOff); + expect(result.current).toBe(false); + + act(turnOn); + expect(result.current).toBe(true); + }); +}); diff --git a/libs/react-helpers/src/hooks/use-navigator-online.ts b/libs/react-helpers/src/hooks/use-navigator-online.ts new file mode 100644 index 000000000..b11679c81 --- /dev/null +++ b/libs/react-helpers/src/hooks/use-navigator-online.ts @@ -0,0 +1,16 @@ +import { useSyncExternalStore } from 'react'; + +const subscribe = (onStoreChange: () => void) => { + window.addEventListener('online', onStoreChange); + window.addEventListener('offline', onStoreChange); + return () => { + window.removeEventListener('online', onStoreChange); + window.removeEventListener('offline', onStoreChange); + }; +}; +export const useNavigatorOnline = () => + useSyncExternalStore( + subscribe, + () => window.navigator.onLine, + () => true + ); diff --git a/libs/ui-toolkit/src/components/indicator/indicator.tsx b/libs/ui-toolkit/src/components/indicator/indicator.tsx index f3bd84717..80deeaab2 100644 --- a/libs/ui-toolkit/src/components/indicator/indicator.tsx +++ b/libs/ui-toolkit/src/components/indicator/indicator.tsx @@ -11,5 +11,5 @@ export const Indicator = ({ variant = Intent.None }: IndicatorProps) => { 'inline-block w-2 h-2 mt-1 mr-2 rounded-full', getIntentTextAndBackground(variant) ); - return ; + return ; };