Compare commits
10 Commits
develop
...
fix/1791-u
Author | SHA1 | Date | |
---|---|---|---|
|
85f7e791ba | ||
|
f3a369a146 | ||
|
a65941a5d3 | ||
|
8cf627aed4 | ||
|
4cae1c7677 | ||
|
ab9574761c | ||
|
58d0174934 | ||
|
eef562762a | ||
|
4102c5f653 | ||
|
bb4555c553 |
@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
addDecimal,
|
addDecimal,
|
||||||
fromNanoSeconds,
|
fromISONano,
|
||||||
t,
|
t,
|
||||||
useThemeSwitcher,
|
useThemeSwitcher,
|
||||||
} from '@vegaprotocol/react-helpers';
|
} from '@vegaprotocol/react-helpers';
|
||||||
@ -228,7 +228,7 @@ export const AccountHistoryChart = ({
|
|||||||
.reduce((acc, edge) => {
|
.reduce((acc, edge) => {
|
||||||
if (edge.node.accountType === accountType) {
|
if (edge.node.accountType === accountType) {
|
||||||
acc?.push({
|
acc?.push({
|
||||||
datetime: fromNanoSeconds(edge.node.timestamp),
|
datetime: fromISONano(edge.node.timestamp),
|
||||||
balance: Number(addDecimal(edge.node.balance, asset.decimals)),
|
balance: Number(addDecimal(edge.node.balance, asset.decimals)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react';
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
import { Footer, NodeHealth } from './footer';
|
import { Footer, NodeHealth } from './footer';
|
||||||
import { useEnvironment } from '@vegaprotocol/environment';
|
import { useEnvironment, useNodeHealth } from '@vegaprotocol/environment';
|
||||||
|
|
||||||
jest.mock('@vegaprotocol/environment');
|
jest.mock('@vegaprotocol/environment');
|
||||||
|
|
||||||
@ -12,10 +12,14 @@ describe('Footer', () => {
|
|||||||
// @ts-ignore mock env hook
|
// @ts-ignore mock env hook
|
||||||
useEnvironment.mockImplementation(() => ({
|
useEnvironment.mockImplementation(() => ({
|
||||||
VEGA_URL: `https://api.${node}/graphql`,
|
VEGA_URL: `https://api.${node}/graphql`,
|
||||||
blockDifference: 0,
|
|
||||||
setNodeSwitcherOpen: mockOpenNodeSwitcher,
|
setNodeSwitcherOpen: mockOpenNodeSwitcher,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// @ts-ignore mock env hook
|
||||||
|
useNodeHealth.mockImplementation(() => ({
|
||||||
|
blockDiff: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
render(<Footer />);
|
render(<Footer />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByText(node));
|
fireEvent.click(screen.getByText(node));
|
||||||
@ -29,10 +33,14 @@ describe('Footer', () => {
|
|||||||
// @ts-ignore mock env hook
|
// @ts-ignore mock env hook
|
||||||
useEnvironment.mockImplementation(() => ({
|
useEnvironment.mockImplementation(() => ({
|
||||||
VEGA_URL: `https://api.${node}/graphql`,
|
VEGA_URL: `https://api.${node}/graphql`,
|
||||||
blockDifference: 0,
|
|
||||||
setNodeSwitcherOpen: mockOpenNodeSwitcher,
|
setNodeSwitcherOpen: mockOpenNodeSwitcher,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// @ts-ignore mock env hook
|
||||||
|
useNodeHealth.mockImplementation(() => ({
|
||||||
|
blockDiff: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
render(<Footer />);
|
render(<Footer />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('Operational'));
|
fireEvent.click(screen.getByText('Operational'));
|
||||||
@ -43,8 +51,9 @@ describe('Footer', () => {
|
|||||||
describe('NodeHealth', () => {
|
describe('NodeHealth', () => {
|
||||||
const cases = [
|
const cases = [
|
||||||
{ diff: 0, classname: 'bg-success', text: 'Operational' },
|
{ diff: 0, classname: 'bg-success', text: 'Operational' },
|
||||||
|
{ diff: -1, classname: 'bg-success', text: 'Operational' },
|
||||||
{ diff: 5, classname: 'bg-warning', text: '5 Blocks behind' },
|
{ diff: 5, classname: 'bg-warning', text: '5 Blocks behind' },
|
||||||
{ diff: -1, classname: 'bg-danger', text: 'Non operational' },
|
{ diff: null, classname: 'bg-danger', text: 'Non operational' },
|
||||||
];
|
];
|
||||||
it.each(cases)(
|
it.each(cases)(
|
||||||
'renders correct text and indicator color for $diff block difference',
|
'renders correct text and indicator color for $diff block difference',
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import { useEnvironment } from '@vegaprotocol/environment';
|
import { useEnvironment, useNodeHealth } from '@vegaprotocol/environment';
|
||||||
import { t, useNavigatorOnline } from '@vegaprotocol/react-helpers';
|
import { t, useNavigatorOnline } from '@vegaprotocol/react-helpers';
|
||||||
import { ButtonLink, Indicator, Intent } from '@vegaprotocol/ui-toolkit';
|
import { ButtonLink, Indicator, Intent } from '@vegaprotocol/ui-toolkit';
|
||||||
|
|
||||||
export const Footer = () => {
|
export const Footer = () => {
|
||||||
const { VEGA_URL, blockDifference, setNodeSwitcherOpen } = useEnvironment();
|
const { VEGA_URL, setNodeSwitcherOpen } = useEnvironment();
|
||||||
|
const { blockDiff } = useNodeHealth();
|
||||||
return (
|
return (
|
||||||
<footer className="px-4 py-1 text-xs border-t border-default">
|
<footer className="px-4 py-1 text-xs border-t border-default">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
@ -11,10 +12,9 @@ export const Footer = () => {
|
|||||||
{VEGA_URL && (
|
{VEGA_URL && (
|
||||||
<>
|
<>
|
||||||
<NodeHealth
|
<NodeHealth
|
||||||
blockDiff={blockDifference}
|
blockDiff={blockDiff}
|
||||||
openNodeSwitcher={setNodeSwitcherOpen}
|
openNodeSwitcher={setNodeSwitcherOpen}
|
||||||
/>
|
/>
|
||||||
{' | '}
|
|
||||||
<NodeUrl url={VEGA_URL} openNodeSwitcher={setNodeSwitcherOpen} />
|
<NodeUrl url={VEGA_URL} openNodeSwitcher={setNodeSwitcherOpen} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -37,8 +37,8 @@ const NodeUrl = ({ url, openNodeSwitcher }: NodeUrlProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface NodeHealthProps {
|
interface NodeHealthProps {
|
||||||
|
blockDiff: number | null;
|
||||||
openNodeSwitcher: () => void;
|
openNodeSwitcher: () => void;
|
||||||
blockDiff: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// How many blocks behind the most advanced block that is
|
// How many blocks behind the most advanced block that is
|
||||||
@ -57,7 +57,7 @@ export const NodeHealth = ({
|
|||||||
if (!online) {
|
if (!online) {
|
||||||
text = t('Offline');
|
text = t('Offline');
|
||||||
intent = Intent.Danger;
|
intent = Intent.Danger;
|
||||||
} else if (blockDiff < 0) {
|
} else if (blockDiff === null) {
|
||||||
// Block height query failed and null was returned
|
// Block height query failed and null was returned
|
||||||
text = t('Non operational');
|
text = t('Non operational');
|
||||||
intent = Intent.Danger;
|
intent = Intent.Danger;
|
||||||
@ -67,9 +67,9 @@ export const NodeHealth = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span>
|
<>
|
||||||
<Indicator variant={intent} />
|
<Indicator variant={intent} />
|
||||||
<ButtonLink onClick={openNodeSwitcher}>{text}</ButtonLink>
|
<ButtonLink onClick={openNodeSwitcher}>{text}</ButtonLink>
|
||||||
</span>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1 +1,2 @@
|
|||||||
export * from './lib/apollo-client';
|
export * from './lib/apollo-client';
|
||||||
|
export * from './lib/header-store';
|
||||||
|
@ -13,7 +13,11 @@ import { createClient as createWSClient } from 'graphql-ws';
|
|||||||
import { onError } from '@apollo/client/link/error';
|
import { onError } from '@apollo/client/link/error';
|
||||||
import { RetryLink } from '@apollo/client/link/retry';
|
import { RetryLink } from '@apollo/client/link/retry';
|
||||||
import ApolloLinkTimeout from 'apollo-link-timeout';
|
import ApolloLinkTimeout from 'apollo-link-timeout';
|
||||||
import { localLoggerFactory } from '@vegaprotocol/react-helpers';
|
import {
|
||||||
|
fromNanoSeconds,
|
||||||
|
localLoggerFactory,
|
||||||
|
} from '@vegaprotocol/react-helpers';
|
||||||
|
import { useHeaderStore } from './header-store';
|
||||||
|
|
||||||
const isBrowser = typeof window !== 'undefined';
|
const isBrowser = typeof window !== 'undefined';
|
||||||
|
|
||||||
@ -24,6 +28,7 @@ export type ClientOptions = {
|
|||||||
cacheConfig?: InMemoryCacheConfig;
|
cacheConfig?: InMemoryCacheConfig;
|
||||||
retry?: boolean;
|
retry?: boolean;
|
||||||
connectToDevTools?: boolean;
|
connectToDevTools?: boolean;
|
||||||
|
connectToHeaderStore?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createClient({
|
export function createClient({
|
||||||
@ -31,6 +36,7 @@ export function createClient({
|
|||||||
cacheConfig,
|
cacheConfig,
|
||||||
retry = true,
|
retry = true,
|
||||||
connectToDevTools = true,
|
connectToDevTools = true,
|
||||||
|
connectToHeaderStore = true,
|
||||||
}: ClientOptions) {
|
}: ClientOptions) {
|
||||||
if (!url) {
|
if (!url) {
|
||||||
throw new Error('url must be passed into createClient!');
|
throw new Error('url must be passed into createClient!');
|
||||||
@ -44,6 +50,28 @@ export function createClient({
|
|||||||
return forward(operation);
|
return forward(operation);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const headerLink = connectToHeaderStore
|
||||||
|
? new ApolloLink((operation, forward) => {
|
||||||
|
return forward(operation).map((response) => {
|
||||||
|
const context = operation.getContext();
|
||||||
|
const headers = context['response'].headers;
|
||||||
|
const blockHeight = headers.get('x-block-height');
|
||||||
|
const timestamp = headers.get('x-block-timestamp');
|
||||||
|
if (blockHeight && timestamp) {
|
||||||
|
const state = useHeaderStore.getState();
|
||||||
|
useHeaderStore.setState({
|
||||||
|
...state,
|
||||||
|
[context['response'].url]: {
|
||||||
|
blockHeight: Number(blockHeight),
|
||||||
|
timestamp: fromNanoSeconds(timestamp),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
: noOpLink;
|
||||||
|
|
||||||
const timeoutLink = new ApolloLinkTimeout(10000);
|
const timeoutLink = new ApolloLinkTimeout(10000);
|
||||||
const enlargedTimeoutLink = new ApolloLinkTimeout(100000);
|
const enlargedTimeoutLink = new ApolloLinkTimeout(100000);
|
||||||
|
|
||||||
@ -104,7 +132,13 @@ export function createClient({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return new ApolloClient({
|
return new ApolloClient({
|
||||||
link: from([errorLink, composedTimeoutLink, retryLink, splitLink]),
|
link: from([
|
||||||
|
errorLink,
|
||||||
|
composedTimeoutLink,
|
||||||
|
headerLink,
|
||||||
|
retryLink,
|
||||||
|
splitLink,
|
||||||
|
]),
|
||||||
cache: new InMemoryCache(cacheConfig),
|
cache: new InMemoryCache(cacheConfig),
|
||||||
connectToDevTools,
|
connectToDevTools,
|
||||||
});
|
});
|
||||||
|
10
libs/apollo-client/src/lib/header-store.ts
Normal file
10
libs/apollo-client/src/lib/header-store.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
type HeaderStore = {
|
||||||
|
[url: string]: {
|
||||||
|
blockHeight: number;
|
||||||
|
timestamp: Date;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useHeaderStore = create<HeaderStore>(() => ({}));
|
@ -13,6 +13,10 @@ type NodeStatsContentProps = {
|
|||||||
setBlock: (value: number) => void;
|
setBlock: (value: number) => void;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
dataTestId?: string;
|
dataTestId?: string;
|
||||||
|
headers?: {
|
||||||
|
blockHeight: number;
|
||||||
|
timestamp: Date;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getResponseTimeDisplayValue = (
|
const getResponseTimeDisplayValue = (
|
||||||
@ -28,13 +32,13 @@ const getResponseTimeDisplayValue = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getBlockDisplayValue = (
|
const getBlockDisplayValue = (
|
||||||
block: NodeData['block'] | undefined,
|
block: number | undefined,
|
||||||
setBlock: (block: number) => void
|
setBlock: (block: number) => void
|
||||||
) => {
|
) => {
|
||||||
if (block?.value) {
|
if (block) {
|
||||||
return <NodeBlockHeight value={block?.value} setValue={setBlock} />;
|
return <NodeBlockHeight value={block} setValue={setBlock} />;
|
||||||
}
|
}
|
||||||
if (block?.hasError) {
|
if (!block) {
|
||||||
return t('n/a');
|
return t('n/a');
|
||||||
}
|
}
|
||||||
return '-';
|
return '-';
|
||||||
@ -59,6 +63,7 @@ const NodeStatsContent = ({
|
|||||||
setBlock,
|
setBlock,
|
||||||
children,
|
children,
|
||||||
dataTestId,
|
dataTestId,
|
||||||
|
headers,
|
||||||
}: NodeStatsContentProps) => {
|
}: NodeStatsContentProps) => {
|
||||||
return (
|
return (
|
||||||
<LayoutRow dataTestId={dataTestId}>
|
<LayoutRow dataTestId={dataTestId}>
|
||||||
@ -72,15 +77,12 @@ const NodeStatsContent = ({
|
|||||||
{getResponseTimeDisplayValue(data.responseTime)}
|
{getResponseTimeDisplayValue(data.responseTime)}
|
||||||
</LayoutCell>
|
</LayoutCell>
|
||||||
<LayoutCell
|
<LayoutCell
|
||||||
label={t('Block')}
|
label={t('Header block')}
|
||||||
isLoading={data.block?.isLoading}
|
isLoading={false}
|
||||||
hasError={
|
hasError={headers ? highestBlock - 3 > headers.blockHeight : false}
|
||||||
data.block?.hasError ||
|
dataTestId="header-block-cell"
|
||||||
(!!data.block?.value && highestBlock > data.block.value)
|
|
||||||
}
|
|
||||||
dataTestId="block-cell"
|
|
||||||
>
|
>
|
||||||
{getBlockDisplayValue(data.block, setBlock)}
|
{getBlockDisplayValue(headers?.blockHeight, setBlock)}
|
||||||
</LayoutCell>
|
</LayoutCell>
|
||||||
<LayoutCell
|
<LayoutCell
|
||||||
label={t('Subscription')}
|
label={t('Subscription')}
|
||||||
@ -113,6 +115,10 @@ export type NodeStatsProps = {
|
|||||||
highestBlock: number;
|
highestBlock: number;
|
||||||
setBlock: (value: number) => void;
|
setBlock: (value: number) => void;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
|
headers: {
|
||||||
|
blockHeight: number;
|
||||||
|
timestamp: Date;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NodeStats = ({
|
export const NodeStats = ({
|
||||||
@ -121,6 +127,7 @@ export const NodeStats = ({
|
|||||||
highestBlock,
|
highestBlock,
|
||||||
children,
|
children,
|
||||||
setBlock,
|
setBlock,
|
||||||
|
headers,
|
||||||
}: NodeStatsProps) => {
|
}: NodeStatsProps) => {
|
||||||
return (
|
return (
|
||||||
<Wrapper client={client}>
|
<Wrapper client={client}>
|
||||||
@ -129,6 +136,7 @@ export const NodeStats = ({
|
|||||||
highestBlock={highestBlock}
|
highestBlock={highestBlock}
|
||||||
setBlock={setBlock}
|
setBlock={setBlock}
|
||||||
dataTestId="node-row"
|
dataTestId="node-row"
|
||||||
|
headers={headers}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</NodeStatsContent>
|
</NodeStatsContent>
|
||||||
|
@ -21,6 +21,7 @@ import type { Configuration, NodeData, ErrorType } from '../../types';
|
|||||||
import { LayoutRow } from './layout-row';
|
import { LayoutRow } from './layout-row';
|
||||||
import { NodeError } from './node-error';
|
import { NodeError } from './node-error';
|
||||||
import { NodeStats } from './node-stats';
|
import { NodeStats } from './node-stats';
|
||||||
|
import { useHeaderStore } from '@vegaprotocol/apollo-client';
|
||||||
|
|
||||||
type NodeSwitcherProps = {
|
type NodeSwitcherProps = {
|
||||||
error?: string;
|
error?: string;
|
||||||
@ -46,6 +47,7 @@ export const NodeSwitcher = ({
|
|||||||
onConnect,
|
onConnect,
|
||||||
}: NodeSwitcherProps) => {
|
}: NodeSwitcherProps) => {
|
||||||
const { VEGA_ENV, VEGA_URL } = useEnvironment();
|
const { VEGA_ENV, VEGA_URL } = useEnvironment();
|
||||||
|
const headerStore = useHeaderStore();
|
||||||
const [networkError, setNetworkError] = useState(
|
const [networkError, setNetworkError] = useState(
|
||||||
getErrorByType(initialErrorType, VEGA_ENV, VEGA_URL)
|
getErrorByType(initialErrorType, VEGA_ENV, VEGA_URL)
|
||||||
);
|
);
|
||||||
@ -96,7 +98,7 @@ export const NodeSwitcher = ({
|
|||||||
<LayoutRow>
|
<LayoutRow>
|
||||||
<div />
|
<div />
|
||||||
<span className="text-right">{t('Response time')}</span>
|
<span className="text-right">{t('Response time')}</span>
|
||||||
<span className="text-right">{t('Block')}</span>
|
<span className="text-right">{t('Block height')}</span>
|
||||||
<span className="text-right">{t('Subscription')}</span>
|
<span className="text-right">{t('Subscription')}</span>
|
||||||
</LayoutRow>
|
</LayoutRow>
|
||||||
</div>
|
</div>
|
||||||
@ -115,6 +117,7 @@ export const NodeSwitcher = ({
|
|||||||
client={clients[node]}
|
client={clients[node]}
|
||||||
highestBlock={highestBlock}
|
highestBlock={highestBlock}
|
||||||
setBlock={(block) => updateNodeBlock(node, block)}
|
setBlock={(block) => updateNodeBlock(node, block)}
|
||||||
|
headers={headerStore[node]}
|
||||||
>
|
>
|
||||||
<div className="break-all" data-testid="node">
|
<div className="break-all" data-testid="node">
|
||||||
<Radio
|
<Radio
|
||||||
@ -131,6 +134,7 @@ export const NodeSwitcher = ({
|
|||||||
client={customUrl ? clients[customUrl] : undefined}
|
client={customUrl ? clients[customUrl] : undefined}
|
||||||
highestBlock={highestBlock}
|
highestBlock={highestBlock}
|
||||||
setBlock={(block) => updateNodeBlock(CUSTOM_NODE_KEY, block)}
|
setBlock={(block) => updateNodeBlock(CUSTOM_NODE_KEY, block)}
|
||||||
|
headers={headerStore[CUSTOM_NODE_KEY]}
|
||||||
>
|
>
|
||||||
<div className="flex w-full mb-2">
|
<div className="flex w-full mb-2">
|
||||||
<Radio
|
<Radio
|
||||||
|
@ -37,6 +37,7 @@ export const getMockStatisticsResult = (
|
|||||||
__typename: 'Statistics',
|
__typename: 'Statistics',
|
||||||
chainId: `${env.toLowerCase()}-0123`,
|
chainId: `${env.toLowerCase()}-0123`,
|
||||||
blockHeight: '11',
|
blockHeight: '11',
|
||||||
|
vegaTime: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -45,6 +46,7 @@ export const getMockQueryResult = (env: Networks): StatisticsQuery => ({
|
|||||||
__typename: 'Statistics',
|
__typename: 'Statistics',
|
||||||
chainId: `${env.toLowerCase()}-0123`,
|
chainId: `${env.toLowerCase()}-0123`,
|
||||||
blockHeight: '11',
|
blockHeight: '11',
|
||||||
|
vegaTime: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -17,6 +17,10 @@ jest.mock('react-dom', () => ({
|
|||||||
createPortal: (node: ReactNode) => node,
|
createPortal: (node: ReactNode) => node,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
jest.mock('../components/node-switcher', () => ({
|
||||||
|
NodeSwitcher: () => <div />,
|
||||||
|
}));
|
||||||
|
|
||||||
global.fetch = jest.fn();
|
global.fetch = jest.fn();
|
||||||
|
|
||||||
const MockWrapper = (props: ComponentProps<typeof EnvironmentProvider>) => {
|
const MockWrapper = (props: ComponentProps<typeof EnvironmentProvider>) => {
|
||||||
@ -46,7 +50,6 @@ const mockEnvironmentState = {
|
|||||||
GITHUB_FEEDBACK_URL: 'https://github.com/test/feedback',
|
GITHUB_FEEDBACK_URL: 'https://github.com/test/feedback',
|
||||||
MAINTENANCE_PAGE: false,
|
MAINTENANCE_PAGE: false,
|
||||||
configLoading: false,
|
configLoading: false,
|
||||||
blockDifference: 0,
|
|
||||||
nodeSwitcherOpen: false,
|
nodeSwitcherOpen: false,
|
||||||
setNodeSwitcherOpen: noop,
|
setNodeSwitcherOpen: noop,
|
||||||
networkError: undefined,
|
networkError: undefined,
|
||||||
|
@ -25,7 +25,6 @@ import type {
|
|||||||
NodeData,
|
NodeData,
|
||||||
Configuration,
|
Configuration,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { useNodeHealth } from './use-node-health';
|
|
||||||
|
|
||||||
type EnvironmentProviderProps = {
|
type EnvironmentProviderProps = {
|
||||||
config?: Configuration;
|
config?: Configuration;
|
||||||
@ -36,7 +35,6 @@ type EnvironmentProviderProps = {
|
|||||||
export type EnvironmentState = Environment & {
|
export type EnvironmentState = Environment & {
|
||||||
configLoading: boolean;
|
configLoading: boolean;
|
||||||
networkError?: ErrorType;
|
networkError?: ErrorType;
|
||||||
blockDifference: number;
|
|
||||||
nodeSwitcherOpen: boolean;
|
nodeSwitcherOpen: boolean;
|
||||||
setNodeSwitcherOpen: () => void;
|
setNodeSwitcherOpen: () => void;
|
||||||
};
|
};
|
||||||
@ -86,8 +84,6 @@ export const EnvironmentProvider = ({
|
|||||||
environment.MAINTENANCE_PAGE
|
environment.MAINTENANCE_PAGE
|
||||||
);
|
);
|
||||||
|
|
||||||
const blockDifference = useNodeHealth(clients, environment.VEGA_URL);
|
|
||||||
|
|
||||||
const nodeKeys = Object.keys(nodes);
|
const nodeKeys = Object.keys(nodes);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -150,7 +146,6 @@ export const EnvironmentProvider = ({
|
|||||||
...environment,
|
...environment,
|
||||||
configLoading: loading,
|
configLoading: loading,
|
||||||
networkError,
|
networkError,
|
||||||
blockDifference,
|
|
||||||
nodeSwitcherOpen: isNodeSwitcherOpen,
|
nodeSwitcherOpen: isNodeSwitcherOpen,
|
||||||
setNodeSwitcherOpen: () => setNodeSwitcherOpen(true),
|
setNodeSwitcherOpen: () => setNodeSwitcherOpen(true),
|
||||||
}}
|
}}
|
||||||
|
@ -1,117 +1,118 @@
|
|||||||
import { act, renderHook } from '@testing-library/react';
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
import {
|
import { useNodeHealth } from './use-node-health';
|
||||||
useNodeHealth,
|
import type { MockedResponse } from '@apollo/react-testing';
|
||||||
NODE_SUBSET_COUNT,
|
import { MockedProvider } from '@apollo/react-testing';
|
||||||
INTERVAL_TIME,
|
import type { StatisticsQuery } from '../utils/__generated__/Node';
|
||||||
} from './use-node-health';
|
import { StatisticsDocument } from '../utils/__generated__/Node';
|
||||||
import type { createClient } from '@vegaprotocol/apollo-client';
|
import { useEnvironment } from './use-environment';
|
||||||
import type { ClientCollection } from './use-nodes';
|
import { useHeaderStore } from '@vegaprotocol/apollo-client';
|
||||||
|
|
||||||
function setup(...args: Parameters<typeof useNodeHealth>) {
|
const vegaUrl = 'https://foo.bar.com';
|
||||||
return renderHook(() => useNodeHealth(...args));
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMockClient(blockHeight: number) {
|
jest.mock('./use-environment');
|
||||||
|
jest.mock('@vegaprotocol/apollo-client');
|
||||||
|
|
||||||
|
// @ts-ignore ignore mock implementation
|
||||||
|
useEnvironment.mockImplementation(() => ({
|
||||||
|
VEGA_URL: vegaUrl,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createStatsMock = (
|
||||||
|
blockHeight: number
|
||||||
|
): MockedResponse<StatisticsQuery> => {
|
||||||
return {
|
return {
|
||||||
query: jest.fn().mockResolvedValue({
|
request: {
|
||||||
|
query: StatisticsDocument,
|
||||||
|
},
|
||||||
|
result: {
|
||||||
data: {
|
data: {
|
||||||
statistics: {
|
statistics: {
|
||||||
chainId: 'chain-id',
|
chainId: 'chain-id',
|
||||||
blockHeight: blockHeight.toString(),
|
blockHeight: blockHeight.toString(),
|
||||||
|
vegaTime: '12345',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
} as unknown as ReturnType<typeof createClient>;
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
function createRejectingClient() {
|
function setup(
|
||||||
return {
|
mock: MockedResponse<StatisticsQuery>,
|
||||||
query: () => Promise.reject(new Error('request failed')),
|
headers:
|
||||||
} as unknown as ReturnType<typeof createClient>;
|
| {
|
||||||
}
|
blockHeight: number;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
) {
|
||||||
|
// @ts-ignore ignore mock implementation
|
||||||
|
useHeaderStore.mockImplementation(() => ({
|
||||||
|
[vegaUrl]: headers,
|
||||||
|
}));
|
||||||
|
|
||||||
function createErroringClient() {
|
return renderHook(() => useNodeHealth(), {
|
||||||
return {
|
wrapper: ({ children }) => (
|
||||||
query: () =>
|
<MockedProvider mocks={[mock]}>{children}</MockedProvider>
|
||||||
Promise.resolve({
|
),
|
||||||
error: new Error('failed'),
|
});
|
||||||
}),
|
|
||||||
} as unknown as ReturnType<typeof createClient>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const CURRENT_URL = 'https://current.test.com';
|
|
||||||
|
|
||||||
describe('useNodeHealth', () => {
|
describe('useNodeHealth', () => {
|
||||||
beforeAll(() => {
|
it.each([
|
||||||
jest.useFakeTimers();
|
{ core: 1, node: 1, expected: 0 },
|
||||||
|
{ core: 1, node: 5, expected: -4 },
|
||||||
|
{ core: 10, node: 5, expected: 5 },
|
||||||
|
])(
|
||||||
|
'provides difference core block $core and node block $node',
|
||||||
|
async (cases) => {
|
||||||
|
const { result } = setup(createStatsMock(cases.core), {
|
||||||
|
blockHeight: cases.node,
|
||||||
|
timestamp: new Date(),
|
||||||
});
|
});
|
||||||
|
expect(result.current.blockDiff).toEqual(null);
|
||||||
it('provides difference between the highest block and the current block', async () => {
|
expect(result.current.coreBlockHeight).toEqual(undefined);
|
||||||
const highest = 100;
|
expect(result.current.datanodeBlockHeight).toEqual(cases.node);
|
||||||
const curr = 97;
|
await waitFor(() => {
|
||||||
const clientCollection: ClientCollection = {
|
expect(result.current.blockDiff).toEqual(cases.expected);
|
||||||
[CURRENT_URL]: createMockClient(curr),
|
expect(result.current.coreBlockHeight).toEqual(cases.core);
|
||||||
'https://n02.test.com': createMockClient(98),
|
expect(result.current.datanodeBlockHeight).toEqual(cases.node);
|
||||||
'https://n03.test.com': createMockClient(highest),
|
|
||||||
};
|
|
||||||
const { result } = setup(clientCollection, CURRENT_URL);
|
|
||||||
await act(async () => {
|
|
||||||
jest.advanceTimersByTime(INTERVAL_TIME);
|
|
||||||
});
|
});
|
||||||
expect(result.current).toBe(highest - curr);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns -1 if the current node query fails', async () => {
|
|
||||||
const clientCollection: ClientCollection = {
|
|
||||||
[CURRENT_URL]: createRejectingClient(),
|
|
||||||
'https://n02.test.com': createMockClient(200),
|
|
||||||
'https://n03.test.com': createMockClient(102),
|
|
||||||
};
|
|
||||||
const { result } = setup(clientCollection, CURRENT_URL);
|
|
||||||
await act(async () => {
|
|
||||||
jest.advanceTimersByTime(INTERVAL_TIME);
|
|
||||||
});
|
|
||||||
expect(result.current).toBe(-1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns -1 if the current node query returns an error', async () => {
|
|
||||||
const clientCollection: ClientCollection = {
|
|
||||||
[CURRENT_URL]: createErroringClient(),
|
|
||||||
'https://n02.test.com': createMockClient(200),
|
|
||||||
'https://n03.test.com': createMockClient(102),
|
|
||||||
};
|
|
||||||
const { result } = setup(clientCollection, CURRENT_URL);
|
|
||||||
await act(async () => {
|
|
||||||
jest.advanceTimersByTime(INTERVAL_TIME);
|
|
||||||
});
|
|
||||||
expect(result.current).toBe(-1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('queries against 5 random nodes along with the current url', async () => {
|
|
||||||
const clientCollection: ClientCollection = new Array(20)
|
|
||||||
.fill(null)
|
|
||||||
.reduce((obj, x, i) => {
|
|
||||||
obj[`https://n${i}.test.com`] = createMockClient(100);
|
|
||||||
return obj;
|
|
||||||
}, {} as ClientCollection);
|
|
||||||
clientCollection[CURRENT_URL] = createMockClient(100);
|
|
||||||
const spyOnCurrent = jest.spyOn(clientCollection[CURRENT_URL], 'query');
|
|
||||||
|
|
||||||
const { result } = setup(clientCollection, CURRENT_URL);
|
|
||||||
await act(async () => {
|
|
||||||
jest.advanceTimersByTime(INTERVAL_TIME);
|
|
||||||
});
|
|
||||||
|
|
||||||
let count = 0;
|
|
||||||
Object.values(clientCollection).forEach((client) => {
|
|
||||||
// @ts-ignore jest.fn() in client setup means mock will be present
|
|
||||||
if (client?.query.mock.calls.length) {
|
|
||||||
count++;
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it('block diff is null if query fails indicating non operational', async () => {
|
||||||
|
const failedQuery: MockedResponse<StatisticsQuery> = {
|
||||||
|
request: {
|
||||||
|
query: StatisticsDocument,
|
||||||
|
},
|
||||||
|
result: {
|
||||||
|
// @ts-ignore failed query with no result
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const { result } = setup(failedQuery, {
|
||||||
|
blockHeight: 1,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
expect(result.current.blockDiff).toEqual(null);
|
||||||
|
expect(result.current.coreBlockHeight).toEqual(undefined);
|
||||||
|
expect(result.current.datanodeBlockHeight).toEqual(1);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.blockDiff).toEqual(null);
|
||||||
|
expect(result.current.coreBlockHeight).toEqual(undefined);
|
||||||
|
expect(result.current.datanodeBlockHeight).toEqual(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(count).toBe(NODE_SUBSET_COUNT + 1);
|
it('returns 0 if no headers are found (wait until stats query resolves)', async () => {
|
||||||
expect(spyOnCurrent).toHaveBeenCalledTimes(1);
|
const { result } = setup(createStatsMock(1), undefined);
|
||||||
expect(result.current).toBe(0);
|
expect(result.current.blockDiff).toEqual(null);
|
||||||
|
expect(result.current.coreBlockHeight).toEqual(undefined);
|
||||||
|
expect(result.current.datanodeBlockHeight).toEqual(undefined);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.blockDiff).toEqual(0);
|
||||||
|
expect(result.current.coreBlockHeight).toEqual(1);
|
||||||
|
expect(result.current.datanodeBlockHeight).toEqual(undefined);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,88 +1,37 @@
|
|||||||
import compact from 'lodash/compact';
|
import { useMemo } from 'react';
|
||||||
import shuffle from 'lodash/shuffle';
|
import { useStatisticsQuery } from '../utils/__generated__/Node';
|
||||||
import type { createClient } from '@vegaprotocol/apollo-client';
|
import { useHeaderStore } from '@vegaprotocol/apollo-client';
|
||||||
import { useEffect, useState } from 'react';
|
import { fromISONano } from '@vegaprotocol/react-helpers';
|
||||||
import type { StatisticsQuery } from '../utils/__generated__/Node';
|
import { useEnvironment } from './use-environment';
|
||||||
import { StatisticsDocument } from '../utils/__generated__/Node';
|
|
||||||
import type { ClientCollection } from './use-nodes';
|
|
||||||
|
|
||||||
// How often to query other nodes
|
export const useNodeHealth = () => {
|
||||||
export const INTERVAL_TIME = 30 * 1000;
|
const { VEGA_URL } = useEnvironment();
|
||||||
// Number of nodes to query against
|
const headerStore = useHeaderStore();
|
||||||
export const NODE_SUBSET_COUNT = 5;
|
const headers = VEGA_URL ? headerStore[VEGA_URL] : undefined;
|
||||||
|
const { data } = useStatisticsQuery({
|
||||||
// Queries all nodes from the environment provider via an interval
|
pollInterval: 1000,
|
||||||
// to calculate and return the difference between the most advanced block
|
fetchPolicy: 'no-cache',
|
||||||
// and the block height of the current node
|
|
||||||
export const useNodeHealth = (clients: ClientCollection, vegaUrl?: string) => {
|
|
||||||
const [blockDiff, setBlockDiff] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!clients || !vegaUrl) return;
|
|
||||||
|
|
||||||
const fetchBlockHeight = async (
|
|
||||||
client?: ReturnType<typeof createClient>
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
const result = await client?.query<StatisticsQuery>({
|
|
||||||
query: StatisticsDocument,
|
|
||||||
fetchPolicy: 'no-cache', // always fetch and never cache
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result) return null;
|
const blockDiff = useMemo(() => {
|
||||||
if (result.error) return null;
|
if (!data?.statistics.blockHeight) {
|
||||||
return result;
|
|
||||||
} catch {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const getBlockHeights = async () => {
|
if (!headers) {
|
||||||
const nodes = Object.keys(clients).filter((key) => key !== vegaUrl);
|
return 0;
|
||||||
// make sure that your current vega url is always included
|
|
||||||
// so we can compare later
|
|
||||||
const testNodes = [vegaUrl, ...randomSubset(nodes, NODE_SUBSET_COUNT)];
|
|
||||||
const result = await Promise.all(
|
|
||||||
testNodes.map((node) => fetchBlockHeight(clients[node]))
|
|
||||||
);
|
|
||||||
const blockHeights: { [node: string]: number | null } = {};
|
|
||||||
testNodes.forEach((node, i) => {
|
|
||||||
const data = result[i];
|
|
||||||
const blockHeight = data
|
|
||||||
? Number(data?.data.statistics.blockHeight)
|
|
||||||
: null;
|
|
||||||
blockHeights[node] = blockHeight;
|
|
||||||
});
|
|
||||||
return blockHeights;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Every INTERVAL_TIME get block heights of a random subset
|
|
||||||
// of nodes and determine if your current node is falling behind
|
|
||||||
const interval = setInterval(async () => {
|
|
||||||
const blockHeights = await getBlockHeights();
|
|
||||||
const highestBlock = Math.max.apply(
|
|
||||||
null,
|
|
||||||
compact(Object.values(blockHeights))
|
|
||||||
);
|
|
||||||
const currNodeBlock = blockHeights[vegaUrl];
|
|
||||||
|
|
||||||
if (!currNodeBlock) {
|
|
||||||
// Block height query failed and null was returned
|
|
||||||
setBlockDiff(-1);
|
|
||||||
} else {
|
|
||||||
setBlockDiff(highestBlock - currNodeBlock);
|
|
||||||
}
|
}
|
||||||
}, INTERVAL_TIME);
|
|
||||||
|
|
||||||
return () => {
|
return Number(data.statistics.blockHeight) - headers.blockHeight;
|
||||||
clearInterval(interval);
|
}, [data, headers]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
coreBlockHeight: data?.statistics.blockHeight
|
||||||
|
? Number(data?.statistics.blockHeight)
|
||||||
|
: undefined,
|
||||||
|
coreVegaTime: fromISONano(data?.statistics.vegaTime),
|
||||||
|
datanodeBlockHeight: headers?.blockHeight,
|
||||||
|
datanodeVegaTime: headers?.timestamp,
|
||||||
|
blockDiff,
|
||||||
};
|
};
|
||||||
}, [clients, vegaUrl]);
|
|
||||||
|
|
||||||
return blockDiff;
|
|
||||||
};
|
|
||||||
|
|
||||||
const randomSubset = (arr: string[], size: number) => {
|
|
||||||
const shuffled = shuffle(arr);
|
|
||||||
return shuffled.slice(0, size);
|
|
||||||
};
|
};
|
||||||
|
@ -2,6 +2,7 @@ query Statistics {
|
|||||||
statistics {
|
statistics {
|
||||||
chainId
|
chainId
|
||||||
blockHeight
|
blockHeight
|
||||||
|
vegaTime
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
3
libs/environment/src/utils/__generated__/Node.ts
generated
3
libs/environment/src/utils/__generated__/Node.ts
generated
@ -6,7 +6,7 @@ const defaultOptions = {} as const;
|
|||||||
export type StatisticsQueryVariables = Types.Exact<{ [key: string]: never; }>;
|
export type StatisticsQueryVariables = Types.Exact<{ [key: string]: never; }>;
|
||||||
|
|
||||||
|
|
||||||
export type StatisticsQuery = { __typename?: 'Query', statistics: { __typename?: 'Statistics', chainId: string, blockHeight: string } };
|
export type StatisticsQuery = { __typename?: 'Query', statistics: { __typename?: 'Statistics', chainId: string, blockHeight: string, vegaTime: any } };
|
||||||
|
|
||||||
export type BlockTimeSubscriptionVariables = Types.Exact<{ [key: string]: never; }>;
|
export type BlockTimeSubscriptionVariables = Types.Exact<{ [key: string]: never; }>;
|
||||||
|
|
||||||
@ -19,6 +19,7 @@ export const StatisticsDocument = gql`
|
|||||||
statistics {
|
statistics {
|
||||||
chainId
|
chainId
|
||||||
blockHeight
|
blockHeight
|
||||||
|
vegaTime
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
@ -10,6 +10,7 @@ export const statisticsQuery = (
|
|||||||
__typename: 'Statistics',
|
__typename: 'Statistics',
|
||||||
chainId: 'chain-id',
|
chainId: 'chain-id',
|
||||||
blockHeight: '11',
|
blockHeight: '11',
|
||||||
|
vegaTime: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
addDecimalsFormatNumber,
|
addDecimalsFormatNumber,
|
||||||
DateRangeFilter,
|
DateRangeFilter,
|
||||||
fromNanoSeconds,
|
fromISONano,
|
||||||
getDateTimeFormat,
|
getDateTimeFormat,
|
||||||
SetFilter,
|
SetFilter,
|
||||||
t,
|
t,
|
||||||
@ -199,7 +199,7 @@ export const LedgerTable = forwardRef<AgGridReact, LedgerEntryProps>(
|
|||||||
valueFormatter={({
|
valueFormatter={({
|
||||||
value,
|
value,
|
||||||
}: VegaValueFormatterParams<LedgerEntry, 'vegaTime'>) =>
|
}: VegaValueFormatterParams<LedgerEntry, 'vegaTime'>) =>
|
||||||
value ? getDateTimeFormat().format(fromNanoSeconds(value)) : '-'
|
value ? getDateTimeFormat().format(fromISONano(value)) : '-'
|
||||||
}
|
}
|
||||||
filter={DateRangeFilter}
|
filter={DateRangeFilter}
|
||||||
/>
|
/>
|
||||||
|
@ -4,7 +4,12 @@ export const toNanoSeconds = (date: Date | string) => {
|
|||||||
return new Date(date).getTime().toString() + '000000';
|
return new Date(date).getTime().toString() + '000000';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fromNanoSeconds = (ts: string) => {
|
export const fromISONano = (ts: string) => {
|
||||||
const val = parseISO(ts);
|
const val = parseISO(ts);
|
||||||
return new Date(isValid(val) ? val : 0);
|
return new Date(isValid(val) ? val : 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const fromNanoSeconds = (ts: string | number) => {
|
||||||
|
const ms = Number(String(ts).slice(0, -6));
|
||||||
|
return new Date(ms);
|
||||||
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user