Compare commits

...

9 Commits

Author SHA1 Message Date
asiaznik
002738af70
chore: skip picking if VEGA_URL set 2024-03-08 14:11:42 +01:00
asiaznik
b0bbae4c2e
chore: skip picking if VEGA_URL set 2024-03-08 13:53:57 +01:00
asiaznik
635445f23b
chore: clear timeout 2024-03-08 13:53:57 +01:00
asiaznik
10e30b9f70
chore: move copy 2024-03-08 13:53:57 +01:00
asiaznik
46c6e27aec
chore: remove app failure component 2024-03-08 13:53:57 +01:00
asiaznik
4bd73bfc0f
chore: add np to mocks 2024-03-08 13:53:57 +01:00
asiaznik
15aad9db62
chore: add np to mocks 2024-03-08 13:53:57 +01:00
asiaznik
46308b381f
chore: i18n texts 2024-03-08 13:53:57 +01:00
asiaznik
938ad08a60
fix(trading): pick the best node to connect, slow notificatio, init error 2024-03-08 13:53:57 +01:00
15 changed files with 307 additions and 111 deletions

View File

@ -1,63 +1,126 @@
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(() => {
const to = setTimeout(() => {
if (!showSlowNotification) setShowSlowNotification(true);
}, 5000);
return () => {
clearTimeout(to);
};
}, [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
skeleton={<AppLoader />}
failure={
<NodeFailure
title={t('Node: {{VEGA_URL}} is unsuitable', { VEGA_URL })}
/>
}
> >
<DataLoader <DataLoader
skeleton={<AppLoader />} skeleton={<Loading />}
failure={ failure={<Failure reason={ERR_DATA_LOADER} />}
<AppFailure
title={t('Could not load market data or asset data')}
error={error}
/>
}
> >
<Web3Provider <Web3Provider
skeleton={<AppLoader />} skeleton={<Loading />}
failure={ failure={<Failure reason={t('Could not configure web3 provider')} />}
<AppFailure title={t('Could not configure web3 provider')} />
}
> >
<WalletProvider config={config}>{children}</WalletProvider> <WalletProvider config={config}>{children}</WalletProvider>
</Web3Provider> </Web3Provider>
</DataLoader> </DataLoader>
</NodeGuard>
</NetworkLoader> </NetworkLoader>
); );
}; };

View File

@ -1,16 +0,0 @@
import { Splash } from '@vegaprotocol/ui-toolkit';
export const AppFailure = ({
title,
error,
}: {
title: string;
error?: string | null;
}) => {
return (
<Splash>
<p className="mb-4 text-xl">{title}</p>
{error && <p className="mb-8 text-sm">{error}</p>}
</Splash>
);
};

View File

@ -1 +0,0 @@
export * from './app-failure';

View File

@ -1,4 +1,3 @@
export * from './app-failure';
export * from './app-loader'; export * from './app-loader';
export * from './network-loader'; export * from './network-loader';
export * from './network-switcher'; export * from './network-switcher';

View File

@ -40,6 +40,16 @@ const mockStatsQuery = (
vegaTime: new Date().toISOString(), vegaTime: new Date().toISOString(),
chainId: 'test-chain-id', chainId: 'test-chain-id',
}, },
networkParametersConnection: {
edges: [
{
node: {
key: 'a',
value: '1',
},
},
],
},
}, },
}, },
}); });
@ -335,6 +345,16 @@ describe('RowData', () => {
vegaTime: new Date().toISOString(), vegaTime: new Date().toISOString(),
chainId: 'test-chain-id', chainId: 'test-chain-id',
}, },
networkParametersConnection: {
edges: [
{
node: {
key: 'a',
value: '1',
},
},
],
},
}, },
}, },
}; };

View File

@ -1,4 +1,9 @@
import { TradingRadio } from '@vegaprotocol/ui-toolkit'; import {
CopyWithTooltip,
TradingRadio,
VegaIcon,
VegaIconNames,
} from '@vegaprotocol/ui-toolkit';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { CUSTOM_NODE_KEY } from '../../types'; import { CUSTOM_NODE_KEY } from '../../types';
import { import {
@ -127,8 +132,17 @@ export const RowData = ({
return ( return (
<> <>
{id !== CUSTOM_NODE_KEY && ( {id !== CUSTOM_NODE_KEY && (
<div className="break-all" data-testid="node"> <div className="break-all flex gap-2" data-testid="node">
<TradingRadio id={`node-url-${id}`} value={url} label={url} /> <TradingRadio id={`node-url-${id}`} value={url} label={url} />
{url.length > 0 && url !== 'custom' && (
<span className="text-muted">
<CopyWithTooltip text={url}>
<button>
<VegaIcon name={VegaIconNames.COPY} />
</button>
</CopyWithTooltip>
</span>
)}
</div> </div>
)} )}
<LayoutCell <LayoutCell

View File

@ -39,6 +39,19 @@ export const getMockStatisticsResult = (
blockHeight: '11', blockHeight: '11',
vegaTime: new Date().toISOString(), vegaTime: new Date().toISOString(),
}, },
networkParametersConnection: {
__typename: 'NetworkParametersConnection',
edges: [
{
__typename: 'NetworkParameterEdge',
node: {
__typename: 'NetworkParameter',
key: 'a',
value: '1',
},
},
],
},
}); });
export const getMockQueryResult = (env: Networks): NodeCheckQuery => ({ export const getMockQueryResult = (env: Networks): NodeCheckQuery => ({
@ -48,6 +61,19 @@ export const getMockQueryResult = (env: Networks): NodeCheckQuery => ({
blockHeight: '11', blockHeight: '11',
vegaTime: new Date().toISOString(), vegaTime: new Date().toISOString(),
}, },
networkParametersConnection: {
__typename: 'NetworkParametersConnection',
edges: [
{
__typename: 'NetworkParameterEdge',
node: {
__typename: 'NetworkParameter',
key: 'a',
value: '1',
},
},
],
},
}); });
function getHandler<T>( function getHandler<T>(

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,32 +618,34 @@ 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 // skip picking up the best node if VEGA_URL env variable is set
if (storedUrl) { if (state.VEGA_URL && isValidUrl(state.VEGA_URL)) {
if (isValidUrl(storedUrl)) { state.setUrl(state.VEGA_URL);
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; return;
} }
@ -639,37 +659,35 @@ 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);
set({ const requestedNode = healthyNodes.find(
status: 'success', (n) => requestedNodeUrl && n.url === requestedNodeUrl
VEGA_URL: url, );
}); if (!requestedNode) {
LocalStorage.setItem(STORAGE_KEY, url); // remove unhealthy node url from local storage
LocalStorage.removeItem(STORAGE_KEY);
} }
// Every node failed either to make a query or retrieve data from // A node's url (VEGA_URL) is either the requested node (previously
// a subscription // connected or taken form env variable) or the currently best available
else { // node.
const url = requestedNode?.url || bestNode?.url;
if (url != null) {
state.setUrl(url);
return;
}
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

@ -28,6 +28,16 @@ const createStatsMock = (
blockHeight: blockHeight.toString(), blockHeight: blockHeight.toString(),
vegaTime: '12345', vegaTime: '12345',
}, },
networkParametersConnection: {
edges: [
{
node: {
key: 'a',
value: '1',
},
},
],
},
}, },
}, },
}; };

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

@ -12,6 +12,19 @@ export const statisticsQuery = (
blockHeight: '11', blockHeight: '11',
vegaTime: new Date().toISOString(), vegaTime: new Date().toISOString(),
}, },
networkParametersConnection: {
__typename: 'NetworkParametersConnection',
edges: [
{
__typename: 'NetworkParameterEdge',
node: {
__typename: 'NetworkParameter',
key: 'a',
value: '1',
},
},
],
},
}; };
return merge(defaultResult, override); return merge(defaultResult, override);

View File

@ -485,5 +485,7 @@
"See all the live games on the cards below. Every on-chain game is community funded and designed. <0>Find out how to create one</0>.": "See all the live games on the cards below. Every on-chain game is community funded and designed. <0>Find out how to create one</0>.", "See all the live games on the cards below. Every on-chain game is community funded and designed. <0>Find out how to create one</0>.": "See all the live games on the cards below. Every on-chain game is community funded and designed. <0>Find out how to create one</0>.",
"Teams can earn rewards if they meet the goals set in the on-chain trading competitions. Track your earned rewards here, and see which teams are top of the leaderboard this month.": "Teams can earn rewards if they meet the goals set in the on-chain trading competitions. Track your earned rewards here, and see which teams are top of the leaderboard this month.", "Teams can earn rewards if they meet the goals set in the on-chain trading competitions. Track your earned rewards here, and see which teams are top of the leaderboard this month.": "Teams can earn rewards if they meet the goals set in the on-chain trading competitions. Track your earned rewards here, and see which teams are top of the leaderboard this month.",
"Currently no active games on the network.": "Currently no active games on the network.", "Currently no active games on the network.": "Currently no active games on the network.",
"Currently no active teams on the network.": "Currently no active teams on the network." "Currently no active teams on the network.": "Currently no active teams on the network.",
"It looks like you're connection is slow, try switching to another node.": "It looks like you're connection is slow, try switching to another node.",
"It appears that the connection to the node <0>{{VEGA_URL}}</0> does not return necessary data, try switching to another node.": "It appears that the connection to the node <0>{{VEGA_URL}}</0> does not return necessary data, try switching to another node."
} }

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