diff --git a/apps/trading/components/footer/footer.spec.tsx b/apps/trading/components/footer/footer.spec.tsx
index b743a4fd2..0e9c5553e 100644
--- a/apps/trading/components/footer/footer.spec.tsx
+++ b/apps/trading/components/footer/footer.spec.tsx
@@ -1,20 +1,30 @@
-import { render, screen } from '@testing-library/react';
+import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { NodeHealth, NodeUrl, HealthIndicator } from './footer';
+import { MockedProvider } from '@apollo/client/testing';
+import { Intent } from '@vegaprotocol/ui-toolkit';
+
+jest.mock('@vegaprotocol/environment', () => ({
+ ...jest.requireActual('@vegaprotocol/environment'),
+ useEnvironment: jest
+ .fn()
+ .mockImplementation(() => ({ VEGA_URL: 'https://vega-url.wtf' })),
+}));
+
+const mockSetNodeSwitcher = jest.fn();
+jest.mock('../../stores', () => ({
+ ...jest.requireActual('../../stores'),
+ useGlobalStore: () => mockSetNodeSwitcher,
+}));
describe('NodeHealth', () => {
it('controls the node switcher dialog', async () => {
- const mockOnClick = jest.fn();
- render(
-
- );
+ render(, { wrapper: MockedProvider });
+ await waitFor(() => {
+ expect(screen.getByRole('button')).toBeInTheDocument();
+ });
await userEvent.click(screen.getByRole('button'));
- expect(mockOnClick).toHaveBeenCalled();
+ expect(mockSetNodeSwitcher).toHaveBeenCalled();
});
});
@@ -31,14 +41,22 @@ describe('NodeUrl', () => {
describe('HealthIndicator', () => {
const cases = [
- { diff: 0, classname: 'bg-vega-green-550', text: 'Operational' },
- { diff: 5, classname: 'bg-warning', text: '5 Blocks behind' },
- { diff: null, classname: 'bg-danger', text: 'Non operational' },
+ {
+ intent: Intent.Success,
+ text: 'Operational',
+ classname: 'bg-vega-green-550',
+ },
+ {
+ intent: Intent.Warning,
+ text: '5 Blocks behind',
+ classname: 'bg-warning',
+ },
+ { intent: Intent.Danger, text: 'Non operational', classname: 'bg-danger' },
];
it.each(cases)(
'renders correct text and indicator color for $diff block difference',
(elem) => {
- render();
+ render();
expect(screen.getByTestId('indicator')).toHaveClass(elem.classname);
expect(screen.getByText(elem.text)).toBeInTheDocument();
}
diff --git a/apps/trading/components/footer/footer.tsx b/apps/trading/components/footer/footer.tsx
index 62ab51491..a2b41a67c 100644
--- a/apps/trading/components/footer/footer.tsx
+++ b/apps/trading/components/footer/footer.tsx
@@ -1,60 +1,45 @@
+import { useCallback } from 'react';
import { useEnvironment, useNodeHealth } from '@vegaprotocol/environment';
-import { useNavigatorOnline } from '@vegaprotocol/react-helpers';
import { t } from '@vegaprotocol/i18n';
-import { Indicator, Intent } from '@vegaprotocol/ui-toolkit';
+import type { Intent } from '@vegaprotocol/ui-toolkit';
+import { Indicator } from '@vegaprotocol/ui-toolkit';
import classNames from 'classnames';
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { useGlobalStore } from '../../stores';
export const Footer = () => {
- const { VEGA_URL } = useEnvironment();
- const setNodeSwitcher = useGlobalStore(
- (store) => (open: boolean) => store.update({ nodeSwitcherDialog: open })
- );
- const { blockDiff, datanodeBlockHeight } = useNodeHealth();
-
return (
);
};
-interface NodeHealthProps {
- url: string;
- blockHeight: number | undefined;
- blockDiff: number | null;
- onClick: () => void;
-}
-export const NodeHealth = ({
- url,
- blockHeight,
- blockDiff,
- onClick,
-}: NodeHealthProps) => {
- return (
+export const NodeHealth = () => {
+ const { VEGA_URL } = useEnvironment();
+ const setNodeSwitcher = useGlobalStore(
+ (store) => (open: boolean) => store.update({ nodeSwitcherDialog: open })
+ );
+ const { datanodeBlockHeight, text, intent } = useNodeHealth();
+ const onClick = useCallback(() => {
+ setNodeSwitcher(true);
+ }, [setNodeSwitcher]);
+ return VEGA_URL ? (
-
+
-
+
- {blockHeight}
+ {datanodeBlockHeight}
- );
+ ) : null;
};
interface NodeUrlProps {
@@ -69,31 +54,11 @@ export const NodeUrl = ({ url }: NodeUrlProps) => {
};
interface HealthIndicatorProps {
- blockDiff: number | null;
+ text: string;
+ intent: Intent;
}
-// How many blocks behind the most advanced block that is
-// deemed acceptable for "Good" status
-const BLOCK_THRESHOLD = 3;
-
-export const HealthIndicator = ({ blockDiff }: HealthIndicatorProps) => {
- const online = useNavigatorOnline();
-
- let intent = Intent.Success;
- let text = 'Operational';
-
- if (!online) {
- text = t('Offline');
- intent = Intent.Danger;
- } else if (blockDiff === null) {
- // Block height query failed and null was returned
- text = t('Non operational');
- intent = Intent.Danger;
- } else if (blockDiff >= BLOCK_THRESHOLD) {
- text = t(`${blockDiff} Blocks behind`);
- intent = Intent.Warning;
- }
-
+export const HealthIndicator = ({ text, intent }: HealthIndicatorProps) => {
return (
diff --git a/libs/apollo-client/src/lib/apollo-client.ts b/libs/apollo-client/src/lib/apollo-client.ts
index 94b74aaa8..77d0d023c 100644
--- a/libs/apollo-client/src/lib/apollo-client.ts
+++ b/libs/apollo-client/src/lib/apollo-client.ts
@@ -59,13 +59,19 @@ export function createClient({
const timestamp = r?.headers.get('x-block-timestamp');
if (blockHeight && timestamp) {
const state = useHeaderStore.getState();
- useHeaderStore.setState({
- ...state,
- [r.url]: {
- blockHeight: Number(blockHeight),
- timestamp: new Date(Number(timestamp.slice(0, -6))),
- },
- });
+ const urlState = state[r.url];
+ if (
+ !urlState?.blockHeight ||
+ urlState.blockHeight !== blockHeight
+ ) {
+ useHeaderStore.setState({
+ ...state,
+ [r.url]: {
+ blockHeight: Number(blockHeight),
+ timestamp: new Date(Number(timestamp.slice(0, -6))),
+ },
+ });
+ }
}
return response;
});
diff --git a/libs/environment/src/hooks/use-node-health.spec.tsx b/libs/environment/src/hooks/use-node-health.spec.tsx
index 13e19b304..9a280e1fe 100644
--- a/libs/environment/src/hooks/use-node-health.spec.tsx
+++ b/libs/environment/src/hooks/use-node-health.spec.tsx
@@ -5,6 +5,7 @@ import { MockedProvider } from '@apollo/react-testing';
import type { StatisticsQuery } from '../utils/__generated__/Node';
import { StatisticsDocument } from '../utils/__generated__/Node';
import { useHeaderStore } from '@vegaprotocol/apollo-client';
+import { Intent } from '@vegaprotocol/ui-toolkit';
const vegaUrl = 'https://foo.bar.com';
@@ -55,9 +56,24 @@ function setup(
describe('useNodeHealth', () => {
it.each([
- { core: 1, node: 1, expected: 0 },
- { core: 1, node: 5, expected: -4 },
- { core: 10, node: 5, expected: 5 },
+ {
+ core: 1,
+ node: 1,
+ expectedText: 'Operational',
+ expectedIntent: Intent.Success,
+ },
+ {
+ core: 1,
+ node: 5,
+ expectedText: 'Operational',
+ expectedIntent: Intent.Success,
+ },
+ {
+ core: 10,
+ node: 5,
+ expectedText: '5 Blocks behind',
+ expectedIntent: Intent.Warning,
+ },
])(
'provides difference core block $core and node block $node',
async (cases) => {
@@ -65,12 +81,12 @@ describe('useNodeHealth', () => {
blockHeight: cases.node,
timestamp: new Date(),
});
- expect(result.current.blockDiff).toEqual(null);
- expect(result.current.coreBlockHeight).toEqual(undefined);
+ expect(result.current.text).toEqual('Non operational');
+ expect(result.current.intent).toEqual(Intent.Danger);
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.text).toEqual(cases.expectedText);
+ expect(result.current.intent).toEqual(cases.expectedIntent);
expect(result.current.datanodeBlockHeight).toEqual(cases.node);
});
}
@@ -90,25 +106,64 @@ describe('useNodeHealth', () => {
blockHeight: 1,
timestamp: new Date(),
});
- expect(result.current.blockDiff).toEqual(null);
- expect(result.current.coreBlockHeight).toEqual(undefined);
+ expect(result.current.text).toEqual('Non operational');
+ expect(result.current.intent).toEqual(Intent.Danger);
expect(result.current.datanodeBlockHeight).toEqual(1);
await waitFor(() => {
- expect(result.current.blockDiff).toEqual(null);
- expect(result.current.coreBlockHeight).toEqual(undefined);
+ expect(result.current.text).toEqual('Non operational');
+ expect(result.current.intent).toEqual(Intent.Danger);
expect(result.current.datanodeBlockHeight).toEqual(1);
});
});
it('returns 0 if no headers are found (waits 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.text).toEqual('Non operational');
+ expect(result.current.intent).toEqual(Intent.Danger);
expect(result.current.datanodeBlockHeight).toEqual(undefined);
await waitFor(() => {
- expect(result.current.blockDiff).toEqual(0);
- expect(result.current.coreBlockHeight).toEqual(1);
+ expect(result.current.text).toEqual('Operational');
+ expect(result.current.intent).toEqual(Intent.Success);
expect(result.current.datanodeBlockHeight).toEqual(undefined);
});
});
+
+ it('Warning latency', async () => {
+ const now = 1678800900087;
+ const headerTimestamp = now - 4000;
+ const dateNow = new Date(now);
+ const dateHeaderTimestamp = new Date(headerTimestamp);
+ jest.useFakeTimers().setSystemTime(dateNow);
+
+ const { result } = setup(createStatsMock(2), {
+ blockHeight: 2,
+ timestamp: dateHeaderTimestamp,
+ });
+ await waitFor(() => {
+ expect(result.current.text).toEqual('Warning delay ( >3 sec): 4.05 sec');
+ expect(result.current.intent).toEqual(Intent.Warning);
+ expect(result.current.datanodeBlockHeight).toEqual(2);
+ });
+ });
+
+ it('Erroneous latency', async () => {
+ const now = 1678800900087;
+ const headerTimestamp = now - 11000;
+ const dateNow = new Date(now);
+ const dateHeaderTimestamp = new Date(headerTimestamp);
+ jest.useFakeTimers().setSystemTime(dateNow);
+
+ const { result } = setup(createStatsMock(2), {
+ blockHeight: 2,
+ timestamp: dateHeaderTimestamp,
+ });
+ await waitFor(() => {
+ expect(result.current.text).toEqual(
+ 'Erroneous latency ( >10 sec): 11.05 sec'
+ );
+ expect(result.current.intent).toEqual(Intent.Danger);
+ expect(result.current.datanodeBlockHeight).toEqual(2);
+ });
+ jest.useRealTimers();
+ });
});
diff --git a/libs/environment/src/hooks/use-node-health.ts b/libs/environment/src/hooks/use-node-health.ts
index 299f8faea..1cffd7e6f 100644
--- a/libs/environment/src/hooks/use-node-health.ts
+++ b/libs/environment/src/hooks/use-node-health.ts
@@ -2,30 +2,35 @@ import { useEffect, useMemo } from 'react';
import { useStatisticsQuery } from '../utils/__generated__/Node';
import { useHeaderStore } from '@vegaprotocol/apollo-client';
import { useEnvironment } from './use-environment';
-import { fromNanoSeconds } from '@vegaprotocol/utils';
+import { useNavigatorOnline } from '@vegaprotocol/react-helpers';
+import { Intent } from '@vegaprotocol/ui-toolkit';
+import { t } from '@vegaprotocol/i18n';
const POLL_INTERVAL = 1000;
+const BLOCK_THRESHOLD = 3;
+const ERROR_LATENCY = 10000;
+const WARNING_LATENCY = 3000;
export const useNodeHealth = () => {
+ const online = useNavigatorOnline();
const url = useEnvironment((store) => store.VEGA_URL);
const headerStore = useHeaderStore();
const headers = url ? headerStore[url] : undefined;
- const { data, error, loading, startPolling, stopPolling } =
- useStatisticsQuery({
- fetchPolicy: 'no-cache',
- });
+ const { data, error, startPolling, stopPolling } = useStatisticsQuery({
+ fetchPolicy: 'no-cache',
+ });
const blockDiff = useMemo(() => {
if (!data?.statistics.blockHeight) {
return null;
}
- if (!headers) {
+ if (!headers?.blockHeight) {
return 0;
}
return Number(data.statistics.blockHeight) - headers.blockHeight;
- }, [data, headers]);
+ }, [data?.statistics.blockHeight, headers?.blockHeight]);
useEffect(() => {
if (error) {
@@ -38,17 +43,43 @@ export const useNodeHealth = () => {
}
}, [error, startPolling, stopPolling]);
+ const blockUpdateMsLatency = headers?.timestamp
+ ? Date.now() - headers.timestamp.getTime()
+ : 0;
+
+ const [text, intent] = useMemo(() => {
+ let intent = Intent.Success;
+ let text = 'Operational';
+
+ if (!online) {
+ text = t('Offline');
+ intent = Intent.Danger;
+ } else if (blockDiff === null) {
+ // Block height query failed and null was returned
+ text = t('Non operational');
+ intent = Intent.Danger;
+ } else if (blockUpdateMsLatency > ERROR_LATENCY) {
+ text = t('Erroneous latency ( >%s sec): %s sec', [
+ (ERROR_LATENCY / 1000).toString(),
+ (blockUpdateMsLatency / 1000).toFixed(2),
+ ]);
+ intent = Intent.Danger;
+ } else if (blockDiff >= BLOCK_THRESHOLD) {
+ text = t(`%s Blocks behind`, String(blockDiff));
+ intent = Intent.Warning;
+ } else if (blockUpdateMsLatency > WARNING_LATENCY) {
+ text = t('Warning delay ( >%s sec): %s sec', [
+ (WARNING_LATENCY / 1000).toString(),
+ (blockUpdateMsLatency / 1000).toFixed(2),
+ ]);
+ intent = Intent.Warning;
+ }
+ return [text, intent];
+ }, [online, blockDiff, blockUpdateMsLatency]);
+
return {
- error,
- loading,
- coreBlockHeight: data?.statistics
- ? Number(data.statistics.blockHeight)
- : undefined,
- coreVegaTime: data?.statistics
- ? fromNanoSeconds(data?.statistics.vegaTime)
- : undefined,
datanodeBlockHeight: headers?.blockHeight,
- datanodeVegaTime: headers?.timestamp,
- blockDiff,
+ text,
+ intent,
};
};