fix(trading): pick the best node to connect, slow notificatio, init error
This commit is contained in:
parent
177e72dd16
commit
938ad08a60
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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: () => ({
|
||||||
|
@ -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');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
@ -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 {
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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);
|
||||||
|
Loading…
Reference in New Issue
Block a user