fix(trading): pick the best node to connect, slow notificatio, init error

This commit is contained in:
asiaznik 2024-03-04 18:37:26 +01:00
parent 177e72dd16
commit 938ad08a60
No known key found for this signature in database
GPG Key ID: 4D5C8972A02C52C2
7 changed files with 239 additions and 103 deletions

View File

@ -1,63 +1,123 @@
import type { InMemoryCacheConfig } from '@apollo/client'; import type { InMemoryCacheConfig } from '@apollo/client';
import { import {
AppFailure,
AppLoader, AppLoader,
NetworkLoader, NetworkLoader,
NodeFailure,
NodeGuard,
useEnvironment, useEnvironment,
useNodeSwitcherStore,
} from '@vegaprotocol/environment'; } from '@vegaprotocol/environment';
import { type ReactNode } from 'react'; import { useEffect, type ReactNode, useState } from 'react';
import { Web3Provider } from './web3-provider'; import { Web3Provider } from './web3-provider';
import { useT } from '../../lib/use-t'; import { useT } from '../../lib/use-t';
import { DataLoader } from './data-loader'; import { DataLoader } from './data-loader';
import { WalletProvider } from '@vegaprotocol/wallet-react'; import { WalletProvider } from '@vegaprotocol/wallet-react';
import { useVegaWalletConfig } from '../../lib/hooks/use-vega-wallet-config'; import { useVegaWalletConfig } from '../../lib/hooks/use-vega-wallet-config';
import { Trans } from 'react-i18next';
import { Button, Loader, Splash, VLogo } from '@vegaprotocol/ui-toolkit';
const Failure = ({ reason }: { reason?: ReactNode }) => {
const t = useT();
const setNodeSwitcher = useNodeSwitcherStore((store) => store.setDialogOpen);
return (
<Splash>
<div className="border border-vega-red m-10 mx-auto w-4/5 max-w-3xl rounded-lg overflow-hidden animate-shake">
<div className="bg-vega-red text-white px-2 py-2 flex gap-1 items-center font-alpha calt uppercase">
<VLogo className="h-4" />
<span className="text-lg">{t('Failed to initialize the app')}</span>
</div>
<div className="p-4 text-left text-sm">
<p className="mb-4">{reason}</p>
<div className="text-center">
<Button className="border-2" onClick={() => setNodeSwitcher(true)}>
{t('Change node')}
</Button>
</div>
</div>
</div>
</Splash>
);
};
const Loading = () => {
const [showSlowNotification, setShowSlowNotification] = useState(false);
const t = useT();
const setNodeSwitcher = useNodeSwitcherStore((store) => store.setDialogOpen);
useEffect(() => {
setTimeout(() => {
if (!showSlowNotification) setShowSlowNotification(true);
}, 5000);
}, [showSlowNotification]);
return (
<Splash>
<div className="border border-transparent m-10 mx-auto w-4/5 max-w-3xl rounded-lg overflow-hidden">
<div className="mt-11 p-4 text-left text-sm">
<Loader />
{showSlowNotification && (
<>
<p className="mt-4 text-center">
{t(
"It looks like you're connection is slow, try switching to another node."
)}
</p>
<div className="mt-4 text-center">
<Button
className="border-2"
onClick={() => setNodeSwitcher(true)}
>
{t('Change node')}
</Button>
</div>
</>
)}
</div>
</div>
</Splash>
);
};
export const Bootstrapper = ({ children }: { children: ReactNode }) => { export const Bootstrapper = ({ children }: { children: ReactNode }) => {
const t = useT(); const t = useT();
const { error, VEGA_URL } = useEnvironment();
const { error, VEGA_URL } = useEnvironment((state) => ({
error: state.error,
VEGA_URL: state.VEGA_URL,
}));
const config = useVegaWalletConfig(); const config = useVegaWalletConfig();
if (!config) { if (!config) {
return <AppLoader />; return <AppLoader />;
} }
const ERR_DATA_LOADER = (
<Trans
i18nKey="It appears that the connection to the node <0>{{VEGA_URL}}</0> does not return necessary data, try switching to another node."
components={[
<span key="vega" className="text-muted">
{VEGA_URL}
</span>,
]}
values={{
VEGA_URL,
}}
/>
);
return ( return (
<NetworkLoader <NetworkLoader
cache={cacheConfig} cache={cacheConfig}
skeleton={<AppLoader />} skeleton={<Loading />}
failure={ failure={<Failure reason={error} />}
<AppFailure title={t('Could not initialize app')} error={error} />
}
> >
<NodeGuard <DataLoader
skeleton={<AppLoader />} skeleton={<Loading />}
failure={ failure={<Failure reason={ERR_DATA_LOADER} />}
<NodeFailure
title={t('Node: {{VEGA_URL}} is unsuitable', { VEGA_URL })}
/>
}
> >
<DataLoader <Web3Provider
skeleton={<AppLoader />} skeleton={<Loading />}
failure={ failure={<Failure reason={t('Could not configure web3 provider')} />}
<AppFailure
title={t('Could not load market data or asset data')}
error={error}
/>
}
> >
<Web3Provider <WalletProvider config={config}>{children}</WalletProvider>
skeleton={<AppLoader />} </Web3Provider>
failure={ </DataLoader>
<AppFailure title={t('Could not configure web3 provider')} />
}
>
<WalletProvider config={config}>{children}</WalletProvider>
</Web3Provider>
</DataLoader>
</NodeGuard>
</NetworkLoader> </NetworkLoader>
); );
}; };

View File

@ -34,6 +34,16 @@ const createDefaultMockClient = () => {
blockHeight: '100', blockHeight: '100',
vegaTime: new Date().toISOString(), vegaTime: new Date().toISOString(),
}, },
networkParametersConnection: {
edges: [
{
node: {
key: 'something',
value: 123,
},
},
],
},
}, },
}), }),
subscribe: () => ({ subscribe: () => ({
@ -183,6 +193,16 @@ describe('useEnvironment', () => {
blockHeight: '100', blockHeight: '100',
vegaTime: new Date(1).toISOString(), vegaTime: new Date(1).toISOString(),
}, },
networkParametersConnection: {
edges: [
{
node: {
key: 'something',
value: 123,
},
},
],
},
}, },
}); });
}, wait); }, wait);
@ -244,6 +264,16 @@ describe('useEnvironment', () => {
blockHeight: '100', blockHeight: '100',
vegaTime: new Date().toISOString(), vegaTime: new Date().toISOString(),
}, },
networkParametersConnection: {
edges: [
{
node: {
key: 'something',
value: 123,
},
},
],
},
}, },
}), }),
subscribe: () => ({ subscribe: () => ({

View File

@ -1,5 +1,5 @@
import { parse as tomlParse } from 'toml'; import { parse as tomlParse } from 'toml';
import { isValidUrl, LocalStorage } from '@vegaprotocol/utils'; import { LocalStorage, isValidUrl } from '@vegaprotocol/utils';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { create } from 'zustand'; import { create } from 'zustand';
import { createClient } from '@vegaprotocol/apollo-client'; import { createClient } from '@vegaprotocol/apollo-client';
@ -22,6 +22,7 @@ import uniq from 'lodash/uniq';
import orderBy from 'lodash/orderBy'; import orderBy from 'lodash/orderBy';
import first from 'lodash/first'; import first from 'lodash/first';
import { canMeasureResponseTime, measureResponseTime } from '../utils/time'; import { canMeasureResponseTime, measureResponseTime } from '../utils/time';
import compact from 'lodash/compact';
type Client = ReturnType<typeof createClient>; type Client = ReturnType<typeof createClient>;
type ClientCollection = { type ClientCollection = {
@ -70,10 +71,19 @@ const fetchConfig = async (url?: string) => {
}; };
/** /**
* Find a suitable node by running a test query and test * Find a suitable nodes by running a test query and test
* subscription, against a list of clients, first to resolve wins * subscription, against a list of clients, first to resolve wins
*/ */
const findNode = async (clients: ClientCollection): Promise<string | null> => { const findHealthyNodes = async (nodes: string[]) => {
const clients: ClientCollection = {};
nodes.forEach((url) => {
clients[url] = createClient({
url,
cacheConfig: undefined,
retry: false,
connectToDevTools: false,
});
});
const tests = Object.entries(clients).map((args) => testNode(...args)); const tests = Object.entries(clients).map((args) => testNode(...args));
try { try {
const nodes = await Promise.all(tests); const nodes = await Promise.all(tests);
@ -93,11 +103,10 @@ const findNode = async (clients: ClientCollection): Promise<string | null> => {
['desc', 'desc', 'asc'] ['desc', 'desc', 'asc']
); );
const best = first(ordered); return ordered;
return best ? best.url : null;
} catch (err) { } catch (err) {
// All tests rejected, no suitable node found // All tests rejected, no suitable node found
return null; return [];
} }
}; };
@ -142,6 +151,15 @@ const testQuery = (
}) })
.then((result) => { .then((result) => {
if (result && !result.error) { if (result && !result.error) {
const netParams = compact(
result.data.networkParametersConnection.edges?.map((n) => n?.node)
);
if (netParams.length === 0) {
// any node that doesn't return the network parameters is considered
// failed
resolve(false);
return;
}
const res = { const res = {
blockHeight: Number(result.data.statistics.blockHeight), blockHeight: Number(result.data.statistics.blockHeight),
vegaTime: new Date(result.data.statistics.vegaTime), vegaTime: new Date(result.data.statistics.vegaTime),
@ -600,35 +618,31 @@ export const useEnvironment = create<EnvStore>()((set, get) => ({
} }
const state = get(); const state = get();
const storedUrl = LocalStorage.getItem(STORAGE_KEY);
let storedUrl = LocalStorage.getItem(STORAGE_KEY);
if (!isValidUrl(storedUrl)) {
// remove invalid data from local storage
LocalStorage.removeItem(STORAGE_KEY);
storedUrl = null;
}
let nodes: string[] | undefined; let nodes: string[] | undefined;
try { try {
nodes = await fetchConfig(state.VEGA_CONFIG_URL); nodes = uniq(
const enrichedNodes = uniq( compact([
[...nodes, state.VEGA_URL, storedUrl].filter(Boolean) as string[] // url from local storage
storedUrl,
// url from state (if set via env var)
state.VEGA_URL,
// urls from network configuration
...(await fetchConfig(state.VEGA_CONFIG_URL)),
])
); );
set({ nodes: enrichedNodes }); set({ nodes });
} catch (err) { } catch (err) {
console.warn(`Could not fetch node config from ${state.VEGA_CONFIG_URL}`); console.warn(`Could not fetch node config from ${state.VEGA_CONFIG_URL}`);
} }
// Node url found in localStorage, if its valid attempt to connect
if (storedUrl) {
if (isValidUrl(storedUrl)) {
set({ VEGA_URL: storedUrl, status: 'success' });
return;
} else {
LocalStorage.removeItem(STORAGE_KEY);
}
}
// VEGA_URL env var is set and is a valid url no need to proceed
if (state.VEGA_URL) {
set({ status: 'success' });
return;
}
// No url found in env vars or localStorage, AND no nodes were found in // No url found in env vars or localStorage, AND no nodes were found in
// the config fetched from VEGA_CONFIG_URL, app initialization has failed // the config fetched from VEGA_CONFIG_URL, app initialization has failed
if (!nodes || !nodes.length) { if (!nodes || !nodes.length) {
@ -639,36 +653,37 @@ export const useEnvironment = create<EnvStore>()((set, get) => ({
return; return;
} }
// Create a map of node urls to client instances const healthyNodes = await findHealthyNodes(nodes);
const clients: ClientCollection = {};
nodes.forEach((url) => {
clients[url] = createClient({
url,
cacheConfig: undefined,
retry: false,
connectToDevTools: false,
});
});
// Find a suitable node to connect to by attempting a query and a // A requested node is a node to which the app was previously connected
// subscription, first to fulfill both will be the resulting url. // or the one set via env variable.
const url = await findNode(clients); const requestedNodeUrl = storedUrl || state.VEGA_URL;
if (url !== null) { const bestNode = first(healthyNodes);
const requestedNode = healthyNodes.find(
(n) => requestedNodeUrl && n.url === requestedNodeUrl
);
if (!requestedNode) {
// remove unhealthy node url from local storage
LocalStorage.removeItem(STORAGE_KEY);
}
// A node's url (VEGA_URL) is either the requsted node (previously
// connected or taken form env variable) or the currently best available
// node.
const url = requestedNode?.url || bestNode?.url;
if (url != null) {
set({ set({
status: 'success', status: 'success',
VEGA_URL: url, VEGA_URL: url,
}); });
LocalStorage.setItem(STORAGE_KEY, url); LocalStorage.setItem(STORAGE_KEY, url);
} } else {
// Every node failed either to make a query or retrieve data from
// a subscription
else {
set({ set({
status: 'failed', status: 'failed',
error: 'No node found', error: 'No suitable node found',
}); });
console.warn('No suitable vega node was found'); console.warn('No suitable node was found');
} }
}, },
})); }));

View File

@ -1,9 +1,19 @@
query NodeCheck { query NodeCheck {
# statistics needed to get the most recent node in terms of block height
statistics { statistics {
chainId chainId
blockHeight blockHeight
vegaTime vegaTime
} }
# net params needed to filter out the nodes that are not suitable
networkParametersConnection {
edges {
node {
key
value
}
}
}
} }
subscription NodeCheckTimeUpdate { subscription NodeCheckTimeUpdate {

View File

@ -6,7 +6,7 @@ const defaultOptions = {} as const;
export type NodeCheckQueryVariables = Types.Exact<{ [key: string]: never; }>; export type NodeCheckQueryVariables = Types.Exact<{ [key: string]: never; }>;
export type NodeCheckQuery = { __typename?: 'Query', statistics: { __typename?: 'Statistics', chainId: string, blockHeight: string, vegaTime: any } }; export type NodeCheckQuery = { __typename?: 'Query', statistics: { __typename?: 'Statistics', chainId: string, blockHeight: string, vegaTime: any }, networkParametersConnection: { __typename?: 'NetworkParametersConnection', edges?: Array<{ __typename?: 'NetworkParameterEdge', node: { __typename?: 'NetworkParameter', key: string, value: string } } | null> | null } };
export type NodeCheckTimeUpdateSubscriptionVariables = Types.Exact<{ [key: string]: never; }>; export type NodeCheckTimeUpdateSubscriptionVariables = Types.Exact<{ [key: string]: never; }>;
@ -21,6 +21,14 @@ export const NodeCheckDocument = gql`
blockHeight blockHeight
vegaTime vegaTime
} }
networkParametersConnection {
edges {
node {
key
value
}
}
}
} }
`; `;

View File

@ -3,6 +3,8 @@ import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
import classNames from 'classnames'; import classNames from 'classnames';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { labelClasses } from '../checkbox'; import { labelClasses } from '../checkbox';
import { CopyWithTooltip } from '../copy-with-tooltip';
import { VegaIcon, VegaIconNames } from '../icon';
export interface TradingRadioGroupProps { export interface TradingRadioGroupProps {
name?: string; name?: string;
@ -86,25 +88,36 @@ export const TradingRadio = ({
'border-vega-clight-700 dark:border-vega-cdark-700' 'border-vega-clight-700 dark:border-vega-cdark-700'
); );
return ( return (
<label className={wrapperClasses} htmlFor={id}> <span className="inline-flex gap-2">
<RadioGroupPrimitive.Item <label className={wrapperClasses} htmlFor={id}>
value={value} <RadioGroupPrimitive.Item
className={itemClasses} value={value}
id={id} className={itemClasses}
data-testid={id} id={id}
disabled={disabled} data-testid={id}
> disabled={disabled}
<RadioGroupPrimitive.Indicator className={indicatorClasses} /> >
</RadioGroupPrimitive.Item> <RadioGroupPrimitive.Indicator className={indicatorClasses} />
<span </RadioGroupPrimitive.Item>
className={ <span
disabled className={
? 'text-vega-clight-200 dark:text-vega-cdark-200' disabled
: 'cursor-pointer' ? 'text-vega-clight-200 dark:text-vega-cdark-200'
} : 'cursor-pointer'
> }
{label} >
</span> {label}
</label> </span>
</label>
{value && value !== 'custom' && (
<span className="text-muted">
<CopyWithTooltip text={value}>
<button>
<VegaIcon name={VegaIconNames.COPY} />
</button>
</CopyWithTooltip>
</span>
)}
</span>
); );
}; };

View File

@ -1,4 +1,4 @@
export const isValidUrl = (url?: string) => { export const isValidUrl = (url?: string | null) => {
if (!url) return false; if (!url) return false;
try { try {
new URL(url); new URL(url);