diff --git a/libs/environment/.eslintrc.json b/libs/environment/.eslintrc.json index 734ddacee..db820c5d0 100644 --- a/libs/environment/.eslintrc.json +++ b/libs/environment/.eslintrc.json @@ -1,6 +1,6 @@ { "extends": ["plugin:@nrwl/nx/react", "../../.eslintrc.json"], - "ignorePatterns": ["!**/*"], + "ignorePatterns": ["!**/*", "__generated__"], "overrides": [ { "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], diff --git a/libs/environment/src/components/index.ts b/libs/environment/src/components/index.ts index ec29fafd4..34e06b5c6 100644 --- a/libs/environment/src/components/index.ts +++ b/libs/environment/src/components/index.ts @@ -1,3 +1,5 @@ export * from './network-loader'; export * from './network-switcher'; export * from './network-switcher-dialog'; +export * from './node-switcher'; +export * from './node-switcher-dialog'; diff --git a/libs/environment/src/components/node-switcher-dialog/index.tsx b/libs/environment/src/components/node-switcher-dialog/index.tsx new file mode 100644 index 000000000..cc6231794 --- /dev/null +++ b/libs/environment/src/components/node-switcher-dialog/index.tsx @@ -0,0 +1 @@ +export * from './node-switcher-dialog'; diff --git a/libs/environment/src/components/node-switcher-dialog/node-switcher-dialog.tsx b/libs/environment/src/components/node-switcher-dialog/node-switcher-dialog.tsx new file mode 100644 index 000000000..680be87f7 --- /dev/null +++ b/libs/environment/src/components/node-switcher-dialog/node-switcher-dialog.tsx @@ -0,0 +1,27 @@ +import type { ComponentProps } from 'react'; +import { Dialog } from '@vegaprotocol/ui-toolkit'; +import { NodeSwitcher } from '../node-switcher'; + +type NodeSwitcherDialogProps = ComponentProps & { + dialogOpen: boolean; + toggleDialogOpen: (dialogOpen: boolean) => void; +}; + +export const NodeSwitcherDialog = ({ + config, + dialogOpen, + toggleDialogOpen, + onConnect, +}: NodeSwitcherDialogProps) => { + return ( + + { + onConnect(url); + toggleDialogOpen(false); + }} + /> + + ); +}; diff --git a/libs/environment/src/components/node-switcher/__generated__/BlockHeightStats.ts b/libs/environment/src/components/node-switcher/__generated__/BlockHeightStats.ts new file mode 100644 index 000000000..574ae149e --- /dev/null +++ b/libs/environment/src/components/node-switcher/__generated__/BlockHeightStats.ts @@ -0,0 +1,23 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL query operation: BlockHeightStats +// ==================================================== + +export interface BlockHeightStats_statistics { + __typename: "Statistics"; + /** + * Current block number + */ + blockHeight: string; +} + +export interface BlockHeightStats { + /** + * get statistics about the vega node + */ + statistics: BlockHeightStats_statistics; +} diff --git a/libs/environment/src/components/node-switcher/index.tsx b/libs/environment/src/components/node-switcher/index.tsx new file mode 100644 index 000000000..b1acd80e3 --- /dev/null +++ b/libs/environment/src/components/node-switcher/index.tsx @@ -0,0 +1 @@ +export * from './node-switcher'; diff --git a/libs/environment/src/components/node-switcher/layout-cell.tsx b/libs/environment/src/components/node-switcher/layout-cell.tsx new file mode 100644 index 000000000..c60063037 --- /dev/null +++ b/libs/environment/src/components/node-switcher/layout-cell.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from 'react'; +import classnames from 'classnames'; +import { t } from '@vegaprotocol/react-helpers'; + +type LayoutCellProps = { + hasError?: boolean; + isLoading?: boolean; + children?: ReactNode; +}; + +export const LayoutCell = ({ + hasError, + isLoading, + children, +}: LayoutCellProps) => { + return ( +
+ {isLoading ? t('Checking') : children || '-'} +
+ ); +}; diff --git a/libs/environment/src/components/node-switcher/layout-row.tsx b/libs/environment/src/components/node-switcher/layout-row.tsx new file mode 100644 index 000000000..d2b274d8e --- /dev/null +++ b/libs/environment/src/components/node-switcher/layout-row.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from 'react'; + +type LayoutRowProps = { + children?: ReactNode; +}; + +export const LayoutRow = ({ children }: LayoutRowProps) => { + return ( +
+ {children} +
+ ); +}; diff --git a/libs/environment/src/components/node-switcher/node-block-height.tsx b/libs/environment/src/components/node-switcher/node-block-height.tsx new file mode 100644 index 000000000..6d02cb2a7 --- /dev/null +++ b/libs/environment/src/components/node-switcher/node-block-height.tsx @@ -0,0 +1,46 @@ +import { useEffect } from 'react'; +import { gql, useQuery } from '@apollo/client'; +import type { BlockHeightStats } from './__generated__/BlockHeightStats'; + +type NodeBlockHeightProps = { + value?: number; + setValue: (value: number) => void; +}; + +const POLL_INTERVAL = 3000; + +const BLOCK_HEIGHT_QUERY = gql` + query BlockHeightStats { + statistics { + blockHeight + } + } +`; + +export const NodeBlockHeight = ({ value, setValue }: NodeBlockHeightProps) => { + const { data, startPolling, stopPolling } = useQuery( + BLOCK_HEIGHT_QUERY, + { + pollInterval: POLL_INTERVAL, + } + ); + + useEffect(() => { + const handleStartPoll = () => startPolling(POLL_INTERVAL); + const handleStopPoll = () => stopPolling(); + window.addEventListener('blur', handleStopPoll); + window.addEventListener('focus', handleStartPoll); + return () => { + window.removeEventListener('blur', handleStopPoll); + window.removeEventListener('focus', handleStartPoll); + }; + }, [startPolling, stopPolling]); + + useEffect(() => { + if (data?.statistics?.blockHeight) { + setValue(Number(data.statistics.blockHeight)); + } + }, [setValue, data?.statistics?.blockHeight]); + + return {value ?? '-'}; +}; diff --git a/libs/environment/src/components/node-switcher/node-error.tsx b/libs/environment/src/components/node-switcher/node-error.tsx new file mode 100644 index 000000000..97e316117 --- /dev/null +++ b/libs/environment/src/components/node-switcher/node-error.tsx @@ -0,0 +1,3 @@ +export const NodeError = () => { + return
; +}; diff --git a/libs/environment/src/components/node-switcher/node-stats.tsx b/libs/environment/src/components/node-switcher/node-stats.tsx new file mode 100644 index 000000000..c6e55fcb1 --- /dev/null +++ b/libs/environment/src/components/node-switcher/node-stats.tsx @@ -0,0 +1,142 @@ +import type { ReactNode } from 'react'; +import { useEffect, useState, useCallback } from 'react'; +import { ApolloProvider } from '@apollo/client'; +import { t } from '@vegaprotocol/react-helpers'; +import type { NodeData } from '../../types'; +import { createClient } from '../../utils/apollo-client'; +import { useNode } from '../../hooks/use-node'; +import { LayoutRow } from './layout-row'; +import { LayoutCell } from './layout-cell'; +import { NodeBlockHeight } from './node-block-height'; + +type NodeStatsContentProps = { + data: NodeData; + highestBlock: number; + setBlock: (value: number) => void; + children: ReactNode; +}; + +const getResponseTimeDisplayValue = ( + responseTime: NodeData['responseTime'] +) => { + if (typeof responseTime.value === 'number') { + return `${Number(responseTime.value).toFixed(2)}ms`; + } + if (responseTime.hasError) { + return t('n/a'); + } + return '-'; +}; + +const getBlockDisplayValue = (block: NodeData['block']) => { + if (block.value) { + return ''; + } + if (block.hasError) { + return t('n/a'); + } + return '-'; +}; + +const getSslDisplayValue = (ssl: NodeData['ssl']) => { + if (ssl.value) { + return t('Yes'); + } + if (ssl.hasError) { + return t('No'); + } + return '-'; +}; + +const NodeStatsContent = ({ + data: { url, responseTime, block, ssl }, + highestBlock, + setBlock, + children, +}: NodeStatsContentProps) => { + return ( + + {children} + + {getResponseTimeDisplayValue(responseTime)} + + block.value) + } + > + {url && block.value && ( + + )} + {getBlockDisplayValue(block)} + + + {getSslDisplayValue(ssl)} + + + ); +}; + +type WrapperProps = { + client?: ReturnType; + children: ReactNode; +}; + +const Wrapper = ({ client, children }: WrapperProps) => { + if (client) { + return {children}; + } + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>{children}; +}; + +export type NodeStatsProps = { + url?: string; + highestBlock: number; + setBlock: (value: number) => void; + render: (data: NodeData) => ReactNode; +}; + +export const NodeStats = ({ + url, + highestBlock, + render, + setBlock, +}: NodeStatsProps) => { + const [client, setClient] = useState< + undefined | ReturnType + >(); + const { state, reset, updateBlockState } = useNode(url, client); + + useEffect(() => { + client?.stop(); + reset(); + setClient(url ? createClient(url) : undefined); + return () => client?.stop(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [url]); + + const onHandleBlockChange = useCallback( + (value: number) => { + updateBlockState(value); + setBlock(value); + }, + [updateBlockState, setBlock] + ); + + return ( + + + {render(state)} + + + ); +}; diff --git a/libs/environment/src/components/node-switcher/node-switcher.tsx b/libs/environment/src/components/node-switcher/node-switcher.tsx new file mode 100644 index 000000000..54401f1de --- /dev/null +++ b/libs/environment/src/components/node-switcher/node-switcher.tsx @@ -0,0 +1,113 @@ +import { useState } from 'react'; +import { t } from '@vegaprotocol/react-helpers'; +import { RadioGroup, Button, Radio } from '@vegaprotocol/ui-toolkit'; +import { useEnvironment } from '../../hooks/use-environment'; +import type { Configuration, NodeData, Networks } from '../../types'; +import { LayoutRow } from './layout-row'; +import { LayoutCell } from './layout-cell'; +import { NodeError } from './node-error'; +import { NodeStats } from './node-stats'; + +type NodeSwitcherProps = { + error?: string; + config: Configuration; + onConnect: (url: string) => void; +}; + +const getDefaultNode = (urls: string[], currentUrl?: string) => { + return currentUrl && urls.includes(currentUrl) ? currentUrl : undefined; +}; + +const getIsLoading = ({ chain, responseTime, block, ssl }: NodeData) => { + return ( + chain.isLoading || + responseTime.isLoading || + block.isLoading || + ssl.isLoading + ); +}; + +const getHasMatchingChain = (env: Networks, chain?: string) => { + return chain?.includes(env.toLowerCase()) ?? false; +}; + +const getIsDisabled = (env: Networks, data: NodeData) => { + const { chain, responseTime, block, ssl } = data; + return ( + !getHasMatchingChain(env, data.chain.value) || + getIsLoading(data) || + chain.hasError || + responseTime.hasError || + block.hasError || + ssl.hasError + ); +}; + +export const NodeSwitcher = ({ config, onConnect }: NodeSwitcherProps) => { + const { VEGA_ENV, VEGA_URL } = useEnvironment(); + const [node, setNode] = useState(getDefaultNode(config.hosts, VEGA_URL)); + const [highestBlock, setHighestBlock] = useState(0); + + const onSubmit = (node: ReturnType) => { + if (node) { + onConnect(node); + } + }; + + const isSubmitDisabled = !node; + + return ( +
+ +
onSubmit(node)}> +

+ {t('Select a GraphQL node to connect to:')} +

+
+ +
+ {t('Response time')} + {t('Block')} + {t('SSL')} + + setNode(value)} + > +
+ {config.hosts.map((url, index) => ( + + setHighestBlock(Math.max(block, highestBlock)) + } + render={(data) => ( +
+ +
+ )} + /> + ))} +
+
+
+ + +
+ ); +}; diff --git a/libs/environment/src/hooks/__generated__/BlockTime.ts b/libs/environment/src/hooks/__generated__/BlockTime.ts new file mode 100644 index 000000000..7f6cc2895 --- /dev/null +++ b/libs/environment/src/hooks/__generated__/BlockTime.ts @@ -0,0 +1,23 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL subscription operation: BlockTime +// ==================================================== + +export interface BlockTime_busEvents { + __typename: "BusEvent"; + /** + * the id for this event + */ + eventId: string; +} + +export interface BlockTime { + /** + * Subscribe to event data from the event bus + */ + busEvents: BlockTime_busEvents[] | null; +} diff --git a/libs/environment/src/hooks/__generated__/Statistics.ts b/libs/environment/src/hooks/__generated__/Statistics.ts new file mode 100644 index 000000000..90d1d3ea2 --- /dev/null +++ b/libs/environment/src/hooks/__generated__/Statistics.ts @@ -0,0 +1,27 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL query operation: Statistics +// ==================================================== + +export interface Statistics_statistics { + __typename: "Statistics"; + /** + * Current chain id + */ + chainId: string; + /** + * Current block number + */ + blockHeight: string; +} + +export interface Statistics { + /** + * get statistics about the vega node + */ + statistics: Statistics_statistics; +} diff --git a/libs/environment/src/hooks/use-config.spec.tsx b/libs/environment/src/hooks/use-config.spec.tsx index ed9a54d0c..8e88761fe 100644 --- a/libs/environment/src/hooks/use-config.spec.tsx +++ b/libs/environment/src/hooks/use-config.spec.tsx @@ -20,6 +20,10 @@ const mockEnvironment: EnvironmentWithOptionalUrl = { VEGA_NETWORKS: {}, ETHEREUM_PROVIDER_URL: 'https://ethereum.provider', ETHERSCAN_URL: 'https://etherscan.url', + GIT_BRANCH: 'test', + GIT_ORIGIN_URL: 'https://github.com/test/repo', + GIT_COMMIT_HASH: 'abcde01234', + GITHUB_FEEDBACK_URL: 'https://github.com/test/feedback', }; function setupFetch(configUrl: string, hostMap: HostMapping) { @@ -77,18 +81,6 @@ afterAll(() => { }); describe('useConfig hook', () => { - it('has an initial success state when the environment already has a URL', async () => { - const mockEnvWithUrl = { - ...mockEnvironment, - VEGA_URL: 'https://some.url/query', - }; - const { result } = renderHook(() => useConfig(mockEnvWithUrl, mockUpdate)); - - expect(fetch).not.toHaveBeenCalled(); - expect(mockUpdate).not.toHaveBeenCalled(); - expect(result.current.status).toBe('success'); - }); - it('updates the environment with a host url from the network configuration', async () => { const allowedStatuses = [ 'idle', @@ -273,7 +265,10 @@ describe('useConfig hook', () => { }); it('refetches the network configuration and resets the cache when malformed data found in the storage', async () => { - window.localStorage.setItem(LOCAL_STORAGE_NETWORK_KEY, '{not:{valid:{json'); + window.localStorage.setItem( + `${LOCAL_STORAGE_NETWORK_KEY}-${mockEnvironment.VEGA_ENV}`, + '{not:{valid:{json' + ); const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(noop); const run1 = renderHook(() => useConfig(mockEnvironment, mockUpdate)); @@ -291,7 +286,7 @@ describe('useConfig hook', () => { it('refetches the network configuration and resets the cache when invalid data found in the storage', async () => { window.localStorage.setItem( - LOCAL_STORAGE_NETWORK_KEY, + `${LOCAL_STORAGE_NETWORK_KEY}-${mockEnvironment.VEGA_ENV}`, JSON.stringify({ invalid: 'data' }) ); const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(noop); diff --git a/libs/environment/src/hooks/use-config.tsx b/libs/environment/src/hooks/use-config.tsx index 205a882da..9c034b691 100644 --- a/libs/environment/src/hooks/use-config.tsx +++ b/libs/environment/src/hooks/use-config.tsx @@ -1,7 +1,12 @@ import type { Dispatch, SetStateAction } from 'react'; import { useState, useEffect } from 'react'; import { LocalStorage } from '@vegaprotocol/react-helpers'; -import type { Environment, Configuration, ConfigStatus } from '../types'; +import type { + Environment, + Configuration, + ConfigStatus, + Networks, +} from '../types'; import { validateConfiguration } from '../utils/validate-configuration'; import { promiseRaceToSuccess } from '../utils/promise-race-success'; @@ -18,8 +23,11 @@ const requestToNode = async (url: string, index: number): Promise => { return index; }; -const getCachedConfig = () => { - const value = LocalStorage.getItem(LOCAL_STORAGE_NETWORK_KEY); +const getCacheKey = (env: Networks) => `${LOCAL_STORAGE_NETWORK_KEY}-${env}`; + +const getCachedConfig = (env: Networks) => { + const key = getCacheKey(env); + const value = LocalStorage.getItem(key); if (value) { try { @@ -32,7 +40,7 @@ const getCachedConfig = () => { return config; } catch (err) { - LocalStorage.removeItem(LOCAL_STORAGE_NETWORK_KEY); + LocalStorage.removeItem(key); console.warn( 'Malformed data found for network configuration. Removed and continuing...' ); @@ -47,10 +55,10 @@ export const useConfig = ( updateEnvironment: Dispatch> ) => { const [config, setConfig] = useState( - getCachedConfig() + getCachedConfig(environment.VEGA_ENV) ); const [status, setStatus] = useState( - !environment.VEGA_URL ? 'idle' : 'success' + environment.VEGA_CONFIG_URL ? 'idle' : 'success' ); useEffect(() => { @@ -68,7 +76,7 @@ export const useConfig = ( setConfig({ hosts: configData.hosts }); LocalStorage.setItem( - LOCAL_STORAGE_NETWORK_KEY, + getCacheKey(environment.VEGA_ENV), JSON.stringify({ hosts: configData.hosts }) ); } catch (err) { @@ -93,7 +101,7 @@ export const useConfig = ( setStatus('success'); updateEnvironment((prevEnvironment) => ({ ...prevEnvironment, - VEGA_URL: config.hosts[0], + VEGA_URL: prevEnvironment.VEGA_URL || config.hosts[0], })); return; } @@ -105,7 +113,7 @@ export const useConfig = ( setStatus('success'); updateEnvironment((prevEnvironment) => ({ ...prevEnvironment, - VEGA_URL: config.hosts[index], + VEGA_URL: prevEnvironment.VEGA_URL || config.hosts[index], })); } catch (err) { setStatus('error-loading-node'); @@ -118,5 +126,6 @@ export const useConfig = ( return { status, + config, }; }; diff --git a/libs/environment/src/hooks/use-environment.spec.tsx b/libs/environment/src/hooks/use-environment.spec.tsx index 78ce13216..da54029f0 100644 --- a/libs/environment/src/hooks/use-environment.spec.tsx +++ b/libs/environment/src/hooks/use-environment.spec.tsx @@ -44,6 +44,7 @@ const mockEnvironmentState: EnvironmentState = { GIT_ORIGIN_URL: 'https://github.com/test/repo', GIT_COMMIT_HASH: 'abcde01234', GITHUB_FEEDBACK_URL: 'https://github.com/test/feedback', + setNodeSwitcherOpen: noop, }; beforeEach(() => { @@ -81,18 +82,26 @@ afterAll(() => { delete process.env['NX_ETHEREUM_PROVIDER_URL']; delete process.env['NX_ETHERSCAN_URL']; delete process.env['NX_VEGA_NETWORKS']; + delete process.env['NX_GIT_BRANCH']; + delete process.env['NX_GIT_ORIGIN_URL']; + delete process.env['NX_GIT_COMMIT_HASH']; + delete process.env['NX_GITHUB_FEEDBACK_URL']; }); describe('useEnvironment hook', () => { - it('transforms and exposes values from the environment', () => { - const { result } = renderHook(() => useEnvironment(), { + it('transforms and exposes values from the environment', async () => { + const { result, waitForNextUpdate } = renderHook(() => useEnvironment(), { wrapper: MockWrapper, }); + await waitForNextUpdate(); expect(result.error).toBe(undefined); - expect(result.current).toEqual(mockEnvironmentState); + expect(result.current).toEqual({ + ...mockEnvironmentState, + setNodeSwitcherOpen: result.current.setNodeSwitcherOpen, + }); }); - it('allows for the VEGA_CONFIG_URL to be missing when there is a VEGA_URL present', () => { + 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, @@ -101,6 +110,7 @@ describe('useEnvironment hook', () => { expect(result.current).toEqual({ ...mockEnvironmentState, VEGA_CONFIG_URL: undefined, + setNodeSwitcherOpen: result.current.setNodeSwitcherOpen, }); }); @@ -115,18 +125,21 @@ describe('useEnvironment hook', () => { expect(result.current).toEqual({ ...mockEnvironmentState, VEGA_URL: MOCK_HOST, + setNodeSwitcherOpen: result.current.setNodeSwitcherOpen, }); }); - it('allows for the VEGA_NETWORKS to be missing from the environment', () => { + it('allows for the VEGA_NETWORKS to be missing from the environment', async () => { delete process.env['NX_VEGA_NETWORKS']; - const { result } = renderHook(() => useEnvironment(), { + const { result, waitForNextUpdate } = renderHook(() => useEnvironment(), { wrapper: MockWrapper, }); + await waitForNextUpdate(); expect(result.error).toBe(undefined); expect(result.current).toEqual({ ...mockEnvironmentState, VEGA_NETWORKS: {}, + setNodeSwitcherOpen: result.current.setNodeSwitcherOpen, }); }); @@ -140,7 +153,7 @@ describe('useEnvironment hook', () => { ); }); - it('throws a validation error when VEGA_ENV is not a valid network', () => { + it('throws a validation error when VEGA_ENV is not a valid network', async () => { process.env['NX_VEGA_ENV'] = 'SOMETHING'; const { result } = renderHook(() => useEnvironment(), { wrapper: MockWrapper, @@ -150,23 +163,25 @@ describe('useEnvironment hook', () => { ); }); - it('when VEGA_NETWORKS is not a valid json, prints a warning and continues without using the value from it', () => { + 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(), { + const { result, waitForNextUpdate } = renderHook(() => useEnvironment(), { wrapper: MockWrapper, }); + await waitForNextUpdate(); expect(result.error).toBe(undefined); expect(result.current).toEqual({ ...mockEnvironmentState, VEGA_NETWORKS: {}, + setNodeSwitcherOpen: result.current.setNodeSwitcherOpen, }); expect(consoleWarnSpy).toHaveBeenCalled(); consoleWarnSpy.mockRestore(); }); - it('throws a validation error when VEGA_NETWORKS is has an invalid network as a key', () => { + it('throws a validation error when VEGA_NETWORKS is has an invalid network as a key', async () => { process.env['NX_VEGA_NETWORKS'] = JSON.stringify({ NOT_A_NETWORK: 'https://somewhere.url', }); @@ -178,7 +193,7 @@ describe('useEnvironment hook', () => { ); }); - it('throws a validation error when both VEGA_URL and VEGA_CONFIG_URL are missing in the environment', () => { + 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 { result } = renderHook(() => useEnvironment(), { @@ -202,15 +217,17 @@ describe('useEnvironment hook', () => { process.env['NX_VEGA_ENV'] = env; delete process.env['NX_ETHEREUM_PROVIDER_URL']; delete process.env['NX_ETHERSCAN_URL']; - const { result } = renderHook(() => useEnvironment(), { + const { result, waitForNextUpdate } = renderHook(() => useEnvironment(), { wrapper: MockWrapper, }); + await waitForNextUpdate(); expect(result.error).toBe(undefined); expect(result.current).toEqual({ ...mockEnvironmentState, VEGA_ENV: env, ETHEREUM_PROVIDER_URL: providerUrl, ETHERSCAN_URL: etherscanUrl, + setNodeSwitcherOpen: result.current.setNodeSwitcherOpen, }); } ); diff --git a/libs/environment/src/hooks/use-environment.tsx b/libs/environment/src/hooks/use-environment.tsx index c9a6d7f4d..c324e6949 100644 --- a/libs/environment/src/hooks/use-environment.tsx +++ b/libs/environment/src/hooks/use-environment.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react'; import { useState, createContext, useContext } from 'react'; +import { NodeSwitcherDialog } from '../components/node-switcher-dialog'; import { useConfig } from './use-config'; import { compileEnvironment } from '../utils/compile-environment'; import { validateEnvironment } from '../utils/validate-environment'; @@ -13,6 +14,7 @@ type EnvironmentProviderProps = { export type EnvironmentState = Environment & { configStatus: ConfigStatus; + setNodeSwitcherOpen: () => void; }; const EnvironmentContext = createContext({} as EnvironmentState); @@ -21,10 +23,14 @@ export const EnvironmentProvider = ({ definitions, children, }: EnvironmentProviderProps) => { + const [isNodeSwitcherOpen, setNodeSwitcherOpen] = useState(false); const [environment, updateEnvironment] = useState( compileEnvironment(definitions) ); - const { status: configStatus } = useConfig(environment, updateEnvironment); + const { status: configStatus, config } = useConfig( + environment, + updateEnvironment + ); const errorMessage = validateEnvironment(environment); @@ -33,7 +39,23 @@ export const EnvironmentProvider = ({ } return ( - + setNodeSwitcherOpen(true), + }} + > + {config && ( + + updateEnvironment((env) => ({ ...env, VEGA_URL: url })) + } + /> + )} {children} ); diff --git a/libs/environment/src/hooks/use-node.spec.tsx b/libs/environment/src/hooks/use-node.spec.tsx new file mode 100644 index 000000000..4b255316e --- /dev/null +++ b/libs/environment/src/hooks/use-node.spec.tsx @@ -0,0 +1,267 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { MockedProvider } from '@apollo/client/testing'; + +import { useNode, STATS_QUERY, TIME_UPDATE_SUBSCRIPTION } from './use-node'; + +const MOCK_DURATION = 1073; + +const MOCK_STATISTICS_QUERY_RESULT = { + blockHeight: '11', + chainId: 'testnet_01234', +}; + +const initialState = { + url: '', + responseTime: { + isLoading: false, + hasError: false, + value: undefined, + }, + block: { + isLoading: false, + hasError: false, + value: undefined, + }, + ssl: { + isLoading: false, + hasError: false, + value: undefined, + }, + chain: { + isLoading: false, + hasError: false, + value: undefined, + }, +}; + +const createMockClient = ({ + failStats = false, + failSubscription = false, +}: { failStats?: boolean; failSubscription?: boolean } = {}) => { + const provider = new MockedProvider({ + mocks: [ + { + request: { + query: STATS_QUERY, + }, + result: failStats + ? undefined + : { + data: { + statistics: { + __typename: 'Statistics', + ...MOCK_STATISTICS_QUERY_RESULT, + }, + }, + }, + }, + { + request: { + query: TIME_UPDATE_SUBSCRIPTION, + }, + result: failSubscription + ? undefined + : { + data: { + busEvents: { + eventId: 'time-0', + }, + }, + }, + }, + ], + }); + + return provider.state.client; +}; + +window.performance.getEntriesByName = jest + .fn() + .mockImplementation((url: string) => [ + { + entryType: 'resource', + name: url, + startTime: 0, + toJSON: () => ({}), + duration: MOCK_DURATION, + }, + ]); + +afterAll(() => { + // @ts-ignore allow deleting the spy function after we're done with the tests + delete window.performance.getEntriesByName; +}); + +describe('useNode hook', () => { + it('returns the default state when no arguments provided', () => { + const { result } = renderHook(() => useNode()); + expect(result.current.state).toEqual(initialState); + }); + + it('returns the default state when no url provided', () => { + const client = createMockClient(); + const { result } = renderHook(() => useNode(undefined, client)); + expect(result.current.state).toEqual(initialState); + }); + + it('returns the default state when no client provided', () => { + const url = 'https://some.url'; + const { result } = renderHook(() => useNode(url, undefined)); + expect(result.current.state).toEqual({ ...initialState, url }); + }); + + it('sets loading state while waiting for the results', async () => { + const url = 'https://some.url'; + const client = createMockClient(); + const { result, waitForNextUpdate } = renderHook(() => + useNode(url, client) + ); + + expect(result.current.state).toEqual({ + url, + responseTime: { + isLoading: true, + hasError: false, + value: undefined, + }, + block: { + isLoading: true, + hasError: false, + value: undefined, + }, + ssl: { + isLoading: true, + hasError: false, + value: undefined, + }, + chain: { + isLoading: true, + hasError: false, + value: undefined, + }, + }); + + await waitForNextUpdate(); + }); + + it('sets statistics results', async () => { + const url = 'https://some.url'; + const client = createMockClient(); + const { result, waitFor } = renderHook(() => useNode(url, client)); + + await waitFor(() => { + expect(result.current.state.block).toEqual({ + isLoading: false, + hasError: false, + value: Number(MOCK_STATISTICS_QUERY_RESULT.blockHeight), + }); + + expect(result.current.state.chain).toEqual({ + isLoading: false, + hasError: false, + value: MOCK_STATISTICS_QUERY_RESULT.chainId, + }); + + expect(result.current.state.responseTime).toEqual({ + isLoading: false, + hasError: false, + value: MOCK_DURATION, + }); + }); + }); + + it('sets subscription result', async () => { + const url = 'https://some.url'; + const client = createMockClient(); + const { result, waitFor } = renderHook(() => useNode(url, client)); + + await waitFor(() => { + expect(result.current.state.ssl).toEqual({ + isLoading: false, + hasError: false, + value: true, + }); + }); + }); + + it('sets error when statistics request fails', async () => { + const url = 'https://some.url'; + const client = createMockClient({ failStats: true }); + const { result, waitFor } = renderHook(() => useNode(url, client)); + + await waitFor(() => { + expect(result.current.state.block).toEqual({ + isLoading: false, + hasError: true, + value: undefined, + }); + + expect(result.current.state.chain).toEqual({ + isLoading: false, + hasError: true, + value: undefined, + }); + + expect(result.current.state.responseTime).toEqual({ + isLoading: false, + hasError: true, + value: undefined, + }); + }); + }); + + it('sets error when subscription request fails', async () => { + const url = 'https://some.url'; + const client = createMockClient({ failSubscription: true }); + const { result, waitFor } = renderHook(() => useNode(url, client)); + + await waitFor(() => { + expect(result.current.state.ssl).toEqual({ + isLoading: false, + hasError: true, + value: undefined, + }); + }); + }); + + it('allows updating block values', async () => { + const url = 'https://some.url'; + const client = createMockClient({ failSubscription: true }); + const { result, waitFor } = renderHook(() => useNode(url, client)); + + await waitFor(() => { + expect(result.current.state.block.value).toEqual(11); + }); + + act(() => { + result.current.updateBlockState(12); + }); + + await waitFor(() => { + expect(result.current.state.block.value).toEqual(12); + }); + }); + + it('allows resetting the state to defaults', async () => { + const url = 'https://some.url'; + const client = createMockClient(); + const { result, waitFor } = renderHook(() => useNode(url, client)); + + await waitFor(() => { + expect(result.current.state.block.value).toBe( + Number(MOCK_STATISTICS_QUERY_RESULT.blockHeight) + ); + expect(result.current.state.chain.value).toBe( + MOCK_STATISTICS_QUERY_RESULT.chainId + ); + expect(result.current.state.responseTime.value).toBe(MOCK_DURATION); + expect(result.current.state.ssl.value).toBe(true); + }); + + act(() => { + result.current.reset(); + }); + + expect(result.current.state).toEqual({ ...initialState, url }); + }); +}); diff --git a/libs/environment/src/hooks/use-node.tsx b/libs/environment/src/hooks/use-node.tsx new file mode 100644 index 000000000..74e59ad02 --- /dev/null +++ b/libs/environment/src/hooks/use-node.tsx @@ -0,0 +1,188 @@ +import { useEffect, useReducer } from 'react'; +import { produce } from 'immer'; +import { gql } from '@apollo/client'; +import type { createClient } from '../utils/apollo-client'; +import type { NodeData } from '../types'; +import type { Statistics } from './__generated__/Statistics'; + +type StatisticsPayload = { + block: NodeData['block']['value']; + chain: NodeData['chain']['value']; + responseTime: NodeData['responseTime']['value']; +}; + +export const STATS_QUERY = gql` + query Statistics { + statistics { + chainId + blockHeight + } + } +`; + +export const TIME_UPDATE_SUBSCRIPTION = gql` + subscription BlockTime { + busEvents(types: TimeUpdate, batchSize: 1) { + eventId + } + } +`; + +enum ACTION { + GET_STATISTICS, + GET_STATISTICS_SUCCESS, + GET_STATISTICS_FAILURE, + CHECK_SUBSCRIPTION, + CHECK_SUBSCRIPTION_SUCCESS, + CHECK_SUBSCRIPTION_FAILURE, + UPDATE_BLOCK, + RESET_STATE, +} + +function withData(value?: T) { + return { + isLoading: false, + hasError: false, + value, + }; +} + +function withError(value?: T) { + return { + isLoading: false, + hasError: true, + value, + }; +} + +const getInitialState = (url?: string): NodeData => ({ + url: url ?? '', + responseTime: withData(), + block: withData(), + ssl: withData(), + chain: withData(), +}); + +const getResponseTime = (url: string) => { + const requests = window.performance.getEntriesByName(url); + const { duration } = (requests.length && requests[requests.length - 1]) || {}; + return duration; +}; + +type ActionType = { + type: T; + payload?: P; +}; + +type Action = + | ActionType + | ActionType + | ActionType + | ActionType + | ActionType + | ActionType + | ActionType + | ActionType; + +const reducer = (state: NodeData, action: Action) => { + switch (action.type) { + case ACTION.GET_STATISTICS: + return produce(state, (state) => { + state.block.isLoading = true; + state.chain.isLoading = true; + state.responseTime.isLoading = true; + }); + case ACTION.GET_STATISTICS_SUCCESS: + return produce(state, (state) => { + state.block = withData(action.payload?.block); + state.chain = withData(action.payload?.chain); + state.responseTime = withData(action.payload?.responseTime); + }); + case ACTION.GET_STATISTICS_FAILURE: + return produce(state, (state) => { + state.block = withError(); + state.chain = withError(); + state.responseTime = withError(); + }); + case ACTION.CHECK_SUBSCRIPTION: + return produce(state, (state) => { + state.ssl.isLoading = true; + }); + case ACTION.CHECK_SUBSCRIPTION_SUCCESS: + return produce(state, (state) => { + state.ssl = withData(true); + }); + case ACTION.CHECK_SUBSCRIPTION_FAILURE: + return produce(state, (state) => { + state.ssl = withError(); + }); + case ACTION.UPDATE_BLOCK: + return produce(state, (state) => { + state.block.value = action.payload; + }); + case ACTION.RESET_STATE: + return produce(state, (state) => { + state.responseTime = withData(); + state.block = withData(); + state.ssl = withData(); + state.chain = withData(); + }); + default: + return state; + } +}; + +export const useNode = ( + url?: string, + client?: ReturnType +) => { + const [state, dispatch] = useReducer(reducer, getInitialState(url)); + + useEffect(() => { + if (client && url) { + dispatch({ type: ACTION.GET_STATISTICS }); + dispatch({ type: ACTION.CHECK_SUBSCRIPTION }); + + client + .query({ + query: STATS_QUERY, + }) + .then((res) => { + dispatch({ + type: ACTION.GET_STATISTICS_SUCCESS, + payload: { + chain: res.data.statistics.chainId, + block: Number(res.data.statistics.blockHeight), + responseTime: getResponseTime(url), + }, + }); + }) + .catch(() => { + dispatch({ type: ACTION.GET_STATISTICS_FAILURE }); + }); + + const subscription = client + .subscribe({ + query: TIME_UPDATE_SUBSCRIPTION, + errorPolicy: 'all', + }) + .subscribe({ + next() { + dispatch({ type: ACTION.CHECK_SUBSCRIPTION_SUCCESS }); + subscription.unsubscribe(); + }, + error() { + dispatch({ type: ACTION.CHECK_SUBSCRIPTION_FAILURE }); + subscription.unsubscribe(); + }, + }); + } + }, [client, url, dispatch]); + + return { + state, + updateBlockState: (value: number) => + dispatch({ type: ACTION.UPDATE_BLOCK, payload: value }), + reset: () => dispatch({ type: ACTION.RESET_STATE }), + }; +}; diff --git a/libs/environment/src/types.ts b/libs/environment/src/types.ts index 58a29a3db..08b3c9b69 100644 --- a/libs/environment/src/types.ts +++ b/libs/environment/src/types.ts @@ -25,3 +25,17 @@ export type ConfigStatus = | 'error-loading-config' | 'error-validating-config' | 'error-loading-node'; + +type NodeCheck = { + isLoading: boolean; + hasError: boolean; + value?: T; +}; + +export type NodeData = { + url: string; + ssl: NodeCheck; + block: NodeCheck; + responseTime: NodeCheck; + chain: NodeCheck; +}; diff --git a/libs/environment/src/utils/apollo-client.tsx b/libs/environment/src/utils/apollo-client.tsx new file mode 100644 index 000000000..e593c912a --- /dev/null +++ b/libs/environment/src/utils/apollo-client.tsx @@ -0,0 +1,71 @@ +import { + ApolloClient, + from, + split, + ApolloLink, + HttpLink, + InMemoryCache, +} from '@apollo/client'; +import { GraphQLWsLink } from '@apollo/client/link/subscriptions'; +import { getMainDefinition } from '@apollo/client/utilities'; +import { createClient as createWSClient } from 'graphql-ws'; +import { onError } from '@apollo/client/link/error'; +import { RetryLink } from '@apollo/client/link/retry'; + +const isBrowser = typeof window !== 'undefined'; + +export function createClient(base?: string) { + if (!base) { + throw new Error('Base must be passed into createClient!'); + } + const gqlPath = 'query'; + const urlHTTP = new URL(gqlPath, base); + const urlWS = new URL(gqlPath, base); + // Replace http with ws, preserving if its a secure connection eg. https => wss + urlWS.protocol = urlWS.protocol.replace('http', 'ws'); + + const retryLink = new RetryLink({ + delay: { + initial: 300, + max: 10000, + jitter: true, + }, + }); + + const httpLink = new HttpLink({ + uri: urlHTTP.href, + credentials: 'same-origin', + }); + + const wsLink = isBrowser + ? new GraphQLWsLink( + createWSClient({ + url: urlWS.href, + }) + ) + : new ApolloLink((operation, forward) => forward(operation)); + + const splitLink = isBrowser + ? split( + ({ query }) => { + const definition = getMainDefinition(query); + return ( + definition.kind === 'OperationDefinition' && + definition.operation === 'subscription' + ); + }, + wsLink, + httpLink + ) + : httpLink; + + const errorLink = onError(({ graphQLErrors, networkError }) => { + if (graphQLErrors) console.log(graphQLErrors); + if (networkError) console.log(networkError); + }); + + return new ApolloClient({ + link: from([errorLink, retryLink, splitLink]), + cache: new InMemoryCache(), + }); +} diff --git a/libs/environment/src/utils/validate-environment.ts b/libs/environment/src/utils/validate-environment.ts index 58c25434b..2289ab6a2 100644 --- a/libs/environment/src/utils/validate-environment.ts +++ b/libs/environment/src/utils/validate-environment.ts @@ -14,20 +14,13 @@ export enum Networks { const schemaObject = { VEGA_URL: z.optional(z.string()), - VEGA_EXPLORER_URL: z.optional(z.string()), VEGA_CONFIG_URL: z.optional(z.string()), - ETHEREUM_PROVIDER_URL: z.string().url({ - message: - 'The NX_ETHEREUM_PROVIDER_URL environment variable must be a valid url', - }), - ETHERSCAN_URL: z.string().url({ - message: 'The NX_ETHERSCAN_URL environment variable must be a valid url', - }), GIT_BRANCH: z.optional(z.string()), GIT_COMMIT_HASH: z.optional(z.string()), GIT_ORIGIN_URL: z.optional(z.string()), GITHUB_FEEDBACK_URL: z.optional(z.string()), VEGA_ENV: z.nativeEnum(Networks), + VEGA_EXPLORER_URL: z.optional(z.string()), VEGA_NETWORKS: z .object( Object.keys(Networks).reduce( @@ -43,6 +36,13 @@ const schemaObject = { Networks ).join(' | ')}`, }), + ETHEREUM_PROVIDER_URL: z.string().url({ + message: + 'The NX_ETHEREUM_PROVIDER_URL environment variable must be a valid url', + }), + ETHERSCAN_URL: z.string().url({ + message: 'The NX_ETHERSCAN_URL environment variable must be a valid url', + }), }; export const ENV_KEYS = Object.keys(schemaObject) as Array< diff --git a/libs/network-info/src/network-info.tsx b/libs/network-info/src/network-info.tsx index b20577358..5403aecc4 100644 --- a/libs/network-info/src/network-info.tsx +++ b/libs/network-info/src/network-info.tsx @@ -1,10 +1,6 @@ -import { useState } from 'react'; import { t } from '@vegaprotocol/react-helpers'; import { Link, Lozenge } from '@vegaprotocol/ui-toolkit'; -import { - useEnvironment, - NetworkSwitcherDialog, -} from '@vegaprotocol/environment'; +import { useEnvironment } from '@vegaprotocol/environment'; const getFeedbackLinks = (gitOriginUrl?: string) => [ @@ -15,77 +11,65 @@ const getFeedbackLinks = (gitOriginUrl?: string) => ].filter((link) => !!link.url); export const NetworkInfo = () => { - const [isNetworkConfigOpen, setNetworkConfigOpen] = useState(false); const { VEGA_URL, - VEGA_NETWORKS, GIT_COMMIT_HASH, GIT_ORIGIN_URL, GITHUB_FEEDBACK_URL, ETHEREUM_PROVIDER_URL, + setNodeSwitcherOpen, } = useEnvironment(); const feedbackLinks = getFeedbackLinks(GITHUB_FEEDBACK_URL); return ( - <> -
+
+

+ {t('Reading network data from')}{' '} + + {VEGA_URL} + + . setNodeSwitcherOpen()}>{t('Edit')} +

+

+ {t('Reading Ethereum data from')}{' '} + + {ETHEREUM_PROVIDER_URL} + + .{' '} +

+ {GIT_COMMIT_HASH && (

- {t('Reading network data from')}{' '} - - {VEGA_URL} - - . setNetworkConfigOpen(true)}>{t('Edit')} + {t('Version/commit hash')}:{' '} + + {GIT_COMMIT_HASH} +

-

- {t('Reading Ethereum data from')}{' '} - - {ETHEREUM_PROVIDER_URL} - - .{' '} + )} + {feedbackLinks.length > 0 && ( +

+ {t('Known issues and feedback on')}{' '} + {feedbackLinks.map(({ name, url }, index) => ( + <> + + {name} + + {feedbackLinks.length > 1 && + index < feedbackLinks.length - 2 && + ','} + {feedbackLinks.length > 1 && + index === feedbackLinks.length - 1 && + `, ${t('and')} `} + + ))}

- {GIT_COMMIT_HASH && ( -

- {t('Version/commit hash')}:{' '} - - {GIT_COMMIT_HASH} - -

- )} - {feedbackLinks.length > 0 && ( -

- {t('Known issues and feedback on')}{' '} - {feedbackLinks.map(({ name, url }, index) => ( - <> - - {name} - - {feedbackLinks.length > 1 && - index < feedbackLinks.length - 2 && - ','} - {feedbackLinks.length > 1 && - index === feedbackLinks.length - 1 && - `, ${t('and')} `} - - ))} -

- )} -
- { - if (VEGA_NETWORKS[network]) { - window.location.href = VEGA_NETWORKS[network] as string; - } - }} - /> - + )} +
); }; diff --git a/libs/ui-toolkit/src/components/dialog/dialog.tsx b/libs/ui-toolkit/src/components/dialog/dialog.tsx index 09f15aa88..3c29fdc2f 100644 --- a/libs/ui-toolkit/src/components/dialog/dialog.tsx +++ b/libs/ui-toolkit/src/components/dialog/dialog.tsx @@ -26,7 +26,7 @@ export function Dialog({ }: DialogProps) { const contentClasses = classNames( // Positions the modal in the center of screen - 'z-20 fixed w-full md:w-[520px] px-28 py-24 top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%]', + 'z-20 fixed w-full md:w-[520px] lg:w-[1000px] px-28 py-24 top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%]', // Need to apply background and text colors again as content is rendered in a portal 'dark:bg-black dark:text-white-95 bg-white text-black-95', getIntentShadow(intent), diff --git a/libs/ui-toolkit/src/components/radio-group/radio-group.tsx b/libs/ui-toolkit/src/components/radio-group/radio-group.tsx index 20348c07f..bf9f70321 100644 --- a/libs/ui-toolkit/src/components/radio-group/radio-group.tsx +++ b/libs/ui-toolkit/src/components/radio-group/radio-group.tsx @@ -5,17 +5,25 @@ import type { ReactNode } from 'react'; interface RadioGroupProps { name?: string; children: ReactNode; + className?: string; defaultValue?: string; value?: string; onChange?: (value: string) => void; } -export const RadioGroup = ({ children, onChange, name }: RadioGroupProps) => { +export const RadioGroup = ({ + children, + name, + value, + className, + onChange, +}: RadioGroupProps) => { return ( {children} @@ -26,12 +34,20 @@ interface RadioProps { id: string; value: string; label: string; + labelClassName?: string; disabled?: boolean; hasError?: boolean; } -export const Radio = ({ id, value, label, disabled, hasError }: RadioProps) => { - const wrapperClasses = classNames('flex flex-row gap-8 items-center', { +export const Radio = ({ + id, + value, + label, + labelClassName, + disabled, + hasError, +}: RadioProps) => { + const wrapperClasses = classNames('relative pl-[25px]', { 'opacity-50': disabled, }); const itemClasses = classNames( @@ -40,6 +56,7 @@ export const Radio = ({ id, value, label, disabled, hasError }: RadioProps) => { 'focus:outline-none focus-visible:outline-none', 'focus-visible:shadow-vega-pink dark:focus-visible:shadow-vega-yellow', 'dark:bg-white-25', + labelClassName, { 'border-black-60 dark:border-white-60': !hasError, 'border-danger dark:border-danger': hasError, @@ -49,12 +66,14 @@ export const Radio = ({ id, value, label, disabled, hasError }: RadioProps) => {
- +
+ +