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 {
|
||||
addDecimal,
|
||||
fromNanoSeconds,
|
||||
fromISONano,
|
||||
t,
|
||||
useThemeSwitcher,
|
||||
} from '@vegaprotocol/react-helpers';
|
||||
@ -228,7 +228,7 @@ export const AccountHistoryChart = ({
|
||||
.reduce((acc, edge) => {
|
||||
if (edge.node.accountType === accountType) {
|
||||
acc?.push({
|
||||
datetime: fromNanoSeconds(edge.node.timestamp),
|
||||
datetime: fromISONano(edge.node.timestamp),
|
||||
balance: Number(addDecimal(edge.node.balance, asset.decimals)),
|
||||
});
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { Footer, NodeHealth } from './footer';
|
||||
import { useEnvironment } from '@vegaprotocol/environment';
|
||||
import { useEnvironment, useNodeHealth } from '@vegaprotocol/environment';
|
||||
|
||||
jest.mock('@vegaprotocol/environment');
|
||||
|
||||
@ -12,10 +12,14 @@ describe('Footer', () => {
|
||||
// @ts-ignore mock env hook
|
||||
useEnvironment.mockImplementation(() => ({
|
||||
VEGA_URL: `https://api.${node}/graphql`,
|
||||
blockDifference: 0,
|
||||
setNodeSwitcherOpen: mockOpenNodeSwitcher,
|
||||
}));
|
||||
|
||||
// @ts-ignore mock env hook
|
||||
useNodeHealth.mockImplementation(() => ({
|
||||
blockDiff: 0,
|
||||
}));
|
||||
|
||||
render(<Footer />);
|
||||
|
||||
fireEvent.click(screen.getByText(node));
|
||||
@ -29,10 +33,14 @@ describe('Footer', () => {
|
||||
// @ts-ignore mock env hook
|
||||
useEnvironment.mockImplementation(() => ({
|
||||
VEGA_URL: `https://api.${node}/graphql`,
|
||||
blockDifference: 0,
|
||||
setNodeSwitcherOpen: mockOpenNodeSwitcher,
|
||||
}));
|
||||
|
||||
// @ts-ignore mock env hook
|
||||
useNodeHealth.mockImplementation(() => ({
|
||||
blockDiff: 0,
|
||||
}));
|
||||
|
||||
render(<Footer />);
|
||||
|
||||
fireEvent.click(screen.getByText('Operational'));
|
||||
@ -43,8 +51,9 @@ describe('Footer', () => {
|
||||
describe('NodeHealth', () => {
|
||||
const cases = [
|
||||
{ diff: 0, classname: 'bg-success', text: 'Operational' },
|
||||
{ diff: -1, classname: 'bg-success', text: 'Operational' },
|
||||
{ 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)(
|
||||
'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 { ButtonLink, Indicator, Intent } from '@vegaprotocol/ui-toolkit';
|
||||
|
||||
export const Footer = () => {
|
||||
const { VEGA_URL, blockDifference, setNodeSwitcherOpen } = useEnvironment();
|
||||
const { VEGA_URL, setNodeSwitcherOpen } = useEnvironment();
|
||||
const { blockDiff } = useNodeHealth();
|
||||
return (
|
||||
<footer className="px-4 py-1 text-xs border-t border-default">
|
||||
<div className="flex justify-between">
|
||||
@ -11,10 +12,9 @@ export const Footer = () => {
|
||||
{VEGA_URL && (
|
||||
<>
|
||||
<NodeHealth
|
||||
blockDiff={blockDifference}
|
||||
blockDiff={blockDiff}
|
||||
openNodeSwitcher={setNodeSwitcherOpen}
|
||||
/>
|
||||
{' | '}
|
||||
<NodeUrl url={VEGA_URL} openNodeSwitcher={setNodeSwitcherOpen} />
|
||||
</>
|
||||
)}
|
||||
@ -37,8 +37,8 @@ const NodeUrl = ({ url, openNodeSwitcher }: NodeUrlProps) => {
|
||||
};
|
||||
|
||||
interface NodeHealthProps {
|
||||
blockDiff: number | null;
|
||||
openNodeSwitcher: () => void;
|
||||
blockDiff: number;
|
||||
}
|
||||
|
||||
// How many blocks behind the most advanced block that is
|
||||
@ -57,7 +57,7 @@ export const NodeHealth = ({
|
||||
if (!online) {
|
||||
text = t('Offline');
|
||||
intent = Intent.Danger;
|
||||
} else if (blockDiff < 0) {
|
||||
} else if (blockDiff === null) {
|
||||
// Block height query failed and null was returned
|
||||
text = t('Non operational');
|
||||
intent = Intent.Danger;
|
||||
@ -67,9 +67,9 @@ export const NodeHealth = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
<>
|
||||
<Indicator variant={intent} />
|
||||
<ButtonLink onClick={openNodeSwitcher}>{text}</ButtonLink>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -1 +1,2 @@
|
||||
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 { RetryLink } from '@apollo/client/link/retry';
|
||||
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';
|
||||
|
||||
@ -24,6 +28,7 @@ export type ClientOptions = {
|
||||
cacheConfig?: InMemoryCacheConfig;
|
||||
retry?: boolean;
|
||||
connectToDevTools?: boolean;
|
||||
connectToHeaderStore?: boolean;
|
||||
};
|
||||
|
||||
export function createClient({
|
||||
@ -31,6 +36,7 @@ export function createClient({
|
||||
cacheConfig,
|
||||
retry = true,
|
||||
connectToDevTools = true,
|
||||
connectToHeaderStore = true,
|
||||
}: ClientOptions) {
|
||||
if (!url) {
|
||||
throw new Error('url must be passed into createClient!');
|
||||
@ -44,6 +50,28 @@ export function createClient({
|
||||
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 enlargedTimeoutLink = new ApolloLinkTimeout(100000);
|
||||
|
||||
@ -104,7 +132,13 @@ export function createClient({
|
||||
);
|
||||
|
||||
return new ApolloClient({
|
||||
link: from([errorLink, composedTimeoutLink, retryLink, splitLink]),
|
||||
link: from([
|
||||
errorLink,
|
||||
composedTimeoutLink,
|
||||
headerLink,
|
||||
retryLink,
|
||||
splitLink,
|
||||
]),
|
||||
cache: new InMemoryCache(cacheConfig),
|
||||
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;
|
||||
children?: ReactNode;
|
||||
dataTestId?: string;
|
||||
headers?: {
|
||||
blockHeight: number;
|
||||
timestamp: Date;
|
||||
};
|
||||
};
|
||||
|
||||
const getResponseTimeDisplayValue = (
|
||||
@ -28,13 +32,13 @@ const getResponseTimeDisplayValue = (
|
||||
};
|
||||
|
||||
const getBlockDisplayValue = (
|
||||
block: NodeData['block'] | undefined,
|
||||
block: number | undefined,
|
||||
setBlock: (block: number) => void
|
||||
) => {
|
||||
if (block?.value) {
|
||||
return <NodeBlockHeight value={block?.value} setValue={setBlock} />;
|
||||
if (block) {
|
||||
return <NodeBlockHeight value={block} setValue={setBlock} />;
|
||||
}
|
||||
if (block?.hasError) {
|
||||
if (!block) {
|
||||
return t('n/a');
|
||||
}
|
||||
return '-';
|
||||
@ -59,6 +63,7 @@ const NodeStatsContent = ({
|
||||
setBlock,
|
||||
children,
|
||||
dataTestId,
|
||||
headers,
|
||||
}: NodeStatsContentProps) => {
|
||||
return (
|
||||
<LayoutRow dataTestId={dataTestId}>
|
||||
@ -72,15 +77,12 @@ const NodeStatsContent = ({
|
||||
{getResponseTimeDisplayValue(data.responseTime)}
|
||||
</LayoutCell>
|
||||
<LayoutCell
|
||||
label={t('Block')}
|
||||
isLoading={data.block?.isLoading}
|
||||
hasError={
|
||||
data.block?.hasError ||
|
||||
(!!data.block?.value && highestBlock > data.block.value)
|
||||
}
|
||||
dataTestId="block-cell"
|
||||
label={t('Header block')}
|
||||
isLoading={false}
|
||||
hasError={headers ? highestBlock - 3 > headers.blockHeight : false}
|
||||
dataTestId="header-block-cell"
|
||||
>
|
||||
{getBlockDisplayValue(data.block, setBlock)}
|
||||
{getBlockDisplayValue(headers?.blockHeight, setBlock)}
|
||||
</LayoutCell>
|
||||
<LayoutCell
|
||||
label={t('Subscription')}
|
||||
@ -113,6 +115,10 @@ export type NodeStatsProps = {
|
||||
highestBlock: number;
|
||||
setBlock: (value: number) => void;
|
||||
children?: ReactNode;
|
||||
headers: {
|
||||
blockHeight: number;
|
||||
timestamp: Date;
|
||||
};
|
||||
};
|
||||
|
||||
export const NodeStats = ({
|
||||
@ -121,6 +127,7 @@ export const NodeStats = ({
|
||||
highestBlock,
|
||||
children,
|
||||
setBlock,
|
||||
headers,
|
||||
}: NodeStatsProps) => {
|
||||
return (
|
||||
<Wrapper client={client}>
|
||||
@ -129,6 +136,7 @@ export const NodeStats = ({
|
||||
highestBlock={highestBlock}
|
||||
setBlock={setBlock}
|
||||
dataTestId="node-row"
|
||||
headers={headers}
|
||||
>
|
||||
{children}
|
||||
</NodeStatsContent>
|
||||
|
@ -21,6 +21,7 @@ import type { Configuration, NodeData, ErrorType } from '../../types';
|
||||
import { LayoutRow } from './layout-row';
|
||||
import { NodeError } from './node-error';
|
||||
import { NodeStats } from './node-stats';
|
||||
import { useHeaderStore } from '@vegaprotocol/apollo-client';
|
||||
|
||||
type NodeSwitcherProps = {
|
||||
error?: string;
|
||||
@ -46,6 +47,7 @@ export const NodeSwitcher = ({
|
||||
onConnect,
|
||||
}: NodeSwitcherProps) => {
|
||||
const { VEGA_ENV, VEGA_URL } = useEnvironment();
|
||||
const headerStore = useHeaderStore();
|
||||
const [networkError, setNetworkError] = useState(
|
||||
getErrorByType(initialErrorType, VEGA_ENV, VEGA_URL)
|
||||
);
|
||||
@ -96,7 +98,7 @@ export const NodeSwitcher = ({
|
||||
<LayoutRow>
|
||||
<div />
|
||||
<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>
|
||||
</LayoutRow>
|
||||
</div>
|
||||
@ -115,6 +117,7 @@ export const NodeSwitcher = ({
|
||||
client={clients[node]}
|
||||
highestBlock={highestBlock}
|
||||
setBlock={(block) => updateNodeBlock(node, block)}
|
||||
headers={headerStore[node]}
|
||||
>
|
||||
<div className="break-all" data-testid="node">
|
||||
<Radio
|
||||
@ -131,6 +134,7 @@ export const NodeSwitcher = ({
|
||||
client={customUrl ? clients[customUrl] : undefined}
|
||||
highestBlock={highestBlock}
|
||||
setBlock={(block) => updateNodeBlock(CUSTOM_NODE_KEY, block)}
|
||||
headers={headerStore[CUSTOM_NODE_KEY]}
|
||||
>
|
||||
<div className="flex w-full mb-2">
|
||||
<Radio
|
||||
|
@ -37,6 +37,7 @@ export const getMockStatisticsResult = (
|
||||
__typename: 'Statistics',
|
||||
chainId: `${env.toLowerCase()}-0123`,
|
||||
blockHeight: '11',
|
||||
vegaTime: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
@ -45,6 +46,7 @@ export const getMockQueryResult = (env: Networks): StatisticsQuery => ({
|
||||
__typename: 'Statistics',
|
||||
chainId: `${env.toLowerCase()}-0123`,
|
||||
blockHeight: '11',
|
||||
vegaTime: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -17,6 +17,10 @@ jest.mock('react-dom', () => ({
|
||||
createPortal: (node: ReactNode) => node,
|
||||
}));
|
||||
|
||||
jest.mock('../components/node-switcher', () => ({
|
||||
NodeSwitcher: () => <div />,
|
||||
}));
|
||||
|
||||
global.fetch = jest.fn();
|
||||
|
||||
const MockWrapper = (props: ComponentProps<typeof EnvironmentProvider>) => {
|
||||
@ -46,7 +50,6 @@ const mockEnvironmentState = {
|
||||
GITHUB_FEEDBACK_URL: 'https://github.com/test/feedback',
|
||||
MAINTENANCE_PAGE: false,
|
||||
configLoading: false,
|
||||
blockDifference: 0,
|
||||
nodeSwitcherOpen: false,
|
||||
setNodeSwitcherOpen: noop,
|
||||
networkError: undefined,
|
||||
|
@ -25,7 +25,6 @@ import type {
|
||||
NodeData,
|
||||
Configuration,
|
||||
} from '../types';
|
||||
import { useNodeHealth } from './use-node-health';
|
||||
|
||||
type EnvironmentProviderProps = {
|
||||
config?: Configuration;
|
||||
@ -36,7 +35,6 @@ type EnvironmentProviderProps = {
|
||||
export type EnvironmentState = Environment & {
|
||||
configLoading: boolean;
|
||||
networkError?: ErrorType;
|
||||
blockDifference: number;
|
||||
nodeSwitcherOpen: boolean;
|
||||
setNodeSwitcherOpen: () => void;
|
||||
};
|
||||
@ -86,8 +84,6 @@ export const EnvironmentProvider = ({
|
||||
environment.MAINTENANCE_PAGE
|
||||
);
|
||||
|
||||
const blockDifference = useNodeHealth(clients, environment.VEGA_URL);
|
||||
|
||||
const nodeKeys = Object.keys(nodes);
|
||||
|
||||
useEffect(() => {
|
||||
@ -150,7 +146,6 @@ export const EnvironmentProvider = ({
|
||||
...environment,
|
||||
configLoading: loading,
|
||||
networkError,
|
||||
blockDifference,
|
||||
nodeSwitcherOpen: isNodeSwitcherOpen,
|
||||
setNodeSwitcherOpen: () => setNodeSwitcherOpen(true),
|
||||
}}
|
||||
|
@ -1,117 +1,118 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import {
|
||||
useNodeHealth,
|
||||
NODE_SUBSET_COUNT,
|
||||
INTERVAL_TIME,
|
||||
} from './use-node-health';
|
||||
import type { createClient } from '@vegaprotocol/apollo-client';
|
||||
import type { ClientCollection } from './use-nodes';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { useNodeHealth } from './use-node-health';
|
||||
import type { MockedResponse } from '@apollo/react-testing';
|
||||
import { MockedProvider } from '@apollo/react-testing';
|
||||
import type { StatisticsQuery } from '../utils/__generated__/Node';
|
||||
import { StatisticsDocument } from '../utils/__generated__/Node';
|
||||
import { useEnvironment } from './use-environment';
|
||||
import { useHeaderStore } from '@vegaprotocol/apollo-client';
|
||||
|
||||
function setup(...args: Parameters<typeof useNodeHealth>) {
|
||||
return renderHook(() => useNodeHealth(...args));
|
||||
}
|
||||
const vegaUrl = 'https://foo.bar.com';
|
||||
|
||||
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 {
|
||||
query: jest.fn().mockResolvedValue({
|
||||
request: {
|
||||
query: StatisticsDocument,
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
statistics: {
|
||||
chainId: 'chain-id',
|
||||
blockHeight: blockHeight.toString(),
|
||||
vegaTime: '12345',
|
||||
},
|
||||
},
|
||||
}),
|
||||
} as unknown as ReturnType<typeof createClient>;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
function createRejectingClient() {
|
||||
return {
|
||||
query: () => Promise.reject(new Error('request failed')),
|
||||
} as unknown as ReturnType<typeof createClient>;
|
||||
}
|
||||
function setup(
|
||||
mock: MockedResponse<StatisticsQuery>,
|
||||
headers:
|
||||
| {
|
||||
blockHeight: number;
|
||||
timestamp: Date;
|
||||
}
|
||||
| undefined
|
||||
) {
|
||||
// @ts-ignore ignore mock implementation
|
||||
useHeaderStore.mockImplementation(() => ({
|
||||
[vegaUrl]: headers,
|
||||
}));
|
||||
|
||||
function createErroringClient() {
|
||||
return {
|
||||
query: () =>
|
||||
Promise.resolve({
|
||||
error: new Error('failed'),
|
||||
}),
|
||||
} as unknown as ReturnType<typeof createClient>;
|
||||
return renderHook(() => useNodeHealth(), {
|
||||
wrapper: ({ children }) => (
|
||||
<MockedProvider mocks={[mock]}>{children}</MockedProvider>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const CURRENT_URL = 'https://current.test.com';
|
||||
|
||||
describe('useNodeHealth', () => {
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
it.each([
|
||||
{ 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 () => {
|
||||
const highest = 100;
|
||||
const curr = 97;
|
||||
const clientCollection: ClientCollection = {
|
||||
[CURRENT_URL]: createMockClient(curr),
|
||||
'https://n02.test.com': createMockClient(98),
|
||||
'https://n03.test.com': createMockClient(highest),
|
||||
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(clientCollection, CURRENT_URL);
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(INTERVAL_TIME);
|
||||
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(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);
|
||||
it('returns 0 if no headers are found (wait until stats query resolves)', async () => {
|
||||
const { result } = setup(createStatsMock(1), undefined);
|
||||
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);
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
@ -1,88 +1,37 @@
|
||||
import compact from 'lodash/compact';
|
||||
import shuffle from 'lodash/shuffle';
|
||||
import type { createClient } from '@vegaprotocol/apollo-client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { StatisticsQuery } from '../utils/__generated__/Node';
|
||||
import { StatisticsDocument } from '../utils/__generated__/Node';
|
||||
import type { ClientCollection } from './use-nodes';
|
||||
import { useMemo } from 'react';
|
||||
import { useStatisticsQuery } from '../utils/__generated__/Node';
|
||||
import { useHeaderStore } from '@vegaprotocol/apollo-client';
|
||||
import { fromISONano } from '@vegaprotocol/react-helpers';
|
||||
import { useEnvironment } from './use-environment';
|
||||
|
||||
// How often to query other nodes
|
||||
export const INTERVAL_TIME = 30 * 1000;
|
||||
// Number of nodes to query against
|
||||
export const NODE_SUBSET_COUNT = 5;
|
||||
export const useNodeHealth = () => {
|
||||
const { VEGA_URL } = useEnvironment();
|
||||
const headerStore = useHeaderStore();
|
||||
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
|
||||
// to calculate and return the difference between the most advanced block
|
||||
// and the block height of the current node
|
||||
export const useNodeHealth = (clients: ClientCollection, vegaUrl?: string) => {
|
||||
const [blockDiff, setBlockDiff] = useState(0);
|
||||
const blockDiff = useMemo(() => {
|
||||
if (!data?.statistics.blockHeight) {
|
||||
return null;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!clients || !vegaUrl) return;
|
||||
if (!headers) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const fetchBlockHeight = async (
|
||||
client?: ReturnType<typeof createClient>
|
||||
) => {
|
||||
try {
|
||||
const result = await client?.query<StatisticsQuery>({
|
||||
query: StatisticsDocument,
|
||||
fetchPolicy: 'no-cache', // always fetch and never cache
|
||||
});
|
||||
return Number(data.statistics.blockHeight) - headers.blockHeight;
|
||||
}, [data, headers]);
|
||||
|
||||
if (!result) return null;
|
||||
if (result.error) return null;
|
||||
return result;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
return {
|
||||
coreBlockHeight: data?.statistics.blockHeight
|
||||
? Number(data?.statistics.blockHeight)
|
||||
: undefined,
|
||||
coreVegaTime: fromISONano(data?.statistics.vegaTime),
|
||||
datanodeBlockHeight: headers?.blockHeight,
|
||||
datanodeVegaTime: headers?.timestamp,
|
||||
blockDiff,
|
||||
};
|
||||
};
|
||||
|
@ -2,6 +2,7 @@ query Statistics {
|
||||
statistics {
|
||||
chainId
|
||||
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 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; }>;
|
||||
|
||||
@ -19,6 +19,7 @@ export const StatisticsDocument = gql`
|
||||
statistics {
|
||||
chainId
|
||||
blockHeight
|
||||
vegaTime
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
@ -10,6 +10,7 @@ export const statisticsQuery = (
|
||||
__typename: 'Statistics',
|
||||
chainId: 'chain-id',
|
||||
blockHeight: '11',
|
||||
vegaTime: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import {
|
||||
addDecimalsFormatNumber,
|
||||
DateRangeFilter,
|
||||
fromNanoSeconds,
|
||||
fromISONano,
|
||||
getDateTimeFormat,
|
||||
SetFilter,
|
||||
t,
|
||||
@ -199,7 +199,7 @@ export const LedgerTable = forwardRef<AgGridReact, LedgerEntryProps>(
|
||||
valueFormatter={({
|
||||
value,
|
||||
}: VegaValueFormatterParams<LedgerEntry, 'vegaTime'>) =>
|
||||
value ? getDateTimeFormat().format(fromNanoSeconds(value)) : '-'
|
||||
value ? getDateTimeFormat().format(fromISONano(value)) : '-'
|
||||
}
|
||||
filter={DateRangeFilter}
|
||||
/>
|
||||
|
@ -4,7 +4,12 @@ export const toNanoSeconds = (date: Date | string) => {
|
||||
return new Date(date).getTime().toString() + '000000';
|
||||
};
|
||||
|
||||
export const fromNanoSeconds = (ts: string) => {
|
||||
export const fromISONano = (ts: string) => {
|
||||
const val = parseISO(ts);
|
||||
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