Feat/676 node switcher hosts (#698)

* feat: add node swicther

* chore: remove hook form from node switcher

* feat: generate apollo types and add tests

* fix: format

* fix: types

* fix: remove redundant wrapper

* fix: layout styles

* fix: add controlled value to radio group

* fix: flaky node hook test

* feat: hook in node switcher to the explorer & token footer info

* fix: cache key handling for env config

* fix: env type in tests

* fix: format again

* fix: use netlify git env vars

* fix: remove commented styles

* fix: replace clsx with classnames

* fix: dialog sizes

* fix: fetch config by default

* fix: format

* fix: dialog close
This commit is contained in:
botond 2022-07-12 17:34:54 +01:00 committed by GitHub
parent 74bb4b9bf9
commit c32dae6eb9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1150 additions and 117 deletions

View File

@ -1,6 +1,6 @@
{
"extends": ["plugin:@nrwl/nx/react", "../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"ignorePatterns": ["!**/*", "__generated__"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],

View File

@ -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';

View File

@ -0,0 +1 @@
export * from './node-switcher-dialog';

View File

@ -0,0 +1,27 @@
import type { ComponentProps } from 'react';
import { Dialog } from '@vegaprotocol/ui-toolkit';
import { NodeSwitcher } from '../node-switcher';
type NodeSwitcherDialogProps = ComponentProps<typeof NodeSwitcher> & {
dialogOpen: boolean;
toggleDialogOpen: (dialogOpen: boolean) => void;
};
export const NodeSwitcherDialog = ({
config,
dialogOpen,
toggleDialogOpen,
onConnect,
}: NodeSwitcherDialogProps) => {
return (
<Dialog open={dialogOpen} onChange={toggleDialogOpen}>
<NodeSwitcher
config={config}
onConnect={(url) => {
onConnect(url);
toggleDialogOpen(false);
}}
/>
</Dialog>
);
};

View File

@ -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;
}

View File

@ -0,0 +1 @@
export * from './node-switcher';

View File

@ -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 (
<div
className={classnames('px-8 text-right', {
'text-danger': !isLoading && hasError,
'text-white-60 dark:text-black-60': isLoading,
})}
>
{isLoading ? t('Checking') : children || '-'}
</div>
);
};

View File

@ -0,0 +1,13 @@
import type { ReactNode } from 'react';
type LayoutRowProps = {
children?: ReactNode;
};
export const LayoutRow = ({ children }: LayoutRowProps) => {
return (
<div className="grid gap-4 py-8 w-full grid-cols-[minmax(200px,_1fr),_150px_125px_100px]">
{children}
</div>
);
};

View File

@ -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<BlockHeightStats>(
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 <span>{value ?? '-'}</span>;
};

View File

@ -0,0 +1,3 @@
export const NodeError = () => {
return <div />;
};

View File

@ -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 (
<LayoutRow>
{children}
<LayoutCell
isLoading={responseTime.isLoading}
hasError={responseTime.hasError}
>
{getResponseTimeDisplayValue(responseTime)}
</LayoutCell>
<LayoutCell
isLoading={block.isLoading}
hasError={
block.hasError || (!!block.value && highestBlock > block.value)
}
>
{url && block.value && (
<NodeBlockHeight value={block.value} setValue={setBlock} />
)}
{getBlockDisplayValue(block)}
</LayoutCell>
<LayoutCell isLoading={ssl.isLoading} hasError={ssl.hasError}>
{getSslDisplayValue(ssl)}
</LayoutCell>
</LayoutRow>
);
};
type WrapperProps = {
client?: ReturnType<typeof createClient>;
children: ReactNode;
};
const Wrapper = ({ client, children }: WrapperProps) => {
if (client) {
return <ApolloProvider client={client}>{children}</ApolloProvider>;
}
// 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<typeof createClient>
>();
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 (
<Wrapper client={client}>
<NodeStatsContent
data={state}
highestBlock={highestBlock}
setBlock={onHandleBlockChange}
>
{render(state)}
</NodeStatsContent>
</Wrapper>
);
};

View File

@ -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<typeof getDefaultNode>) => {
if (node) {
onConnect(node);
}
};
const isSubmitDisabled = !node;
return (
<div className="text-black dark:text-white w-full">
<NodeError />
<form onSubmit={() => onSubmit(node)}>
<p className="text-body-large font-bold mb-32">
{t('Select a GraphQL node to connect to:')}
</p>
<div>
<LayoutRow>
<div />
<LayoutCell>{t('Response time')}</LayoutCell>
<LayoutCell>{t('Block')}</LayoutCell>
<LayoutCell>{t('SSL')}</LayoutCell>
</LayoutRow>
<RadioGroup
className="block"
value={node}
onChange={(value) => setNode(value)}
>
<div>
{config.hosts.map((url, index) => (
<NodeStats
key={index}
url={url}
highestBlock={highestBlock}
setBlock={(block) =>
setHighestBlock(Math.max(block, highestBlock))
}
render={(data) => (
<div>
<Radio
id={`node-url-${index}`}
labelClassName="whitespace-nowrap text-ellipsis overflow-hidden"
value={url}
label={url}
disabled={getIsDisabled(VEGA_ENV, data)}
/>
</div>
)}
/>
))}
</div>
</RadioGroup>
</div>
<Button
className="w-full mt-16"
disabled={isSubmitDisabled}
type="submit"
>
{t('Connect')}
</Button>
</form>
</div>
);
};

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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);

View File

@ -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<number> => {
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<SetStateAction<Environment>>
) => {
const [config, setConfig] = useState<Configuration | undefined>(
getCachedConfig()
getCachedConfig(environment.VEGA_ENV)
);
const [status, setStatus] = useState<ConfigStatus>(
!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,
};
};

View File

@ -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,
});
}
);

View File

@ -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<Environment>(
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 (
<EnvironmentContext.Provider value={{ ...environment, configStatus }}>
<EnvironmentContext.Provider
value={{
...environment,
configStatus,
setNodeSwitcherOpen: () => setNodeSwitcherOpen(true),
}}
>
{config && (
<NodeSwitcherDialog
dialogOpen={isNodeSwitcherOpen}
toggleDialogOpen={setNodeSwitcherOpen}
config={config}
onConnect={(url) =>
updateEnvironment((env) => ({ ...env, VEGA_URL: url }))
}
/>
)}
{children}
</EnvironmentContext.Provider>
);

View File

@ -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 });
});
});

View File

@ -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<T>(value?: T) {
return {
isLoading: false,
hasError: false,
value,
};
}
function withError<T>(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<T extends ACTION, P = undefined> = {
type: T;
payload?: P;
};
type Action =
| ActionType<ACTION.GET_STATISTICS>
| ActionType<ACTION.GET_STATISTICS_SUCCESS, StatisticsPayload>
| ActionType<ACTION.GET_STATISTICS_FAILURE>
| ActionType<ACTION.CHECK_SUBSCRIPTION>
| ActionType<ACTION.CHECK_SUBSCRIPTION_SUCCESS>
| ActionType<ACTION.CHECK_SUBSCRIPTION_FAILURE>
| ActionType<ACTION.UPDATE_BLOCK, number>
| ActionType<ACTION.RESET_STATE>;
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<typeof createClient>
) => {
const [state, dispatch] = useReducer(reducer, getInitialState(url));
useEffect(() => {
if (client && url) {
dispatch({ type: ACTION.GET_STATISTICS });
dispatch({ type: ACTION.CHECK_SUBSCRIPTION });
client
.query<Statistics>({
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 }),
};
};

View File

@ -25,3 +25,17 @@ export type ConfigStatus =
| 'error-loading-config'
| 'error-validating-config'
| 'error-loading-node';
type NodeCheck<T> = {
isLoading: boolean;
hasError: boolean;
value?: T;
};
export type NodeData = {
url: string;
ssl: NodeCheck<boolean>;
block: NodeCheck<number>;
responseTime: NodeCheck<number>;
chain: NodeCheck<string>;
};

View File

@ -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(),
});
}

View File

@ -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<

View File

@ -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 (
<>
<div>
<div>
<p className="mb-16">
{t('Reading network data from')}{' '}
<Lozenge className="text-black dark:text-white bg-white-60 dark:bg-black-60">
{VEGA_URL}
</Lozenge>
. <Link onClick={() => setNodeSwitcherOpen()}>{t('Edit')}</Link>
</p>
<p className="mb-16">
{t('Reading Ethereum data from')}{' '}
<Lozenge className="text-black dark:text-white bg-white-60 dark:bg-black-60">
{ETHEREUM_PROVIDER_URL}
</Lozenge>
.{' '}
</p>
{GIT_COMMIT_HASH && (
<p className="mb-16">
{t('Reading network data from')}{' '}
<Lozenge className="text-black dark:text-white bg-white-60 dark:bg-black-60">
{VEGA_URL}
</Lozenge>
. <Link onClick={() => setNetworkConfigOpen(true)}>{t('Edit')}</Link>
{t('Version/commit hash')}:{' '}
<Link
href={
GIT_ORIGIN_URL
? `${GIT_ORIGIN_URL}/commit/${GIT_COMMIT_HASH}`
: undefined
}
target={GIT_ORIGIN_URL ? '_blank' : undefined}
>
{GIT_COMMIT_HASH}
</Link>
</p>
<p className="mb-[1rem]">
{t('Reading Ethereum data from')}{' '}
<Lozenge className="text-black dark:text-white bg-white-60 dark:bg-black-60">
{ETHEREUM_PROVIDER_URL}
</Lozenge>
.{' '}
)}
{feedbackLinks.length > 0 && (
<p className="mb-16">
{t('Known issues and feedback on')}{' '}
{feedbackLinks.map(({ name, url }, index) => (
<>
<Link key={index} href={url}>
{name}
</Link>
{feedbackLinks.length > 1 &&
index < feedbackLinks.length - 2 &&
','}
{feedbackLinks.length > 1 &&
index === feedbackLinks.length - 1 &&
`, ${t('and')} `}
</>
))}
</p>
{GIT_COMMIT_HASH && (
<p className="mb-[1rem]">
{t('Version/commit hash')}:{' '}
<Link
href={
GIT_ORIGIN_URL
? `${GIT_ORIGIN_URL}/commit/${GIT_COMMIT_HASH}`
: undefined
}
target={GIT_ORIGIN_URL ? '_blank' : undefined}
>
{GIT_COMMIT_HASH}
</Link>
</p>
)}
{feedbackLinks.length > 0 && (
<p className="mb-16">
{t('Known issues and feedback on')}{' '}
{feedbackLinks.map(({ name, url }, index) => (
<>
<Link key={index} href={url}>
{name}
</Link>
{feedbackLinks.length > 1 &&
index < feedbackLinks.length - 2 &&
','}
{feedbackLinks.length > 1 &&
index === feedbackLinks.length - 1 &&
`, ${t('and')} `}
</>
))}
</p>
)}
</div>
<NetworkSwitcherDialog
dialogOpen={isNetworkConfigOpen}
setDialogOpen={setNetworkConfigOpen}
onConnect={({ network }) => {
if (VEGA_NETWORKS[network]) {
window.location.href = VEGA_NETWORKS[network] as string;
}
}}
/>
</>
)}
</div>
);
};

View File

@ -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),

View File

@ -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 (
<RadioGroupPrimitive.Root
name={name}
value={value}
onValueChange={onChange}
className="flex flex-row gap-24"
className={classNames('flex flex-row gap-24', className)}
>
{children}
</RadioGroupPrimitive.Root>
@ -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) => {
<div className={wrapperClasses}>
<RadioGroupPrimitive.Item
value={value}
className={itemClasses}
className="absolute h-full w-[25px] top-0 left-0"
id={id}
data-testid={id}
disabled={disabled}
>
<RadioGroupPrimitive.Indicator className="w-[7px] h-[7px] bg-vega-pink dark:bg-vega-yellow rounded-full" />
<div className={itemClasses}>
<RadioGroupPrimitive.Indicator className="w-[7px] h-[7px] bg-vega-pink dark:bg-vega-yellow rounded-full" />
</div>
</RadioGroupPrimitive.Item>
<label htmlFor={id} className={disabled ? '' : 'cursor-pointer'}>
{label}