feat(explorer,governance): always allow selecting a node, add node guard (#3678)

This commit is contained in:
Matthew Russell 2023-05-09 12:58:09 -07:00 committed by GitHub
parent 76ddf45f4c
commit bded1d32ba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 318 additions and 168 deletions

View File

@ -1,9 +1,18 @@
import { NetworkLoader, useInitializeEnv } from '@vegaprotocol/environment';
import {
AppFailure,
NetworkLoader,
NodeGuard,
NodeSwitcherDialog,
useEnvironment,
useInitializeEnv,
useNodeSwitcherStore,
} from '@vegaprotocol/environment';
import { TendermintWebsocketProvider } from './contexts/websocket/tendermint-websocket-provider';
import { Loader, Splash } from '@vegaprotocol/ui-toolkit';
import { DEFAULT_CACHE_CONFIG } from '@vegaprotocol/apollo-client';
import { RouterProvider } from 'react-router-dom';
import { router } from './routes/router-config';
import { t } from '@vegaprotocol/i18n';
const splashLoading = (
<Splash>
@ -12,10 +21,23 @@ const splashLoading = (
);
function App() {
const { VEGA_URL } = useEnvironment();
const [nodeSwitcherOpen, setNodeSwitcherOpen] = useNodeSwitcherStore(
(store) => [store.dialogOpen, store.setDialogOpen]
);
return (
<TendermintWebsocketProvider>
<NetworkLoader cache={DEFAULT_CACHE_CONFIG}>
<RouterProvider router={router} fallbackElement={splashLoading} />
<NodeGuard
skeleton={<div>{t('Loading')}</div>}
failure={<AppFailure title={t(`Node: ${VEGA_URL} is unsuitable`)} />}
>
<RouterProvider router={router} fallbackElement={splashLoading} />
</NodeGuard>
<NodeSwitcherDialog
open={nodeSwitcherOpen}
setOpen={setNodeSwitcherOpen}
/>
</NetworkLoader>
</TendermintWebsocketProvider>
);

View File

@ -1,13 +1,19 @@
import { NodeSwitcherDialog, useEnvironment } from '@vegaprotocol/environment';
import {
useEnvironment,
useNodeSwitcherStore,
} from '@vegaprotocol/environment';
import { t } from '@vegaprotocol/i18n';
import { useScreenDimensions } from '@vegaprotocol/react-helpers';
import { ExternalLink, Link } from '@vegaprotocol/ui-toolkit';
import { useMemo, useState } from 'react';
import { useMemo } from 'react';
import { ENV } from '../../config/env';
export const Footer = () => {
const { VEGA_URL, GIT_COMMIT_HASH, GIT_ORIGIN_URL } = useEnvironment();
const [nodeSwitcherOpen, setNodeSwitcherOpen] = useState(false);
const setNodeSwitcherOpen = useNodeSwitcherStore(
(store) => store.setDialogOpen
);
const { screenSize } = useScreenDimensions();
const showFullFeedbackLabel = useMemo(
() => ['md', 'lg', 'xl', 'xxl', 'xxxl'].includes(screenSize),
@ -15,46 +21,40 @@ export const Footer = () => {
);
return (
<>
<footer className="grid grid-rows-2 grid-cols-[1fr_auto] text-xs md:text-md md:flex md:col-span-2 px-4 py-2 gap-4 border-t border-vega-light-200 dark:border-vega-dark-200">
<div className="flex justify-between gap-2 align-middle">
{GIT_COMMIT_HASH && (
<div className="content-center flex border-r border-neutral-700 dark:border-neutral-300 pr-4">
<p data-testid="git-commit-hash">
{t('Version')}:{' '}
<Link
href={
GIT_ORIGIN_URL
? `${GIT_ORIGIN_URL}/commit/${GIT_COMMIT_HASH}`
: undefined
}
target={GIT_ORIGIN_URL ? '_blank' : undefined}
>
{GIT_COMMIT_HASH}
</Link>
</p>
</div>
)}
<div className="content-center flex pl-2 md:border-r border-neutral-700 dark:border-neutral-300 pr-4">
{VEGA_URL && <NodeUrl url={VEGA_URL} />}
<Link className="ml-2" onClick={() => setNodeSwitcherOpen(true)}>
{t('Change')}
</Link>
<footer className="grid grid-rows-2 grid-cols-[1fr_auto] text-xs md:text-md md:flex md:col-span-2 px-4 py-2 gap-4 border-t border-vega-light-200 dark:border-vega-dark-200">
<div className="flex justify-between gap-2 align-middle">
{GIT_COMMIT_HASH && (
<div className="content-center flex border-r border-neutral-700 dark:border-neutral-300 pr-4">
<p data-testid="git-commit-hash">
{t('Version')}:{' '}
<Link
href={
GIT_ORIGIN_URL
? `${GIT_ORIGIN_URL}/commit/${GIT_COMMIT_HASH}`
: undefined
}
target={GIT_ORIGIN_URL ? '_blank' : undefined}
>
{GIT_COMMIT_HASH}
</Link>
</p>
</div>
)}
<div className="flex pl-2 content-center">
<ExternalLink href={ENV.addresses.feedback}>
{showFullFeedbackLabel ? t('Share your feedback') : t('Feedback')}
</ExternalLink>
</div>
<div className="content-center flex pl-2 md:border-r border-neutral-700 dark:border-neutral-300 pr-4">
{VEGA_URL && <NodeUrl url={VEGA_URL} />}
<Link className="ml-2" onClick={() => setNodeSwitcherOpen(true)}>
{t('Change')}
</Link>
</div>
</footer>
<NodeSwitcherDialog
open={nodeSwitcherOpen}
setOpen={setNodeSwitcherOpen}
/>
</>
<div className="flex pl-2 content-center">
<ExternalLink href={ENV.addresses.feedback}>
{showFullFeedbackLabel ? t('Share your feedback') : t('Feedback')}
</ExternalLink>
</div>
</div>
</footer>
);
};

View File

@ -37,6 +37,10 @@ import {
useEnvironment,
NetworkLoader,
useInitializeEnv,
NodeGuard,
AppFailure,
NodeSwitcherDialog,
useNodeSwitcherStore,
} from '@vegaprotocol/environment';
import { ENV } from './config';
import type { InMemoryCacheConfig } from '@apollo/client';
@ -48,6 +52,7 @@ import {
TELEMETRY_ON,
} from './components/telemetry-dialog/telemetry-dialog';
import { useLocalStorage } from '@vegaprotocol/react-helpers';
import { useTranslation } from 'react-i18next';
const cache: InMemoryCacheConfig = {
typePolicies: {
@ -181,9 +186,19 @@ const ScrollToTop = () => {
const AppContainer = () => {
const { config, loading, error } = useEthereumConfig();
const { VEGA_ENV, GIT_COMMIT_HASH, GIT_BRANCH, ETHEREUM_PROVIDER_URL } =
useEnvironment();
const {
VEGA_ENV,
VEGA_URL,
GIT_COMMIT_HASH,
GIT_BRANCH,
ETHEREUM_PROVIDER_URL,
} = useEnvironment();
const [telemetryOn] = useLocalStorage(TELEMETRY_ON);
const { t } = useTranslation();
const [nodeSwitcherOpen, setNodeSwitcher] = useNodeSwitcherStore((store) => [
store.dialogOpen,
store.setDialogOpen,
]);
useEffect(() => {
if (ENV.dsn && telemetryOn) {
@ -219,21 +234,29 @@ const AppContainer = () => {
<ScrollToTop />
<AppStateProvider>
<div className="grid min-h-full text-white">
<AsyncRenderer<EthereumConfig | null>
loading={loading}
data={config}
error={error}
render={(cnf) =>
cnf && (
<Web3Container
chainId={Number(cnf.chain_id)}
providerUrl={ETHEREUM_PROVIDER_URL}
/>
)
<NodeGuard
skeleton={<div>{t('Loading')}</div>}
failure={
<AppFailure title={t('NodeUnsuitable', { url: VEGA_URL })} />
}
/>
>
<AsyncRenderer<EthereumConfig | null>
loading={loading}
data={config}
error={error}
render={(cnf) =>
cnf && (
<Web3Container
chainId={Number(cnf.chain_id)}
providerUrl={ETHEREUM_PROVIDER_URL}
/>
)
}
/>
</NodeGuard>
</div>
</AppStateProvider>
<NodeSwitcherDialog open={nodeSwitcherOpen} setOpen={setNodeSwitcher} />
</Router>
);
};

View File

@ -811,5 +811,6 @@
"OptOutOfTelemetry": "You can opt out any time via settings",
"NoThanks": "No thanks",
"ShareData": "Share data",
"ContinueSharingData": "Continue sharing data"
"ContinueSharingData": "Continue sharing data",
"NodeUnsuitable": "Node: {{url}} is unsuitable"
}

View File

@ -12,6 +12,7 @@ import {
createMarketsDataFragment,
assetQuery,
networkParamsQuery,
nodeGuardQuery,
} from '@vegaprotocol/mock';
import {
addDecimalsFormatNumber,
@ -158,6 +159,7 @@ describe('Closed markets', { tags: '@smoke' }, () => {
cy.mockGQL((req) => {
aliasGQLQuery(req, 'ChainId', chainIdQuery());
aliasGQLQuery(req, 'Statistics', statisticsQuery());
aliasGQLQuery(req, 'NodeGuard', nodeGuardQuery());
aliasGQLQuery(req, 'NetworkParams', networkParamsQuery());
aliasGQLQuery(
req,

View File

@ -20,6 +20,7 @@ import {
marketsDataQuery,
marketsQuery,
networkParamsQuery,
nodeGuardQuery,
ordersQuery,
positionsQuery,
proposalListQuery,
@ -82,6 +83,7 @@ const mockTradingPage = (
) => {
aliasGQLQuery(req, 'ChainId', chainIdQuery());
aliasGQLQuery(req, 'Statistics', statisticsQuery());
aliasGQLQuery(req, 'NodeGuard', nodeGuardQuery());
aliasGQLQuery(
req,
'Markets',

View File

@ -1,5 +1,6 @@
import type { InMemoryCacheConfig } from '@apollo/client';
import {
AppFailure,
NetworkLoader,
NodeGuard,
useEnvironment,
@ -9,7 +10,6 @@ import { MaintenancePage } from '@vegaprotocol/ui-toolkit';
import { VegaWalletProvider } from '@vegaprotocol/wallet';
import dynamic from 'next/dynamic';
import type { ReactNode } from 'react';
import { AppFailure } from './app-failure';
import { Web3Provider } from './web3-provider';
export const DynamicLoader = dynamic(() => import('../preloader/preloader'), {

View File

@ -1,3 +1,2 @@
export * from './app-failure';
export * from './app-loader';
export * from './web3-provider';

View File

@ -1,11 +1,14 @@
import { useCallback } from 'react';
import { useEnvironment, useNodeHealth } from '@vegaprotocol/environment';
import {
useEnvironment,
useNodeHealth,
useNodeSwitcherStore,
} from '@vegaprotocol/environment';
import { t } from '@vegaprotocol/i18n';
import type { Intent } from '@vegaprotocol/ui-toolkit';
import { Indicator, ExternalLink } from '@vegaprotocol/ui-toolkit';
import classNames from 'classnames';
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { useGlobalStore } from '../../stores';
export const Footer = () => {
return (
@ -20,9 +23,7 @@ export const Footer = () => {
export const NodeHealth = () => {
const { VEGA_URL, VEGA_INCIDENT_URL } = useEnvironment();
const setNodeSwitcher = useGlobalStore(
(store) => (open: boolean) => store.update({ nodeSwitcherDialog: open })
);
const setNodeSwitcher = useNodeSwitcherStore((store) => store.setDialogOpen);
const { datanodeBlockHeight, text, intent } = useNodeHealth();
const onClick = useCallback(() => {
setNodeSwitcher(true);

View File

@ -21,9 +21,10 @@ import {
NodeSwitcherDialog,
useEnvironment,
useInitializeEnv,
useNodeSwitcherStore,
} from '@vegaprotocol/environment';
import './styles.css';
import { useGlobalStore, usePageTitleStore } from '../stores';
import { usePageTitleStore } from '../stores';
import { Footer } from '../components/footer';
import DialogsContainer from './dialogs-container';
import ToastsManager from './toasts-manager';
@ -115,11 +116,10 @@ function AppBody({ Component }: AppProps) {
function VegaTradingApp(props: AppProps) {
const status = useEnvironment((store) => store.status);
const { nodeSwitcherOpen, setNodeSwitcher } = useGlobalStore((store) => ({
nodeSwitcherOpen: store.nodeSwitcherDialog,
setNodeSwitcher: (open: boolean) =>
store.update({ nodeSwitcherDialog: open }),
}));
const [nodeSwitcherOpen, setNodeSwitcher] = useNodeSwitcherStore((store) => [
store.dialogOpen,
store.setDialogOpen,
]);
useInitializeEnv();

View File

@ -3,7 +3,6 @@ import { create } from 'zustand';
import produce from 'immer';
interface GlobalStore {
nodeSwitcherDialog: boolean;
marketId: string | null;
update: (store: Partial<Omit<GlobalStore, 'update'>>) => void;
shouldDisplayWelcomeDialog: boolean;
@ -15,7 +14,6 @@ interface PageTitleStore {
}
export const useGlobalStore = create<GlobalStore>()((set) => ({
nodeSwitcherDialog: false,
marketId: LocalStorage.getItem('marketId') || null,
shouldDisplayWelcomeDialog: false,
update: (newState) => {

View File

@ -7,6 +7,7 @@ export * from '../candles-chart/src/lib/chart.mock';
export * from '../deal-ticket/src/hooks/estimate-order.mock';
export * from '../deposits/src/lib/deposit.mock';
export * from '../environment/src/utils/node.mock';
export * from '../environment/src/components/node-guard/node-guard.mock';
export * from '../fills/src/lib/fills.mock';
export * from '../proposals/src/lib/proposals-data-provider/proposals.mock';
export * from '../ledger/src/lib/ledger-entries.mock';

View File

@ -1,6 +1,6 @@
import { t } from '@vegaprotocol/i18n';
import { Button } from '@vegaprotocol/ui-toolkit';
import { useGlobalStore } from '../../stores';
import { useNodeSwitcherStore } from '../../hooks/use-node-switcher-store';
export const AppFailure = ({
title,
@ -9,17 +9,13 @@ export const AppFailure = ({
title: string;
error?: string | null;
}) => {
const { setNodeSwitcher } = useGlobalStore((store) => ({
nodeSwitcherOpen: store.nodeSwitcherDialog,
setNodeSwitcher: (open: boolean) =>
store.update({ nodeSwitcherDialog: open }),
}));
const setNodeSwitcher = useNodeSwitcherStore((store) => store.setDialogOpen);
const nonIdealWrapperClasses =
'h-full min-h-screen flex items-center justify-center';
return (
<div className={nonIdealWrapperClasses}>
<div className="text-center">
<h1 className="text-xl mb-4">{title}</h1>
<p className="text-xl mb-4">{title}</p>
{error && <p className="text-sm mb-8">{error}</p>}
<Button onClick={() => setNodeSwitcher(true)}>
{t('Change node')}

View File

@ -0,0 +1 @@
export * from './app-failure';

View File

@ -1,3 +1,4 @@
export * from './app-failure';
export * from './network-loader';
export * from './network-switcher';
export * from './node-guard';

View File

@ -0,0 +1,11 @@
query NodeGuard {
lastBlockHeight
networkParametersConnection {
edges {
node {
key
value
}
}
}
}

View File

@ -0,0 +1,51 @@
import * as Types from '@vegaprotocol/types';
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
const defaultOptions = {} as const;
export type NodeGuardQueryVariables = Types.Exact<{ [key: string]: never; }>;
export type NodeGuardQuery = { __typename?: 'Query', lastBlockHeight: string, networkParametersConnection: { __typename?: 'NetworkParametersConnection', edges?: Array<{ __typename?: 'NetworkParameterEdge', node: { __typename?: 'NetworkParameter', key: string, value: string } } | null> | null } };
export const NodeGuardDocument = gql`
query NodeGuard {
lastBlockHeight
networkParametersConnection {
edges {
node {
key
value
}
}
}
}
`;
/**
* __useNodeGuardQuery__
*
* To run a query within a React component, call `useNodeGuardQuery` and pass it any options that fit your needs.
* When your component renders, `useNodeGuardQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useNodeGuardQuery({
* variables: {
* },
* });
*/
export function useNodeGuardQuery(baseOptions?: Apollo.QueryHookOptions<NodeGuardQuery, NodeGuardQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<NodeGuardQuery, NodeGuardQueryVariables>(NodeGuardDocument, options);
}
export function useNodeGuardLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<NodeGuardQuery, NodeGuardQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<NodeGuardQuery, NodeGuardQueryVariables>(NodeGuardDocument, options);
}
export type NodeGuardQueryHookResult = ReturnType<typeof useNodeGuardQuery>;
export type NodeGuardLazyQueryHookResult = ReturnType<typeof useNodeGuardLazyQuery>;
export type NodeGuardQueryResult = Apollo.QueryResult<NodeGuardQuery, NodeGuardQueryVariables>;

View File

@ -0,0 +1,53 @@
import type { NodeGuardQuery } from './__generated__/NodeGuard';
import merge from 'lodash/merge';
import type { PartialDeep } from 'type-fest';
export const nodeGuardQuery = (
override?: PartialDeep<NodeGuardQuery>
): NodeGuardQuery => {
const defaultResult: NodeGuardQuery = {
lastBlockHeight: '11',
networkParametersConnection: {
__typename: 'NetworkParametersConnection',
edges: [
{
__typename: 'NetworkParameterEdge',
node: {
__typename: 'NetworkParameter' as const,
key: 'governance.proposal.market.requiredMajority',
value: '0.66',
},
},
{
__typename: 'NetworkParameterEdge',
node: {
__typename: 'NetworkParameter' as const,
key: 'blockchains.ethereumConfig',
value: JSON.stringify({
network_id: '3',
chain_id: '3',
collateral_bridge_contract: {
address: '0x7fe27d970bc8Afc3B11Cc8d9737bfB66B1efd799',
},
multisig_control_contract: {
address: '0x6eBc32d66277D94DB8FF2ccF86E36f37F29a52D3',
deployment_block_height: 12341882,
},
staking_bridge_contract: {
address: '0xFFb0A0d4806502ceF491aF1141f66669A1Bd0D03',
deployment_block_height: 11177313,
},
token_vesting_contract: {
address: '0x680fF88252FA7071CAce7398e77872d54D781d0B',
deployment_block_height: 11177353,
},
confirmations: 3,
}),
},
},
],
},
};
return merge(defaultResult, override);
};

View File

@ -1,5 +1,5 @@
import type { ReactNode } from 'react';
import { useStatisticsQuery } from '../../utils/__generated__/Node';
import { useNodeGuardQuery } from './__generated__/NodeGuard';
export const NodeGuard = ({
children,
@ -10,14 +10,19 @@ export const NodeGuard = ({
failure: ReactNode;
skeleton: ReactNode;
}) => {
const { error, loading } = useStatisticsQuery();
const wrapperClasses = 'h-full min-h-screen flex items-center justify-center';
const { data, error, loading } = useNodeGuardQuery();
const wrapperClasses =
'h-full min-h-screen flex items-center justify-center text-black dark:text-white';
if (loading) {
return <div className={wrapperClasses}>{skeleton}</div>;
}
if (error) {
// It is possible for nodes to have a functioning datanode, but not return
// any net params. The app cannot safely function without net params
const netParamEdges = data?.networkParametersConnection.edges;
if (error || !netParamEdges || !netParamEdges.length) {
return <div className={wrapperClasses}>{failure}</div>;
}

View File

@ -124,29 +124,6 @@ export const RowData = ({
return false;
};
const getIsNodeDisabled = () => {
if (!isValidUrl(url)) {
return true;
}
// if still waiting or query errored disable node
if (loading || error) {
return true;
}
if (subLoading || subError) {
return true;
}
// if we are still waiting for a header entry for this
// url disable the node
if (!headers) {
return true;
}
return false;
};
const getSubFailed = (
subError: ApolloError | undefined,
subFailed: boolean
@ -160,12 +137,7 @@ export const RowData = ({
<>
{id !== CUSTOM_NODE_KEY && (
<div className="break-all" data-testid="node">
<Radio
id={`node-url-${id}`}
value={url}
label={url}
disabled={getIsNodeDisabled()}
/>
<Radio id={`node-url-${id}`} value={url} label={url} />
</div>
)}
<LayoutCell

View File

@ -1,5 +1,6 @@
export * from './use-environment';
export * from './use-links';
export * from './use-node-health';
export * from './use-node-switcher-store';
export * from './use-vega-releases';
export * from './use-vega-release';

View File

@ -0,0 +1,11 @@
import { create } from 'zustand';
export const useNodeSwitcherStore = create<{
dialogOpen: boolean;
setDialogOpen: (isOpen: boolean) => void;
}>()((set) => ({
dialogOpen: false,
setDialogOpen: (isOpen) => {
set({ dialogOpen: isOpen });
},
}));

View File

@ -1,7 +1,11 @@
import { Fragment, useState } from 'react';
import { Fragment } from 'react';
import { t } from '@vegaprotocol/i18n';
import { Link, Lozenge } from '@vegaprotocol/ui-toolkit';
import { NodeSwitcherDialog, useEnvironment } from '@vegaprotocol/environment';
import {
NodeSwitcherDialog,
useEnvironment,
useNodeSwitcherStore,
} from '@vegaprotocol/environment';
const getFeedbackLinks = (gitOriginUrl?: string) =>
[
@ -19,64 +23,59 @@ export const NetworkInfo = () => {
GITHUB_FEEDBACK_URL,
ETHEREUM_PROVIDER_URL,
} = useEnvironment();
const [nodeSwitcherOpen, setNodeSwitcherOpen] = useState(false);
const setNodeSwitcher = useNodeSwitcherStore((store) => store.setDialogOpen);
const feedbackLinks = getFeedbackLinks(GITHUB_FEEDBACK_URL);
return (
<>
<div data-testid="git-info">
<p data-testid="git-network-data" className="mb-2">
{t('Reading network data from')}{' '}
<Lozenge className="bg-neutral-300 dark:bg-neutral-700">
{VEGA_URL}
</Lozenge>
. <Link onClick={() => setNodeSwitcherOpen(true)}>{t('Edit')}</Link>
<div data-testid="git-info">
<p data-testid="git-network-data" className="mb-2">
{t('Reading network data from')}{' '}
<Lozenge className="bg-neutral-300 dark:bg-neutral-700">
{VEGA_URL}
</Lozenge>
. <Link onClick={() => setNodeSwitcher(true)}>{t('Edit')}</Link>
</p>
<p data-testid="git-eth-data" className="mb-2 break-all">
{t('Reading Ethereum data from')}{' '}
<Lozenge className="bg-neutral-300 dark:bg-neutral-700">
{ETHEREUM_PROVIDER_URL}
</Lozenge>
.{' '}
</p>
{GIT_COMMIT_HASH && (
<p data-testid="git-commit-hash" className="mb-2">
{t('Version/commit hash')}:{' '}
<Link
href={
GIT_ORIGIN_URL
? `${GIT_ORIGIN_URL}/commit/${GIT_COMMIT_HASH}`
: undefined
}
target={GIT_ORIGIN_URL ? '_blank' : undefined}
>
{GIT_COMMIT_HASH}
</Link>
</p>
<p data-testid="git-eth-data" className="mb-2 break-all">
{t('Reading Ethereum data from')}{' '}
<Lozenge className="bg-neutral-300 dark:bg-neutral-700">
{ETHEREUM_PROVIDER_URL}
</Lozenge>
.{' '}
)}
{feedbackLinks.length > 0 && (
<p>
{t('Known issues and feedback on')}{' '}
{feedbackLinks.map(({ name, url }, index) => (
<Fragment key={index}>
<Link key={index} href={url}>
{name}
</Link>
{feedbackLinks.length > 1 &&
index < feedbackLinks.length - 2 &&
','}
{feedbackLinks.length > 1 &&
index === feedbackLinks.length - 1 &&
`, ${t('and')} `}
</Fragment>
))}
</p>
{GIT_COMMIT_HASH && (
<p data-testid="git-commit-hash" className="mb-2">
{t('Version/commit hash')}:{' '}
<Link
href={
GIT_ORIGIN_URL
? `${GIT_ORIGIN_URL}/commit/${GIT_COMMIT_HASH}`
: undefined
}
target={GIT_ORIGIN_URL ? '_blank' : undefined}
>
{GIT_COMMIT_HASH}
</Link>
</p>
)}
{feedbackLinks.length > 0 && (
<p>
{t('Known issues and feedback on')}{' '}
{feedbackLinks.map(({ name, url }, index) => (
<Fragment key={index}>
<Link key={index} href={url}>
{name}
</Link>
{feedbackLinks.length > 1 &&
index < feedbackLinks.length - 2 &&
','}
{feedbackLinks.length > 1 &&
index === feedbackLinks.length - 1 &&
`, ${t('and')} `}
</Fragment>
))}
</p>
)}
</div>
<NodeSwitcherDialog
open={nodeSwitcherOpen}
setOpen={setNodeSwitcherOpen}
/>
</>
)}
</div>
);
};