Compare commits

...

10 Commits

Author SHA1 Message Date
Matthew Russell
85f7e791ba
chore: update mock query result 2023-02-07 12:37:44 -08:00
Matthew Russell
f3a369a146
chore: mock nodeswitcher, remove block diff from tests 2023-02-07 12:37:44 -08:00
Matthew Russell
a65941a5d3
chore: update tests to accomodate useNodeHealth changes 2023-02-07 12:37:43 -08:00
Matthew Russell
8cf627aed4
chore: update footer tests to account for changes to useNodeHealth 2023-02-07 12:37:43 -08:00
Matthew Russell
4cae1c7677
fix: layout row 2023-02-07 12:37:43 -08:00
Matthew Russell
ab9574761c
chore: lift block height state for easier testing 2023-02-07 12:37:43 -08:00
Matthew Russell
58d0174934
chore: rename fromISONano to be more explicit 2023-02-07 12:37:43 -08:00
Matthew Russell
eef562762a
chore: only show node block height in node switcher table 2023-02-07 12:37:43 -08:00
Matthew Russell
4102c5f653
fix: update header store to contain header results for all nodes 2023-02-07 12:37:43 -08:00
Matthew Russell
bb4555c553
fix: get block height from header and compare with core block height 2023-02-07 12:37:42 -08:00
18 changed files with 240 additions and 216 deletions

View File

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

View File

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

View File

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

View File

@ -1 +1,2 @@
export * from './lib/apollo-client'; export * from './lib/apollo-client';
export * from './lib/header-store';

View File

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

View File

@ -0,0 +1,10 @@
import { create } from 'zustand';
type HeaderStore = {
[url: string]: {
blockHeight: number;
timestamp: Date;
};
};
export const useHeaderStore = create<HeaderStore>(() => ({}));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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);
expect(result.current.coreBlockHeight).toEqual(undefined);
expect(result.current.datanodeBlockHeight).toEqual(cases.node);
await waitFor(() => {
expect(result.current.blockDiff).toEqual(cases.expected);
expect(result.current.coreBlockHeight).toEqual(cases.core);
expect(result.current.datanodeBlockHeight).toEqual(cases.node);
});
}
);
it('provides difference between the highest block and the current block', async () => { it('block diff is null if query fails indicating non operational', async () => {
const highest = 100; const failedQuery: MockedResponse<StatisticsQuery> = {
const curr = 97; request: {
const clientCollection: ClientCollection = { query: StatisticsDocument,
[CURRENT_URL]: createMockClient(curr), },
'https://n02.test.com': createMockClient(98), result: {
'https://n03.test.com': createMockClient(highest), // @ts-ignore failed query with no result
data: {},
},
}; };
const { result } = setup(clientCollection, CURRENT_URL); const { result } = setup(failedQuery, {
await act(async () => { blockHeight: 1,
jest.advanceTimersByTime(INTERVAL_TIME); 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(result.current).toBe(highest - curr);
}); });
it('returns -1 if the current node query fails', async () => { it('returns 0 if no headers are found (wait until stats query resolves)', async () => {
const clientCollection: ClientCollection = { const { result } = setup(createStatsMock(1), undefined);
[CURRENT_URL]: createRejectingClient(), expect(result.current.blockDiff).toEqual(null);
'https://n02.test.com': createMockClient(200), expect(result.current.coreBlockHeight).toEqual(undefined);
'https://n03.test.com': createMockClient(102), expect(result.current.datanodeBlockHeight).toEqual(undefined);
}; await waitFor(() => {
const { result } = setup(clientCollection, CURRENT_URL); expect(result.current.blockDiff).toEqual(0);
await act(async () => { expect(result.current.coreBlockHeight).toEqual(1);
jest.advanceTimersByTime(INTERVAL_TIME); expect(result.current.datanodeBlockHeight).toEqual(undefined);
}); });
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++;
}
});
expect(count).toBe(NODE_SUBSET_COUNT + 1);
expect(spyOnCurrent).toHaveBeenCalledTimes(1);
expect(result.current).toBe(0);
}); });
}); });

View File

@ -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({
pollInterval: 1000,
fetchPolicy: 'no-cache',
});
// Queries all nodes from the environment provider via an interval const blockDiff = useMemo(() => {
// to calculate and return the difference between the most advanced block if (!data?.statistics.blockHeight) {
// and the block height of the current node return null;
export const useNodeHealth = (clients: ClientCollection, vegaUrl?: string) => { }
const [blockDiff, setBlockDiff] = useState(0);
useEffect(() => { if (!headers) {
if (!clients || !vegaUrl) return; return 0;
}
const fetchBlockHeight = async ( return Number(data.statistics.blockHeight) - headers.blockHeight;
client?: ReturnType<typeof createClient> }, [data, headers]);
) => {
try {
const result = await client?.query<StatisticsQuery>({
query: StatisticsDocument,
fetchPolicy: 'no-cache', // always fetch and never cache
});
if (!result) return null; return {
if (result.error) return null; coreBlockHeight: data?.statistics.blockHeight
return result; ? Number(data?.statistics.blockHeight)
} catch { : undefined,
return null; coreVegaTime: fromISONano(data?.statistics.vegaTime),
} datanodeBlockHeight: headers?.blockHeight,
}; datanodeVegaTime: headers?.timestamp,
blockDiff,
const getBlockHeights = async () => { };
const nodes = Object.keys(clients).filter((key) => key !== vegaUrl);
// 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 () => {
clearInterval(interval);
};
}, [clients, vegaUrl]);
return blockDiff;
};
const randomSubset = (arr: string[], size: number) => {
const shuffled = shuffle(arr);
return shuffled.slice(0, size);
}; };

View File

@ -2,6 +2,7 @@ query Statistics {
statistics { statistics {
chainId chainId
blockHeight blockHeight
vegaTime
} }
} }

View File

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

View File

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

View File

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

View File

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