feat(explorer): breadcrumbs (#3254)

This commit is contained in:
Art 2023-03-23 19:11:34 +00:00 committed by GitHub
parent a4f512f946
commit 8a0c15ac11
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 1174 additions and 1300 deletions

View File

@ -6,18 +6,10 @@ context('Home Page', function () {
describe('Stats page', { tags: '@smoke' }, function () { describe('Stats page', { tags: '@smoke' }, function () {
const statsValue = '[data-testid="stats-value"]'; const statsValue = '[data-testid="stats-value"]';
it('Should show connected environment', function () {
const deployedEnv = Cypress.env('environment').toUpperCase();
cy.get('[data-testid="stats-environment"]').should(
'have.text',
`/ ${deployedEnv}`
);
});
it('should show connected environment stats', function () { it('should show connected environment stats', function () {
const statTitles = { const statTitles = {
0: 'Status', 0: 'Status',
1: 'Height', 1: 'Block height',
2: 'Uptime', 2: 'Uptime',
3: 'Total nodes', 3: 'Total nodes',
4: 'Total staked', 4: 'Total staked',
@ -36,27 +28,27 @@ context('Home Page', function () {
cy.get('[data-testid="stats-title"]') cy.get('[data-testid="stats-title"]')
.each(($list, index) => { .each(($list, index) => {
cy.wrap($list).should('have.text', statTitles[index]); cy.wrap($list).should('contain.text', statTitles[index]);
}) })
.then(($list) => { .then(($list) => {
cy.wrap($list).should('have.length', 16); cy.wrap($list).should('have.length', 16);
}); });
cy.get(statsValue).eq(0).should('have.text', 'CONNECTED'); cy.get(statsValue).eq(0).should('contain.text', 'CONNECTED');
cy.get(statsValue).eq(1).should('not.be.empty'); cy.get(statsValue).eq(1).should('not.be.empty');
cy.get(statsValue) cy.get(statsValue)
.eq(2) .eq(2)
.invoke('text') .invoke('text')
.should('match', /\d+d \d+h \d+m \d+s/i); .should('match', /\d+d \d+h \d+m \d+s/i);
cy.get(statsValue).eq(3).should('have.text', '2'); cy.get(statsValue).eq(3).should('contain.text', '2');
cy.get(statsValue) cy.get(statsValue)
.eq(4) .eq(4)
.invoke('text') .invoke('text')
.should('match', /\d+\.\d\d(?!\d)/i); .should('match', /\d+\.\d\d(?!\d)/i);
cy.get(statsValue).eq(5).should('have.text', '0'); cy.get(statsValue).eq(5).should('contain.text', '0');
cy.get(statsValue).eq(6).should('have.text', '0'); cy.get(statsValue).eq(6).should('contain.text', '0');
cy.get(statsValue).eq(7).should('have.text', '0'); cy.get(statsValue).eq(7).should('contain.text', '0');
cy.get(statsValue).eq(8).should('have.text', '0'); cy.get(statsValue).eq(8).should('contain.text', '0');
cy.get(statsValue).eq(9).should('not.be.empty'); cy.get(statsValue).eq(9).should('not.be.empty');
cy.get(statsValue).eq(10).should('not.be.empty'); cy.get(statsValue).eq(10).should('not.be.empty');
cy.get(statsValue).eq(11).should('not.be.empty'); cy.get(statsValue).eq(11).should('not.be.empty');
@ -86,75 +78,4 @@ context('Home Page', function () {
}); });
}); });
}); });
describe('Git info', function () {
it('git info is rendered on the footer of the page', function () {
cy.getByTestId('git-info').within(() => {
cy.getByTestId('git-network-data').within(() => {
cy.contains('Reading network data from').should('be.visible');
cy.get('span').should('have.text', Cypress.env('networkQueryUrl'));
cy.getByTestId('link').should('be.visible');
});
cy.getByTestId('git-eth-data').within(() => {
cy.contains('Reading Ethereum data from').should('be.visible');
cy.get('span').should('have.text', Cypress.env('ethUrl'));
});
cy.getByTestId('git-commit-hash').within(() => {
cy.contains('Version/commit hash:').should('be.visible');
cy.getByTestId('link').should('have.text', Cypress.env('commitHash'));
});
});
});
});
describe('Search bar', function () {
it('Successful search for specific id by block id', function () {
const blockId = '973624';
search(blockId);
cy.url().should('include', blockId);
});
it('Successful search for specific id by tx hash', function () {
const txHash =
'9ED3718AA8308E7E08EC588EE7AADAF49711D2138860D8914B4D81A2054D9FB8';
search(txHash);
cy.url().should('include', txHash);
});
it('Successful search for specific id by tx id', function () {
const txId =
'0x61DCCEBB955087F50D0B85382DAE138EDA9631BF1A4F92E563D528904AA38898';
search(txId);
cy.url().should('include', txId);
});
it('Error message displayed when invalid search by wrong string length', function () {
search('9ED3718AA8308E7E08EC588EE7AADAF497D2138860D8914B4D81A2054D9FB8');
validateSearchError("Something doesn't look right");
});
it('Error message displayed when invalid search by invalid hash', function () {
search(
'9ED3718AA8308E7E08ECht8EE753DAF49711D2138860D8914B4D81A2054D9FB8'
);
validateSearchError('Transaction is not hexadecimal');
});
it('Error message displayed when searching empty field', function () {
cy.get('[data-testid="search"]').clear();
cy.get('[data-testid="search-button"]').click();
validateSearchError('Search required');
});
function search(searchTxt) {
cy.get('[data-testid="search"]').clear().type(searchTxt);
cy.get('[data-testid="search-button"]').click();
}
function validateSearchError(expectedError) {
cy.get('[data-testid="search-error"]').should('have.text', expectedError);
}
});
}); });

View File

@ -1,87 +1,21 @@
import { NetworkLoader, useInitializeEnv } from '@vegaprotocol/environment'; import { NetworkLoader, useInitializeEnv } from '@vegaprotocol/environment';
import { Header } from './components/header';
import { Main } from './components/main';
import { TendermintWebsocketProvider } from './contexts/websocket/tendermint-websocket-provider'; import { TendermintWebsocketProvider } from './contexts/websocket/tendermint-websocket-provider';
import { Footer } from './components/footer/footer'; import { Loader, Splash } from '@vegaprotocol/ui-toolkit';
import {
AnnouncementBanner,
ExternalLink,
Icon,
} from '@vegaprotocol/ui-toolkit';
import {
AssetDetailsDialog,
useAssetDetailsDialogStore,
} from '@vegaprotocol/assets';
import { DEFAULT_CACHE_CONFIG } from '@vegaprotocol/apollo-client'; import { DEFAULT_CACHE_CONFIG } from '@vegaprotocol/apollo-client';
import classNames from 'classnames'; import { RouterProvider } from 'react-router-dom';
import { useState } from 'react'; import { router } from './routes/router-config';
const DialogsContainer = () => { const splashLoading = (
const { isOpen, id, trigger, asJson, setOpen } = useAssetDetailsDialogStore(); <Splash>
return ( <Loader />
<AssetDetailsDialog </Splash>
assetId={id} );
trigger={trigger || null}
asJson={asJson}
open={isOpen}
onChange={setOpen}
/>
);
};
const MainnetSimAd = () => {
const [shouldDisplayBanner, setShouldDisplayBanner] = useState<boolean>(true);
// Return an empty div so that the grid layout in _app.page.ts
// renders correctly
if (!shouldDisplayBanner) {
return <div />;
}
return (
<AnnouncementBanner>
<div className="grid grid-cols-[auto_1fr] gap-4 font-alpha calt uppercase text-center text-lg text-white">
<button
className="flex items-center"
onClick={() => setShouldDisplayBanner(false)}
>
<Icon name="cross" className="w-6 h-6" ariaLabel="dismiss" />
</button>
<div>
<span className="pr-4">Mainnet sim 3 is live!</span>
<ExternalLink href="https://fairground.wtf/">Learn more</ExternalLink>
</div>
</div>
</AnnouncementBanner>
);
};
function App() { function App() {
return ( return (
<TendermintWebsocketProvider> <TendermintWebsocketProvider>
<NetworkLoader cache={DEFAULT_CACHE_CONFIG}> <NetworkLoader cache={DEFAULT_CACHE_CONFIG}>
<div <RouterProvider router={router} fallbackElement={splashLoading} />
className={classNames(
'max-w-[1500px] min-h-[100vh]',
'mx-auto my-0',
'grid grid-rows-[auto_1fr_auto] grid-cols-1',
'border-vega-light-200 dark:border-vega-dark-200 lg:border-l lg:border-r',
'antialiased text-black dark:text-white',
'overflow-hidden relative'
)}
>
<div>
<MainnetSimAd />
<Header />
</div>
<div>
<Main />
</div>
<div>
<Footer />
</div>
</div>
<DialogsContainer />
</NetworkLoader> </NetworkLoader>
</TendermintWebsocketProvider> </TendermintWebsocketProvider>
); );

View File

@ -13,7 +13,7 @@ jest.mock('../search', () => ({
})); }));
const renderComponent = () => ( const renderComponent = () => (
<MemoryRouter> <MemoryRouter initialEntries={['/txs']}>
<Header /> <Header />
</MemoryRouter> </MemoryRouter>
); );
@ -24,6 +24,7 @@ describe('Header', () => {
expect(screen.getByTestId('navigation')).toHaveTextContent('Explorer'); expect(screen.getByTestId('navigation')).toHaveTextContent('Explorer');
}); });
it('should render search', () => { it('should render search', () => {
render(renderComponent()); render(renderComponent());

View File

@ -1,4 +1,4 @@
import { matchPath, useLocation } from 'react-router-dom'; import { matchPath, useLocation, useMatch } from 'react-router-dom';
import { import {
ThemeSwitcher, ThemeSwitcher,
Navigation, Navigation,
@ -13,23 +13,26 @@ import { t } from '@vegaprotocol/i18n';
import { Routes } from '../../routes/route-names'; import { Routes } from '../../routes/route-names';
import { NetworkSwitcher } from '@vegaprotocol/environment'; import { NetworkSwitcher } from '@vegaprotocol/environment';
import type { Navigable } from '../../routes/router-config'; import type { Navigable } from '../../routes/router-config';
import routerConfig from '../../routes/router-config'; import { isNavigable } from '../../routes/router-config';
import { routerConfig } from '../../routes/router-config';
import { useMemo } from 'react'; import { useMemo } from 'react';
import compact from 'lodash/compact'; import compact from 'lodash/compact';
import { Search } from '../search'; import { Search } from '../search';
const routeToNavigationItem = (r: Navigable) => ( const routeToNavigationItem = (r: Navigable) => (
<NavigationItem key={r.name}> <NavigationItem key={r.handle.name}>
<NavigationLink to={r.path}>{r.text}</NavigationLink> <NavigationLink to={r.path}>{r.handle.text}</NavigationLink>
</NavigationItem> </NavigationItem>
); );
export const Header = () => { export const Header = () => {
const isHome = Boolean(useMatch(Routes.HOME));
const pages = routerConfig[0].children || [];
const mainItems = compact( const mainItems = compact(
[Routes.TX, Routes.BLOCKS, Routes.ORACLES, Routes.VALIDATORS].map((n) => [Routes.TX, Routes.BLOCKS, Routes.ORACLES, Routes.VALIDATORS].map((n) =>
routerConfig.find((r) => r.path === n) pages.find((r) => r.path === n)
) )
); ).filter(isNavigable);
const groupedItems = compact( const groupedItems = compact(
[ [
@ -39,8 +42,8 @@ export const Header = () => {
Routes.GOVERNANCE, Routes.GOVERNANCE,
Routes.NETWORK_PARAMETERS, Routes.NETWORK_PARAMETERS,
Routes.GENESIS, Routes.GENESIS,
].map((n) => routerConfig.find((r) => r.path === n)) ].map((n) => pages.find((r) => r.path === n))
); ).filter(isNavigable);
const { pathname } = useLocation(); const { pathname } = useLocation();
@ -67,7 +70,7 @@ export const Header = () => {
actions={ actions={
<> <>
<ThemeSwitcher /> <ThemeSwitcher />
<Search /> {!isHome && <Search />}
</> </>
} }
onResize={(width, el) => { onResize={(width, el) => {
@ -90,7 +93,7 @@ export const Header = () => {
hide={[NavigationBreakpoint.Small, NavigationBreakpoint.Narrow]} hide={[NavigationBreakpoint.Small, NavigationBreakpoint.Narrow]}
> >
{mainItems.map(routeToNavigationItem)} {mainItems.map(routeToNavigationItem)}
{groupedItems && ( {groupedItems && groupedItems.length > 0 && (
<NavigationItem> <NavigationItem>
<NavigationTrigger isActive={Boolean(isOnOther)}> <NavigationTrigger isActive={Boolean(isOnOther)}>
{t('Other')} {t('Other')}

View File

@ -1,9 +0,0 @@
import { AppRouter } from '../../routes';
export const Main = () => {
return (
<main className="p-4">
<AppRouter />
</main>
);
};

View File

@ -1,32 +0,0 @@
import { t } from '@vegaprotocol/i18n';
import React from 'react';
interface RouteErrorBoundaryProps {
children: React.ReactElement;
}
export class RouteErrorBoundary extends React.Component<
RouteErrorBoundaryProps,
{ hasError: boolean }
> {
constructor(props: RouteErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
override componentDidCatch(error: Error) {
console.log(`Error caught in App error boundary ${error.message}`, error);
}
override render() {
if (this.state.hasError) {
return <h1>{t('Something went wrong')}</h1>;
}
return this.props.children;
}
}

View File

@ -1,189 +1,70 @@
import { import {
detectTypeByFetching, determineType,
detectTypeFromQuery,
getSearchType,
isBlock, isBlock,
isHexadecimal,
isNetworkParty, isNetworkParty,
isNonHex,
SearchTypes, SearchTypes,
toHex, isHash,
toNonHex,
} from './detect-search'; } from './detect-search';
import { DATA_SOURCES } from '../../config';
global.fetch = jest.fn(); global.fetch = jest.fn();
describe('Detect Search', () => { describe('Detect Search', () => {
it("should detect that it's a hexadecimal", () => { it.each([
const expected = true; ['0000000000000000000000000000000000000000000000000000000000000000', true],
const testString = ['0000000000000000000000000000000000000000000000000000000000000001', true],
'0x073ceaab59e5f2dd0561dec4883e7ee5bc7165cd4de34717a3ab8f2cbe3007f9'; [
const actual = isHexadecimal(testString); 'LOOONG0000000000000000000000000000000000000000000000000000000000000000',
expect(actual).toBe(expected); false,
}); ],
['xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', false],
it("should detect that it's not hexadecimal", () => { ['something else', false],
const expected = true; ])("should detect that it's a hash", (input, expected) => {
const testString = expect(isHash(input)).toBe(expected);
'073ceaab59e5f2dd0561dec4883e7ee5bc7165cd4de34717a3ab8f2cbe3007f9';
const actual = isNonHex(testString);
expect(actual).toBe(expected);
}); });
it("should detect that it's a network party", () => { it("should detect that it's a network party", () => {
const expected = true; expect(isNetworkParty('network')).toBe(true);
const testString = 'network'; expect(isNetworkParty('web')).toBe(false);
const actual = isNetworkParty(testString);
expect(actual).toBe(expected);
}); });
it("should detect that it's a block", () => { it("should detect that it's a block", () => {
const expected = true; expect(isBlock('123')).toBe(true);
const testString = '3188'; expect(isBlock('x123')).toBe(false);
const actual = isBlock(testString);
expect(actual).toBe(expected);
}); });
it('should convert from non-hex to hex', () => { it.each([
const expected = '0x123'; [
const testString = '123'; '0000000000000000000000000000000000000000000000000000000000000000',
const actual = toHex(testString); SearchTypes.Transaction,
expect(actual).toBe(expected); ],
}); [
'0000000000000000000000000000000000000000000000000000000000000001',
it('should convert from hex to non-hex', () => { SearchTypes.Party,
const expected = '123'; ],
const testString = '0x123'; ['123', SearchTypes.Block],
const actual = toNonHex(testString); ['network', SearchTypes.Party],
expect(actual).toBe(expected); ['something else', SearchTypes.Unknown],
}); ])(
"detectTypeByFetching should call fetch with non-hex query it's a transaction",
it("should detect type client side from query if it's a hexadecimal", () => { async (input, type) => {
const expected = [SearchTypes.Party, SearchTypes.Transaction]; // @ts-ignore issue related to polyfill
const testString = fetch.mockImplementation(
'0x4624293CFE3D8B67A0AB448BAFF8FBCF1A1B770D9D5F263761D3D6CBEA94D97F'; jest.fn(() =>
const actual = detectTypeFromQuery(testString); Promise.resolve({
expect(actual).toStrictEqual(expected); ok:
}); input ===
'0000000000000000000000000000000000000000000000000000000000000000',
it("should detect type client side from query if it's a non hex", () => { json: () =>
const expected = [SearchTypes.Party, SearchTypes.Transaction]; Promise.resolve({
const testString = transaction: {
'4624293CFE3D8B67A0AB448BAFF8FBCF1A1B770D9D5F263761D3D6CBEA94D97F'; hash: input,
const actual = detectTypeFromQuery(testString); },
expect(actual).toStrictEqual(expected); }),
}); })
)
it("should detect type client side from query if it's a network party", () => { );
const expected = [SearchTypes.Party]; const result = await determineType(input);
const testString = 'network'; expect(result).toBe(type);
const actual = detectTypeFromQuery(testString); }
expect(actual).toStrictEqual(expected); );
});
it("should detect type client side from query if it's a block (number)", () => {
const expected = [SearchTypes.Block];
const testString = '23432';
const actual = detectTypeFromQuery(testString);
expect(actual).toStrictEqual(expected);
});
it("detectTypeByFetching should call fetch with non-hex query it's a transaction", async () => {
const query = '0xabc';
const type = SearchTypes.Transaction;
// @ts-ignore issue related to polyfill
fetch.mockImplementation(
jest.fn(() =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
transaction: {
hash: query,
},
}),
})
)
);
const result = await detectTypeByFetching(query);
expect(fetch).toHaveBeenCalledWith(
`${DATA_SOURCES.blockExplorerUrl}/transactions/${toNonHex(query)}`
);
expect(result).toBe(type);
});
it("detectTypeByFetching should call fetch with non-hex query it's a party", async () => {
const query = 'abc';
const type = SearchTypes.Party;
// @ts-ignore issue related to polyfill
fetch.mockImplementation(
jest.fn(() =>
Promise.resolve({
ok: false,
})
)
);
const result = await detectTypeByFetching(query);
expect(result).toBe(type);
});
it('getSearchType should return party from fetch response', async () => {
const query =
'0x4624293CFE3D8B67A0AB448BAFF8FBCF1A1B770D9D5F263761D3D6CBEA94D97F';
const expected = SearchTypes.Party;
// @ts-ignore issue related to polyfill
fetch.mockImplementation(
jest.fn(() =>
Promise.resolve({
ok: false,
})
)
);
const result = await getSearchType(query);
expect(result).toBe(expected);
});
it('getSearchType should return transaction from fetch response', async () => {
const query =
'4624293CFE3D8B67A0AB448BAFF8FBCF1A1B770D9D5F263761D3D6CBEA94D97F';
const expected = SearchTypes.Transaction;
// @ts-ignore issue related to polyfill
fetch.mockImplementation(
jest.fn(() =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
transaction: {
hash: query,
},
}),
})
)
);
const result = await getSearchType(query);
expect(result).toBe(expected);
});
it('getSearchType should return undefined from transaction response', async () => {
const query = 'u';
const expected = undefined;
const result = await getSearchType(query);
expect(result).toBe(expected);
});
it('getSearchType should return block if query is number', async () => {
const query = '123';
const expected = SearchTypes.Block;
const result = await getSearchType(query);
expect(result).toBe(expected);
});
it('getSearchType should return party if query is network', async () => {
const query = 'network';
const expected = SearchTypes.Party;
const result = await getSearchType(query);
expect(result).toBe(expected);
});
}); });

View File

@ -1,3 +1,4 @@
import { remove0x } from '@vegaprotocol/utils';
import { DATA_SOURCES } from '../../config'; import { DATA_SOURCES } from '../../config';
import type { BlockExplorerTransaction } from '../../routes/types/block-explorer-response'; import type { BlockExplorerTransaction } from '../../routes/types/block-explorer-response';
@ -6,15 +7,20 @@ export enum SearchTypes {
Party = 'party', Party = 'party',
Block = 'block', Block = 'block',
Order = 'order', Order = 'order',
Unknown = 'unknown',
} }
export const TX_LENGTH = 64; export const HASH_LENGTH = 64;
export const isHash = (value: string) =>
/[0-9a-fA-F]+/.test(remove0x(value)) &&
remove0x(value).length === HASH_LENGTH;
export const isHexadecimal = (search: string) => export const isHexadecimal = (search: string) =>
search.startsWith('0x') && search.length === 2 + TX_LENGTH; search.startsWith('0x') && search.length === 2 + HASH_LENGTH;
export const isNonHex = (search: string) => export const isNonHex = (search: string) =>
!search.startsWith('0x') && search.length === TX_LENGTH; !search.startsWith('0x') && search.length === HASH_LENGTH;
export const isBlock = (search: string) => !Number.isNaN(Number(search)); export const isBlock = (search: string) => !Number.isNaN(Number(search));
@ -23,121 +29,44 @@ export const isNetworkParty = (search: string) => search === 'network';
export const toHex = (query: string) => export const toHex = (query: string) =>
isHexadecimal(query) ? query : `0x${query}`; isHexadecimal(query) ? query : `0x${query}`;
export const toNonHex = (query: string) => export const toNonHex = remove0x;
isNonHex(query) ? query : `${query.replace('0x', '')}`;
export const detectTypeFromQuery = ( /**
query: string * Determine the type of the given query
): SearchTypes[] | undefined => { */
const i = query.toLowerCase(); export const determineType = async (query: string): Promise<SearchTypes> => {
const value = query.toLowerCase();
if (isHexadecimal(i) || isNonHex(i)) { if (isHash(value)) {
return [SearchTypes.Party, SearchTypes.Transaction]; // it can be either `SearchTypes.Party` or `SearchTypes.Transaction`
} else if (isNetworkParty(i)) { if (await isTransactionHash(value)) {
return [SearchTypes.Party]; return SearchTypes.Transaction;
} else if (isBlock(i)) { } else {
return [SearchTypes.Block]; return SearchTypes.Party;
}
} else if (isNetworkParty(value)) {
return SearchTypes.Party;
} else if (isBlock(value)) {
return SearchTypes.Block;
} }
return SearchTypes.Unknown;
return undefined;
}; };
export const detectTypeByFetching = async ( /**
query: string * Checks if given input is a transaction hash by querying the transactions
): Promise<SearchTypes | undefined> => { * endpoint
const hash = toNonHex(query); */
export const isTransactionHash = async (input: string): Promise<boolean> => {
const hash = remove0x(input);
const request = await fetch( const request = await fetch(
`${DATA_SOURCES.blockExplorerUrl}/transactions/${hash}` `${DATA_SOURCES.blockExplorerUrl}/transactions/${hash}`
); );
if (request?.ok) { if (request?.ok) {
const body: BlockExplorerTransaction = await request.json(); const body: BlockExplorerTransaction = await request.json();
if (body?.transaction) { if (body?.transaction) {
return SearchTypes.Transaction; return true;
} }
} }
return SearchTypes.Party; return false;
};
// Code commented out because the current solution to detect a hex is temporary (by process of elimination)
// export const detectTypeByFetching = async (
// query: string,
// type: SearchTypes
// ): Promise<SearchTypes | undefined> => {
// const TYPES = [SearchTypes.Party, SearchTypes.Transaction];
//
// if (!TYPES.includes(type)) {
// throw new Error('Search type provided not recognised');
// }
//
// if (type === SearchTypes.Transaction) {
// const hash = toNonHex(query);
// const request = await fetch(
// `${DATA_SOURCES.blockExplorerUrl}/transactions/${hash}`
// );
//
// if (request?.ok) {
// const body: BlockExplorerTransaction = await request.json();
//
// if (body?.transaction) {
// return SearchTypes.Transaction;
// }
// }
// } else if (type === SearchTypes.Party) {
// const party = toNonHex(query);
//
// const request = await fetch(
// `${DATA_SOURCES.blockExplorerUrl}/transactions?limit=1&filters[tx.submitter]=${party}`
// );
//
// if (request.ok) {
// const body: BlockExplorerTransactions = await request.json();
//
// if (body?.transactions?.length) {
// return SearchTypes.Party;
// }
// }
// }
//
// return undefined;
// };
// export const getSearchType = async (
// query: string
// ): Promise<SearchTypes | undefined> => {
// const searchTypes = detectTypeFromQuery(query);
// const hasResults = searchTypes?.length;
//
// if (hasResults) {
// if (hasResults > 1) {
// const promises = searchTypes.map((type) =>
// detectTypeByFetching(query, type)
// );
// const results = await Promise.all(promises);
// return results.find((result) => result !== undefined);
// }
//
// return searchTypes[0];
// }
//
// return undefined;
// };
export const getSearchType = async (
query: string
): Promise<SearchTypes | undefined> => {
const searchTypes = detectTypeFromQuery(query);
const hasResults = searchTypes?.length;
if (hasResults) {
if (hasResults > 1) {
return await detectTypeByFetching(query);
}
return searchTypes[0];
}
return undefined;
}; };

View File

@ -1,157 +1,82 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import {
import { Search } from './search'; act,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { SearchForm } from './search';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { Routes } from '../../routes/route-names'; import { Routes } from '../../routes/route-names';
import { SearchTypes, getSearchType } from './detect-search';
global.fetch = jest.fn();
const mockedNavigate = jest.fn(); const mockedNavigate = jest.fn();
const mockGetSearchType = getSearchType as jest.MockedFunction<
typeof getSearchType
>;
jest.mock('react-router-dom', () => ({ jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'), ...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate, useNavigate: () => mockedNavigate,
})); }));
jest.mock('./detect-search', () => ({
...jest.requireActual('./detect-search'),
getSearchType: jest.fn(),
}));
beforeEach(() => { beforeEach(() => {
mockedNavigate.mockClear(); mockedNavigate.mockClear();
}); });
const renderComponent = () => ( const renderComponent = () =>
<MemoryRouter> render(
<Search /> <MemoryRouter>
</MemoryRouter> <SearchForm />
); </MemoryRouter>
);
const getInputs = () => ({ describe('SearchForm', () => {
input: screen.getByTestId('search'),
button: screen.getByTestId('search-button'),
});
describe('Search', () => {
it('should render search input and button', () => { it('should render search input and button', () => {
render(renderComponent()); renderComponent();
expect(screen.getByTestId('search')).toBeInTheDocument(); expect(screen.getByTestId('search')).toBeInTheDocument();
expect(screen.getByTestId('search-button')).toHaveTextContent('Search'); expect(screen.getByTestId('search-button')).toHaveTextContent('Search');
}); });
it('should render error if input is not known', async () => { it.each([
render(renderComponent()); [
const { button, input } = getInputs(); Routes.TX,
fireEvent.change(input, { target: { value: 'asd' } }); '0000000000000000000000000000000000000000000000000000000000000000',
fireEvent.click(button); ],
[
expect(await screen.findByTestId('search-error')).toHaveTextContent( Routes.PARTIES,
'Transaction type is not recognised' '0000000000000000000000000000000000000000000000000000000000000001',
],
[Routes.BLOCKS, '123'],
[undefined, 'something else'],
])('should redirect to %s', async (route, input) => {
// @ts-ignore issue related to polyfill
fetch.mockImplementation(
jest.fn(() =>
Promise.resolve({
ok:
input ===
'0000000000000000000000000000000000000000000000000000000000000000',
json: () =>
Promise.resolve({
transaction: {
hash: input,
},
}),
})
)
); );
}); renderComponent();
await act(async () => {
it('should render error if no input is given', async () => { fireEvent.change(screen.getByTestId('search'), {
render(renderComponent()); target: {
const { button } = getInputs(); value: input,
},
fireEvent.click(button); });
fireEvent.click(screen.getByTestId('search-button'));
expect(await screen.findByTestId('search-error')).toHaveTextContent(
'Search query required'
);
});
it('should redirect to transactions page', async () => {
render(renderComponent());
const { button, input } = getInputs();
mockGetSearchType.mockResolvedValue(SearchTypes.Transaction);
fireEvent.change(input, {
target: {
value:
'0x1234567890123456789012345678901234567890123456789012345678901234',
},
}); });
fireEvent.click(button);
await waitFor(() => { await waitFor(() => {
expect(mockedNavigate).toBeCalledWith( expect(mockedNavigate).toBeCalledTimes(route ? 1 : 0);
`${Routes.TX}/0x1234567890123456789012345678901234567890123456789012345678901234` if (route) {
); // eslint-disable-next-line jest/no-conditional-expect
}); expect(mockedNavigate).toBeCalledWith(`${route}/${input}`);
}); }
it('should redirect to transactions page without proceeding 0x', async () => {
render(renderComponent());
const { button, input } = getInputs();
mockGetSearchType.mockResolvedValue(SearchTypes.Transaction);
fireEvent.change(input, {
target: {
value:
'1234567890123456789012345678901234567890123456789012345678901234',
},
});
fireEvent.click(button);
await waitFor(() => {
expect(mockedNavigate).toBeCalledWith(
`${Routes.TX}/0x1234567890123456789012345678901234567890123456789012345678901234`
);
});
});
it('should redirect to parties page', async () => {
render(renderComponent());
const { button, input } = getInputs();
mockGetSearchType.mockResolvedValue(SearchTypes.Party);
fireEvent.change(input, {
target: {
value:
'0x1234567890123456789012345678901234567890123456789012345678901234',
},
});
fireEvent.click(button);
await waitFor(() => {
expect(mockedNavigate).toBeCalledWith(
`${Routes.PARTIES}/0x1234567890123456789012345678901234567890123456789012345678901234`
);
});
});
it('should redirect to parties page without proceeding 0x', async () => {
render(renderComponent());
const { button, input } = getInputs();
mockGetSearchType.mockResolvedValue(SearchTypes.Party);
fireEvent.change(input, {
target: {
value:
'1234567890123456789012345678901234567890123456789012345678901234',
},
});
fireEvent.click(button);
await waitFor(() => {
expect(mockedNavigate).toBeCalledWith(
`${Routes.PARTIES}/0x1234567890123456789012345678901234567890123456789012345678901234`
);
});
});
it('should redirect to blocks page if passed a number', async () => {
render(renderComponent());
const { button, input } = getInputs();
mockGetSearchType.mockResolvedValue(SearchTypes.Block);
fireEvent.change(input, {
target: {
value: '123',
},
});
fireEvent.click(button);
await waitFor(() => {
expect(mockedNavigate).toBeCalledWith(`${Routes.BLOCKS}/123`);
}); });
}); });
}); });

View File

@ -1,12 +1,13 @@
import { useCallback, useState } from 'react'; import { useCallback } from 'react';
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import { Button, Input, InputError } from '@vegaprotocol/ui-toolkit'; import { Button, Input, InputError } from '@vegaprotocol/ui-toolkit';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { getSearchType, SearchTypes, toHex } from './detect-search';
import { Routes } from '../../routes/route-names'; import { Routes } from '../../routes/route-names';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import classNames from 'classnames'; import classNames from 'classnames';
import { remove0x } from '@vegaprotocol/utils';
import { determineType, isBlock, isHash, SearchTypes } from './detect-search';
interface FormFields { interface FormFields {
search: string; search: string;
@ -30,101 +31,26 @@ const MagnifyingGlass = () => (
</svg> </svg>
); );
const Clear = () => (
<svg
className="w-3 h-3"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.3748 1.37478L1.37478 11.3748L0.625244 10.6252L10.6252 0.625244L11.3748 1.37478Z"
fill="currentColor"
/>
<path
d="M1.37478 0.625244L11.3748 10.6252L10.6252 11.3748L0.625244 1.37478L1.37478 0.625244Z"
fill="currentColor"
/>
</svg>
);
export const Search = () => { export const Search = () => {
const { register, handleSubmit } = useForm<FormFields>(); const searchForm = <SearchForm />;
const navigate = useNavigate();
const [error, setError] = useState<Error | null>(null);
const onSubmit = useCallback(
async (fields: FormFields) => {
setError(null);
const query = fields.search;
if (!query) {
return setError(new Error(t('Search query required')));
}
const result = await getSearchType(query);
const urlAsHex = toHex(query);
const unrecognisedError = new Error(
t('Transaction type is not recognised')
);
if (result) {
switch (result) {
case SearchTypes.Party:
return navigate(`${Routes.PARTIES}/${urlAsHex}`);
case SearchTypes.Transaction:
return navigate(`${Routes.TX}/${urlAsHex}`);
case SearchTypes.Block:
return navigate(`${Routes.BLOCKS}/${Number(query)}`);
default:
return setError(unrecognisedError);
}
}
return setError(unrecognisedError);
},
[navigate]
);
const searchForm = (
<form className="block min-w-[290px]" onSubmit={handleSubmit(onSubmit)}>
<div className="flex relative items-stretch gap-2 text-xs">
<label htmlFor="search" className="sr-only">
{t('Search by block number or transaction hash')}
</label>
<button
className={classNames(
'absolute top-[50%] translate-y-[-50%] left-2',
'text-vega-light-300 dark:text-vega-dark-300'
)}
>
<MagnifyingGlass />
</button>
<Input
{...register('search')}
id="search"
data-testid="search"
className={classNames(
'peer',
'pl-8 py-2 text-xs',
'border rounded border-vega-light-200 dark:border-vega-dark-200'
)}
hasError={Boolean(error?.message)}
type="text"
placeholder={t('Enter block number, public key or transaction hash')}
/>
{error?.message && (
<div
className={classNames(
'hidden peer-focus:block',
'bg-white dark:bg-black',
'border rounded-b border-t-0 border-vega-light-200 dark:border-vega-dark-200',
'absolute top-[100%] flex-1 w-full pb-2 px-2 text-black dark:text-white'
)}
>
<InputError
data-testid="search-error"
intent="danger"
className="text-xs"
>
{error.message}
</InputError>
</div>
)}
<Button
className="hidden [.search-dropdown_&]:block"
type="submit"
size="xs"
data-testid="search-button"
>
{t('Search')}
</Button>
</div>
</form>
);
const searchTrigger = ( const searchTrigger = (
<DropdownMenu.Root> <DropdownMenu.Root>
@ -154,10 +80,135 @@ export const Search = () => {
return ( return (
<> <>
<div className="hidden [.nav-search-full_&]:block">{searchForm}</div> <div className="hidden [.nav-search-full_&]:block min-w-[290px]">
{searchForm}
</div>
<div className="hidden [.nav-search-compact_&]:block"> <div className="hidden [.nav-search-compact_&]:block">
{searchTrigger} {searchTrigger}
</div> </div>
</> </>
); );
}; };
export const SearchForm = () => {
const {
register,
handleSubmit,
setValue,
setError,
clearErrors,
formState,
watch,
} = useForm<FormFields>();
const navigate = useNavigate();
const onSubmit = useCallback(
async (fields: FormFields) => {
clearErrors();
const type = await determineType(fields.search);
if (type) {
switch (type) {
case SearchTypes.Party:
return navigate(`${Routes.PARTIES}/${remove0x(fields.search)}`);
case SearchTypes.Transaction:
return navigate(`${Routes.TX}/${remove0x(fields.search)}`);
case SearchTypes.Block:
return navigate(`${Routes.BLOCKS}/${Number(fields.search)}`);
}
}
setError('search', new Error(t('The search term is not a valid query')));
},
[clearErrors, navigate, setError]
);
const searchQuery = watch('search', '');
return (
<form className="block min-w-[200px]" onSubmit={handleSubmit(onSubmit)}>
<div className="flex relative items-stretch gap-2 text-xs">
<div className="relative w-full">
<label htmlFor="search" className="sr-only">
{t('Search by block number or transaction hash')}
</label>
<button
className={classNames(
'absolute top-[50%] translate-y-[-50%] left-2',
'text-vega-light-300 dark:text-vega-dark-300'
)}
>
<MagnifyingGlass />
</button>
<button
onClick={(e) => {
e.preventDefault();
setValue('search', '');
clearErrors();
}}
className={classNames(
{ hidden: searchQuery.length === 0 },
'absolute top-[50%] translate-y-[-50%] right-2',
'text-vega-light-300 dark:text-vega-dark-300'
)}
>
<Clear />
</button>
<Input
{...register('search', {
required: t('Search query is required'),
validate: (value) =>
isHash(value) ||
isBlock(value) ||
t('Search query has to be a number or a 64 character hash'),
onBlur: () => clearErrors('search'),
})}
id="search"
data-testid="search"
className={classNames(
'pl-8 py-2 text-xs',
{ 'pr-8': searchQuery.length > 1 },
'border rounded border-vega-light-200 dark:border-vega-dark-200',
{
'border-vega-pink dark:border-vega-pink': Boolean(
formState.errors.search
),
}
)}
hasError={Boolean(formState.errors.search)}
type="text"
placeholder={t(
'Enter block number, public key or transaction hash'
)}
/>
</div>
{formState.errors.search && (
<div
className={classNames(
'[nav_&]:border [nav_&]:rounded [nav_&]:border-vega-light-300 [nav_&]:dark:border-vega-light-300',
'[.search-dropdown_&]:border [.search-dropdown_&]:rounded [.search-dropdown_&]:border-vega-light-300 [.search-dropdown_&]:dark:border-vega-light-300',
'bg-white dark:bg-black',
'absolute top-[100%] flex-1 w-full pb-2 px-2 text-black dark:text-white'
)}
>
<InputError
data-testid="search-error"
intent="danger"
className="text-xs"
>
{formState.errors.search.message}
</InputError>
</div>
)}
<Button
variant="primary"
type="submit"
size="xs"
data-testid="search-button"
className="[nav_&]:hidden"
>
{t('Search')}
</Button>
</div>
</form>
);
};

View File

@ -1,14 +1,19 @@
import { StatsManager } from '@vegaprotocol/network-stats'; import { StatsManager } from '@vegaprotocol/network-stats';
import { SearchForm } from '../../components/search';
import { useDocumentTitle } from '../../hooks/use-document-title'; import { useDocumentTitle } from '../../hooks/use-document-title';
const Home = () => { const Home = () => {
const classnames = 'mt-4 grid grid-cols-1 lg:grid-cols-2 lg:gap-4'; const classnames = 'mt-4 mb-4';
useDocumentTitle(); useDocumentTitle();
return ( return (
<section> <section>
<StatsManager className={classnames} /> <div className="p-20 max-sm:py-10 max-sm:px-0">
<SearchForm />
</div>
<div className="px-20 max-sm:px-0">
<StatsManager className={classnames} />
</div>
</section> </section>
); );
}; };

View File

@ -1,26 +0,0 @@
import React from 'react';
import { useRoutes } from 'react-router-dom';
import { RouteErrorBoundary } from '../components/router-error-boundary';
import routerConfig from './router-config';
import { Loader, Splash } from '@vegaprotocol/ui-toolkit';
export interface RouteChildProps {
name: string;
}
export const AppRouter = () => {
const routes = useRoutes(routerConfig);
const splashLoading = (
<Splash>
<Loader />
</Splash>
);
return (
<RouteErrorBoundary>
<React.Suspense fallback={splashLoading}>{routes}</React.Suspense>
</RouteErrorBoundary>
);
};

View File

@ -0,0 +1,187 @@
import {
AssetDetailsDialog,
useAssetDetailsDialogStore,
} from '@vegaprotocol/assets';
import { t } from '@vegaprotocol/i18n';
import {
AnnouncementBanner,
BackgroundVideo,
BreadcrumbsContainer,
ButtonLink,
ExternalLink,
Icon,
} from '@vegaprotocol/ui-toolkit';
import classNames from 'classnames';
import { useState } from 'react';
import {
isRouteErrorResponse,
Link,
Outlet,
useMatch,
useRouteError,
} from 'react-router-dom';
import { Footer } from '../components/footer/footer';
import { Header } from '../components/header';
import { Routes } from './route-names';
const DialogsContainer = () => {
const { isOpen, id, trigger, asJson, setOpen } = useAssetDetailsDialogStore();
return (
<AssetDetailsDialog
assetId={id}
trigger={trigger || null}
asJson={asJson}
open={isOpen}
onChange={setOpen}
/>
);
};
const MainnetSimAd = () => {
const [shouldDisplayBanner, setShouldDisplayBanner] = useState<boolean>(true);
// Return an empty div so that the grid layout in _app.page.ts
// renders correctly
if (!shouldDisplayBanner) {
return <div />;
}
return (
<AnnouncementBanner>
<div className="grid grid-cols-[auto_1fr] gap-4 font-alpha calt uppercase text-center text-lg text-white">
<button
className="flex items-center"
onClick={() => setShouldDisplayBanner(false)}
>
<Icon name="cross" className="w-6 h-6" ariaLabel="dismiss" />
</button>
<div>
<span className="pr-4">Mainnet sim 3 is live!</span>
<ExternalLink href="https://fairground.wtf/">Learn more</ExternalLink>
</div>
</div>
</AnnouncementBanner>
);
};
export const Layout = () => {
const isHome = Boolean(useMatch(Routes.HOME));
return (
<>
<div
className={classNames(
'max-w-[1500px] min-h-[100vh]',
'mx-auto my-0',
'grid grid-rows-[auto_1fr_auto] grid-cols-1',
'border-vega-light-200 dark:border-vega-dark-200 lg:border-l lg:border-r',
'antialiased text-black dark:text-white',
'overflow-hidden relative'
)}
>
<div>
<MainnetSimAd />
<Header />
</div>
<div>
<main className="p-4">
{!isHome && <BreadcrumbsContainer className="mb-4" />}
<Outlet />
</main>
</div>
<div>
<Footer />
</div>
</div>
<DialogsContainer />
</>
);
};
export const ErrorBoundary = () => {
const error = useRouteError();
const errorTitle = isRouteErrorResponse(error)
? `${error.status} ${error.statusText}`
: t('Something went wrong');
const errorMessage = isRouteErrorResponse(error)
? error.error?.message
: (error as Error).message || JSON.stringify(error);
return (
<>
<BackgroundVideo className="brightness-50" />
<div
className={classNames(
'max-w-[620px] p-2 mt-[10vh]',
'mx-auto my-0',
'antialiased text-white',
'overflow-hidden relative',
'flex flex-col gap-2'
)}
>
<div className="flex gap-4">
<div>{GHOST}</div>
<h1 className="text-[2.7rem] font-alpha calt break-words uppercase">
{errorTitle}
</h1>
</div>
<div className="text-sm mt-10 overflow-auto break-all font-mono">
{errorMessage}
</div>
<div>
<ButtonLink onClick={() => window.location.reload()}>
{t('Try refreshing')}
</ButtonLink>{' '}
{t('or go back to')}{' '}
<Link className="underline" to="/">
{t('Home')}
</Link>
</div>
</div>
</>
);
};
const GHOST = (
<svg
width="56"
height="85"
viewBox="0 0 56 85"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M41 0.5H3V60.5H41V0.5Z" fill="white" />
<path d="M15 18.5H13V20.5H15V18.5Z" fill="black" />
<path d="M17 20.5H15V22.5H17V20.5Z" fill="black" />
<path d="M19 18.5H17V20.5H19V18.5Z" fill="black" />
<path d="M15 22.5H13V24.5H15V22.5Z" fill="black" />
<path d="M19 22.5H17V24.5H19V22.5Z" fill="black" />
<path d="M29 28.5H15V30.5H29V28.5Z" fill="black" />
<path d="M27 18.5H25V20.5H27V18.5Z" fill="black" />
<path d="M29 20.5H27V22.5H29V20.5Z" fill="black" />
<path d="M31 18.5H29V20.5H31V18.5Z" fill="black" />
<path d="M27 22.5H25V24.5H27V22.5Z" fill="black" />
<path d="M31 22.5H29V24.5H31V22.5Z" fill="black" />
<path d="M31 26.5H29V28.5H31V26.5Z" fill="black" />
<path d="M19 60.5H17V84.5H19V60.5Z" fill="black" />
<path d="M27 60.5H25V84.5H27V60.5Z" fill="black" />
<path
d="M3 42.5V58.64V60.5V64.5H21V60.5H23V64.5H41V60.5V58.64V42.5H3Z"
fill="#FF077F"
/>
<path d="M35 46.5H41V42.5H3V46.5H31H35Z" fill="#CB0666" />
<path d="M3 32.32V29.5L0 32.5V60.5H2V33.33L3 32.32Z" fill="black" />
<path d="M41 31.8V29.49L54.79 21.53L55.79 23.26L41 31.8Z" fill="black" />
<path d="M36 54.5H35V55.5H36V54.5Z" fill="black" />
<path d="M35 53.5H34V54.5H35V53.5Z" fill="black" />
<path d="M34 48.5H33V53.5H34V48.5Z" fill="black" />
<path d="M38 48.5H37V52.5H38V48.5Z" fill="black" />
<path d="M37 53.5H36V54.5H37V53.5Z" fill="black" />
<path d="M39 52.5H38V53.5H39V52.5Z" fill="black" />
<path
d="M55.7901 23.27L53.0601 22.54L45.1001 8.75L46.8301 7.75L55.7901 23.27Z"
fill="black"
/>
</svg>
);

View File

@ -8,7 +8,6 @@ import { Oracle } from './oracles/id';
import Party from './parties'; import Party from './parties';
import { Parties } from './parties/home'; import { Parties } from './parties/home';
import { Party as PartySingle } from './parties/id'; import { Party as PartySingle } from './parties/id';
import Txs from './txs';
import { ValidatorsPage } from './validators'; import { ValidatorsPage } from './validators';
import Genesis from './genesis'; import Genesis from './genesis';
import { Block } from './blocks/id'; import { Block } from './blocks/id';
@ -20,23 +19,55 @@ import flags from '../config/flags';
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import { Routes } from './route-names'; import { Routes } from './route-names';
import { NetworkParameters } from './network-parameters'; import { NetworkParameters } from './network-parameters';
import type { RouteObject } from 'react-router-dom'; import type { Params, RouteObject } from 'react-router-dom';
import { createBrowserRouter } from 'react-router-dom';
import { Link } from 'react-router-dom';
import { MarketPage, MarketsPage } from './markets'; import { MarketPage, MarketsPage } from './markets';
import type { ReactNode } from 'react';
import { ErrorBoundary, Layout } from './layout';
import compact from 'lodash/compact';
import { AssetLink, MarketLink } from '../components/links';
import { truncateMiddle } from '@vegaprotocol/ui-toolkit';
import { remove0x } from '@vegaprotocol/utils';
export type Navigable = { export type Navigable = {
path: string; path: string;
name: string; handle: {
text: string; name: string;
text: string;
};
};
export const isNavigable = (item: RouteObject): item is Navigable =>
(item as Navigable).path !== undefined &&
(item as Navigable).handle !== undefined &&
(item as Navigable).handle.name !== undefined &&
(item as Navigable).handle.text !== undefined;
export type Breadcrumbable = {
handle: { breadcrumb: (data?: Params<string>) => ReactNode | string };
};
export const isBreadcrumbable = (item: RouteObject): item is Breadcrumbable =>
(item as Breadcrumbable).handle !== undefined &&
(item as Breadcrumbable).handle.breadcrumb !== undefined;
type RouteItem =
| RouteObject
| (RouteObject & Navigable)
| (RouteObject & Breadcrumbable);
type Route = RouteItem & {
children?: RouteItem[];
}; };
type Route = RouteObject & Navigable;
const partiesRoutes: Route[] = flags.parties const partiesRoutes: Route[] = flags.parties
? [ ? [
{ {
path: Routes.PARTIES, path: Routes.PARTIES,
name: t('Parties'),
text: t('Parties'),
element: <Party />, element: <Party />,
handle: {
name: t('Parties'),
text: t('Parties'),
breadcrumb: () => <Link to={Routes.PARTIES}>{t('Parties')}</Link>,
},
children: [ children: [
{ {
index: true, index: true,
@ -45,6 +76,13 @@ const partiesRoutes: Route[] = flags.parties
{ {
path: ':party', path: ':party',
element: <PartySingle />, element: <PartySingle />,
handle: {
breadcrumb: (params: Params<string>) => (
<Link to={linkTo(Routes.PARTIES, params.party)}>
{truncateMiddle(params.party as string)}
</Link>
),
},
}, },
], ],
}, },
@ -55,8 +93,11 @@ const assetsRoutes: Route[] = flags.assets
? [ ? [
{ {
path: Routes.ASSETS, path: Routes.ASSETS,
text: t('Assets'), handle: {
name: t('Assets'), name: t('Assets'),
text: t('Assets'),
breadcrumb: () => <Link to={Routes.ASSETS}>{t('Assets')}</Link>,
},
children: [ children: [
{ {
index: true, index: true,
@ -65,6 +106,11 @@ const assetsRoutes: Route[] = flags.assets
{ {
path: ':assetId', path: ':assetId',
element: <AssetPage />, element: <AssetPage />,
handle: {
breadcrumb: (params: Params<string>) => (
<AssetLink assetId={params.assetId as string} />
),
},
}, },
], ],
}, },
@ -75,8 +121,13 @@ const genesisRoutes: Route[] = flags.genesis
? [ ? [
{ {
path: Routes.GENESIS, path: Routes.GENESIS,
name: t('Genesis'), handle: {
text: t('Genesis Parameters'), name: t('Genesis'),
text: t('Genesis Parameters'),
breadcrumb: () => (
<Link to={Routes.GENESIS}>{t('Genesis Parameters')}</Link>
),
},
element: <Genesis />, element: <Genesis />,
}, },
] ]
@ -86,8 +137,13 @@ const governanceRoutes: Route[] = flags.governance
? [ ? [
{ {
path: Routes.GOVERNANCE, path: Routes.GOVERNANCE,
name: t('Governance proposals'), handle: {
text: t('Governance Proposals'), name: t('Governance proposals'),
text: t('Governance Proposals'),
breadcrumb: () => (
<Link to={Routes.GOVERNANCE}>{t('Governance Proposals')}</Link>
),
},
element: <Proposals />, element: <Proposals />,
}, },
] ]
@ -97,8 +153,11 @@ const marketsRoutes: Route[] = flags.markets
? [ ? [
{ {
path: Routes.MARKETS, path: Routes.MARKETS,
name: t('Markets'), handle: {
text: t('Markets'), name: t('Markets'),
text: t('Markets'),
breadcrumb: () => <Link to={Routes.MARKETS}>{t('Markets')}</Link>,
},
children: [ children: [
{ {
index: true, index: true,
@ -107,6 +166,11 @@ const marketsRoutes: Route[] = flags.markets
{ {
path: ':marketId', path: ':marketId',
element: <MarketPage />, element: <MarketPage />,
handle: {
breadcrumb: (params: Params<string>) => (
<MarketLink id={params.marketId as string} />
),
},
}, },
], ],
}, },
@ -117,8 +181,15 @@ const networkParametersRoutes: Route[] = flags.networkParameters
? [ ? [
{ {
path: Routes.NETWORK_PARAMETERS, path: Routes.NETWORK_PARAMETERS,
name: t('NetworkParameters'), handle: {
text: t('Network Parameters'), name: t('NetworkParameters'),
text: t('Network Parameters'),
breadcrumb: () => (
<Link to={Routes.NETWORK_PARAMETERS}>
{t('Network Parameters')}
</Link>
),
},
element: <NetworkParameters />, element: <NetworkParameters />,
}, },
] ]
@ -128,80 +199,133 @@ const validators: Route[] = flags.validators
? [ ? [
{ {
path: Routes.VALIDATORS, path: Routes.VALIDATORS,
name: t('Validators'), handle: {
text: t('Validators'), name: t('Validators'),
text: t('Validators'),
breadcrumb: () => (
<Link to={Routes.VALIDATORS}>{t('Validators')}</Link>
),
},
element: <ValidatorsPage />, element: <ValidatorsPage />,
}, },
] ]
: []; : [];
const routerConfig: Route[] = [ const linkTo = (...segments: (string | undefined)[]) =>
compact(segments).join('/');
export const routerConfig: Route[] = [
{ {
path: Routes.HOME, path: Routes.HOME,
name: t('Home'), element: <Layout />,
text: t('Home'), handle: {
element: <Home />, name: t('Home'),
index: true, text: t('Home'),
}, breadcrumb: () => <Link to={Routes.HOME}>{t('Home')}</Link>,
{ },
path: Routes.TX, errorElement: <ErrorBoundary />,
name: t('Txs'),
text: t('Transactions'),
element: <Txs />,
children: [
{
path: 'pending',
element: <PendingTxs />,
},
{
path: ':txHash',
element: <Tx />,
},
{
index: true,
element: <TxsList />,
},
],
},
{
path: Routes.BLOCKS,
name: t('Blocks'),
text: t('Blocks'),
element: <BlockPage />,
children: [ children: [
{ {
index: true, index: true,
element: <Blocks />, element: <Home />,
}, },
{ {
path: ':block', path: Routes.TX,
element: <Block />, handle: {
name: t('Txs'),
text: t('Transactions'),
breadcrumb: () => <Link to={Routes.TX}>{t('Transactions')}</Link>,
},
children: [
{
path: 'pending',
element: <PendingTxs />,
handle: {
breadcrumb: () => (
<Link to={linkTo(Routes.TX, 'pending')}>
{t('Pending transactions')}
</Link>
),
},
},
{
path: ':txHash',
element: <Tx />,
handle: {
breadcrumb: (params: Params<string>) => (
<Link to={linkTo(Routes.TX, params.txHash)}>
{truncateMiddle(remove0x(params.txHash as string))}
</Link>
),
},
},
{
index: true,
element: <TxsList />,
},
],
}, },
{
path: Routes.BLOCKS,
handle: {
name: t('Blocks'),
text: t('Blocks'),
breadcrumb: () => <Link to={Routes.BLOCKS}>{t('Blocks')}</Link>,
},
element: <BlockPage />,
children: [
{
index: true,
element: <Blocks />,
},
{
path: ':block',
element: <Block />,
handle: {
breadcrumb: (params: Params<string>) => (
<Link to={linkTo(Routes.BLOCKS, params.block)}>
{params.block}
</Link>
),
},
},
],
},
{
path: Routes.ORACLES,
handle: {
name: t('Oracles'),
text: t('Oracles'),
breadcrumb: () => <Link to={Routes.ORACLES}>{t('Oracles')}</Link>,
},
element: <OraclePage />,
children: [
{
index: true,
element: <Oracles />,
},
{
path: ':id',
element: <Oracle />,
handle: {
breadcrumb: (params: Params<string>) => (
<Link to={linkTo(Routes.ORACLES, params.id)}>
{truncateMiddle(params.id as string)}
</Link>
),
},
},
],
},
...partiesRoutes,
...assetsRoutes,
...genesisRoutes,
...governanceRoutes,
...marketsRoutes,
...networkParametersRoutes,
...validators,
], ],
}, },
{
path: Routes.ORACLES,
name: t('Oracles'),
text: t('Oracles'),
element: <OraclePage />,
children: [
{
index: true,
element: <Oracles />,
},
{
path: ':id',
element: <Oracle />,
},
],
},
...partiesRoutes,
...assetsRoutes,
...genesisRoutes,
...governanceRoutes,
...marketsRoutes,
...networkParametersRoutes,
...validators,
]; ];
export default routerConfig; export const router = createBrowserRouter(routerConfig);

View File

@ -1,5 +1,4 @@
import React from 'react'; import { useParams } from 'react-router-dom';
import { Link, useParams } from 'react-router-dom';
import { remove0x } from '@vegaprotocol/utils'; import { remove0x } from '@vegaprotocol/utils';
import { useFetch } from '@vegaprotocol/react-helpers'; import { useFetch } from '@vegaprotocol/react-helpers';
import { DATA_SOURCES } from '../../../config'; import { DATA_SOURCES } from '../../../config';
@ -8,9 +7,6 @@ import { TxDetails } from './tx-details';
import type { BlockExplorerTransaction } from '../../../routes/types/block-explorer-response'; import type { BlockExplorerTransaction } from '../../../routes/types/block-explorer-response';
import { toNonHex } from '../../../components/search/detect-search'; import { toNonHex } from '../../../components/search/detect-search';
import { PageHeader } from '../../../components/page-header'; import { PageHeader } from '../../../components/page-header';
import { Routes } from '../../../routes/route-names';
import { IconNames } from '@blueprintjs/icons';
import { Icon } from '@vegaprotocol/ui-toolkit';
import { useDocumentTitle } from '../../../hooks/use-document-title'; import { useDocumentTitle } from '../../../hooks/use-document-title';
const Tx = () => { const Tx = () => {
@ -35,17 +31,6 @@ const Tx = () => {
return ( return (
<section> <section>
<Link
className="font-normal underline underline-offset-4 block mb-5"
to={`/${Routes.TX}`}
>
<Icon
className="text-vega-light-150 dark:text-vega-light-150"
name={IconNames.CHEVRON_LEFT}
/>
All Transactions
</Link>
<PageHeader <PageHeader
title="transaction" title="transaction"
truncateStart={5} truncateStart={5}

View File

@ -1,5 +1,4 @@
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import './styles.css'; import './styles.css';
import App from './app/app'; import App from './app/app';
@ -10,8 +9,6 @@ const root = rootElement && createRoot(rootElement);
root?.render( root?.render(
<StrictMode> <StrictMode>
<BrowserRouter> <App />
<App />
</BrowserRouter>
</StrictMode> </StrictMode>
); );

View File

@ -1 +0,0 @@
export { PromotedStatsItem } from './promoted-stats-item';

View File

@ -1,36 +0,0 @@
import { Callout, Indicator, Intent, Tooltip } from '@vegaprotocol/ui-toolkit';
import type { StatFields } from '../../config/types';
import { defaultFieldFormatter } from '../table-row';
import { useMemo } from 'react';
export const PromotedStatsItem = ({
title,
formatter,
goodThreshold,
value,
description,
...props
}: StatFields) => {
const variant = useMemo(
() =>
goodThreshold
? goodThreshold(value)
? Intent.Success
: Intent.Danger
: Intent.Primary,
[goodThreshold, value]
);
return (
<Tooltip description={description} align="start">
<Callout>
<div className="uppercase text-sm">
<Indicator variant={variant} />
<span data-testid="stats-title">{title}</span>
</div>
<div data-testid="stats-value" className="mt-2 text-2xl">
{formatter ? formatter(value) : defaultFieldFormatter(value)}
</div>
</Callout>
</Tooltip>
);
};

View File

@ -1 +0,0 @@
export { PromotedStats } from './promoted-stats';

View File

@ -1,13 +0,0 @@
import React from 'react';
interface PromotedStatsProps {
children: React.ReactNode;
}
export const PromotedStats = ({ children }: PromotedStatsProps) => {
return (
<div className="grid promoted-stats content-start gap-4 mb-24">
{children}
</div>
);
};

View File

@ -1,107 +1,89 @@
import { useEffect } from 'react';
import classnames from 'classnames';
import { useEnvironment } from '@vegaprotocol/environment'; import { useEnvironment } from '@vegaprotocol/environment';
import { statsFields } from '../../config/stats-fields'; import type { Statistics, NodeData } from '../../config/stats-fields';
import type { import { fieldsDefinition } from '../../config/stats-fields';
Stats as IStats,
StructuredStats as IStructuredStats,
} from '../../config/types';
import { Table } from '../table';
import { TableRow } from '../table-row';
import { PromotedStats } from '../promoted-stats';
import { PromotedStatsItem } from '../promoted-stats-item';
import { useStatsQuery } from './__generated__/Stats'; import { useStatsQuery } from './__generated__/Stats';
import type { StatsQuery } from './__generated__/Stats'; import { Icon, Tooltip } from '@vegaprotocol/ui-toolkit';
import classNames from 'classnames';
import { useEffect } from 'react';
interface StatsManagerProps { interface StatsManagerProps {
className?: string; className?: string;
} }
const compileData = (data?: StatsQuery) => {
const { nodeData, statistics } = data || {};
const returned = { ...nodeData, ...statistics };
// Loop through the stats fields config, grabbing values from the fetched
// data and building a set of promoted and standard table entries.
return Object.entries(statsFields).reduce(
(acc, [key, value]) => {
const statKey = key as keyof IStats;
const statData = returned[statKey];
value.forEach((x) => {
const stat = {
...x,
value: statData || '-',
};
stat.promoted ? acc.promoted.push(stat) : acc.table.push(stat);
});
return acc;
},
{ promoted: [], table: [] } as IStructuredStats
);
};
export const StatsManager = ({ className }: StatsManagerProps) => { export const StatsManager = ({ className }: StatsManagerProps) => {
const { VEGA_ENV } = useEnvironment(); const { VEGA_ENV } = useEnvironment();
const { data, error, startPolling, stopPolling } = useStatsQuery(); const { data, startPolling, stopPolling } = useStatsQuery();
useEffect(() => { useEffect(() => {
startPolling(1000); startPolling(500);
return () => stopPolling(); return () => stopPolling();
}); }, [startPolling, stopPolling]);
const displayData = compileData(data); const getValue = (field: keyof NodeData | keyof Statistics) =>
['stakedTotal', 'totalNodes', 'inactiveNodes'].includes(field)
? data?.nodeData?.[field as keyof NodeData]
: data?.statistics?.[field as keyof Statistics];
const classes = classnames( const panels = fieldsDefinition.map(
className, ({ field, title, description, formatter, goodThreshold }) => ({
'stats-grid w-full self-start justify-self-center' field,
title,
description,
value: formatter ? formatter(getValue(field)) : getValue(field),
good:
goodThreshold && getValue(field)
? goodThreshold(getValue(field))
: undefined,
})
); );
return ( return (
<div className={classes}> <div
<h3 className={classNames(
data-testid="stats-environment" 'grid grid-cols-2 md:grid-cols-3 gap-3 w-full self-start justify-self-center',
className="font-alpha calt uppercase text-2xl pb-8 col-span-full" className
> )}
{(error && `/ ${error}`) || >
(data ? `/ ${VEGA_ENV}` : '/ Connecting...')} {panels.map(({ field, title, description, value, good }, i) => (
</h3> <div
key={i}
{displayData?.promoted ? ( className={classNames(
<PromotedStats> 'border rounded p-2 relative border-vega-light-200 dark:border-vega-dark-200',
{displayData.promoted.map((stat, i) => { {
return ( 'col-span-2': field === 'chainId' || field === 'status',
<PromotedStatsItem },
title={stat.title} {
value={stat.value || '-'} 'bg-transparent border-vega-light-200 dark:border-vega-dark-200':
formatter={stat.formatter} good === undefined,
goodThreshold={stat.goodThreshold} 'bg-vega-pink-300 dark:bg-vega-pink-700 border-vega-pink-500 dark:border-vega-pink-500':
description={stat.description} good !== undefined && !good,
key={i} 'bg-vega-green-300 dark:bg-vega-green-700 border-vega-green-500 dark:border-vega-green-500':
/> good !== undefined && good,
); }
})} )}
</PromotedStats> >
) : null} <div className="uppercase flex items-center gap-2 text-xs font-alpha calt">
<div
<Table> className={classNames('w-2 h-2 rounded-full', {
{displayData?.table 'bg-vega-light-150 dark:bg-vega-dark-150': good === undefined,
? displayData.table.map((stat, i) => { 'bg-vega-pink dark:bg-vega-pink': good !== undefined && !good,
return ( 'bg-vega-green dark:bg-vega-green': good !== undefined && good,
<TableRow })}
title={stat.title} ></div>
value={stat.value || '-'} <div data-testid="stats-title">{title}</div>
formatter={stat.formatter} {description && (
goodThreshold={stat.goodThreshold} <Tooltip description={description} align="center">
description={stat.description} <div className="absolute top-1 right-2 text-vega-light-200 dark:text-vega-dark-200 cursor-help">
key={i} <Icon name="info-sign" size={3} />
/> </div>
); </Tooltip>
}) )}
: null} </div>
</Table> <div data-testid="stats-value" className="font-mono text-xl pt-2">
{value} {field === 'status' && `(${VEGA_ENV})`}
</div>
</div>
))}
</div> </div>
); );
}; };

View File

@ -1 +0,0 @@
export { TableRow, defaultFieldFormatter } from './table-row';

View File

@ -1,41 +0,0 @@
import { Tooltip } from '@vegaprotocol/ui-toolkit';
import type { StatFields } from '../../config/types';
import { useMemo } from 'react';
import { Indicator, Intent } from '@vegaprotocol/ui-toolkit';
export const defaultFieldFormatter = (field: unknown) =>
field === undefined ? 'no data' : field;
export const TableRow = ({
title,
formatter,
goodThreshold,
value,
description,
...props
}: StatFields) => {
const variant = useMemo(
() =>
goodThreshold
? goodThreshold(value)
? Intent.Success
: Intent.Danger
: Intent.None,
[goodThreshold, value]
);
return (
<Tooltip description={description} align="start">
<tr className="border border-black dark:border-white">
<td data-testid="stats-title" className="py-2 px-4">
{title}
</td>
<td data-testid="stats-value" className="py-2 px-4 text-right">
{formatter ? formatter(value) : defaultFieldFormatter(value)}
</td>
<td className="py-2 px-4">
<Indicator variant={variant} />
</td>
</tr>
</Tooltip>
);
};

View File

@ -1 +0,0 @@
export { Table } from './table';

View File

@ -1,13 +0,0 @@
import React from 'react';
interface TableProps {
children: React.ReactNode;
}
export const Table = ({ children }: TableProps) => {
return (
<table>
<tbody>{children}</tbody>
</table>
);
};

View File

@ -5,204 +5,231 @@ import {
isValidDate, isValidDate,
} from '@vegaprotocol/utils'; } from '@vegaprotocol/utils';
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import type { Stats, StatFields } from './types'; import type * as Schema from '@vegaprotocol/types';
import type { StatsQuery } from '../components/stats-manager/__generated__/Stats';
// Stats fields config. Keys will correspond to graphql queries when used, and values export type NodeData = Pick<
// contain the associated data and methods we need to render. A single query Schema.NodeData,
// can be rendered in multiple ways (see 'upTime'). 'stakedTotal' | 'totalNodes' | 'inactiveNodes'
export const statsFields: { [key in keyof Stats]: StatFields[] } = { >;
status: [
{
title: t('Status'),
formatter: (status: string) => {
if (!status) {
return;
}
const i = status.lastIndexOf('_'); export type Statistics = Omit<StatsQuery['statistics'], '__typename'>;
if (i === -1) {
return status; // eslint-disable-next-line
} else { export type FieldValue = any;
return status.substring(i + 1); export type GoodThreshold = (...args: FieldValue[]) => boolean;
}
}, export interface Stats {
goodThreshold: (status: string) => field: keyof NodeData | keyof Statistics;
status === 'CONNECTED' || status === 'CHAIN_STATUS_CONNECTED', title: string;
promoted: true, goodThreshold?: GoodThreshold;
description: t( // eslint-disable-next-line
'Status is either connected, replaying, unspecified or disconnected' formatter?: (arg0: FieldValue) => any;
), promoted?: boolean;
}, value?: FieldValue;
], description?: string;
blockHeight: [ }
{
title: t('Height'), const STATUS: Stats = {
goodThreshold: (height: number) => height >= 60, field: 'status',
promoted: true, title: t('Status'),
description: t('Block height'), formatter: (status: string) => {
}, if (!status) {
], return;
totalNodes: [ }
{
title: t('Total nodes'), const i = status.lastIndexOf('_');
description: t('The total number of nodes registered on the network'), if (i === -1) {
}, return status;
], } else {
inactiveNodes: [], return status.substring(i + 1);
stakedTotal: [ }
{ },
title: t('Total staked'), goodThreshold: (status: string) =>
formatter: (total: string) => { status === 'CONNECTED' || status === 'CHAIN_STATUS_CONNECTED',
return addDecimalsFormatNumber(total, 18, 2); description: t(
}, 'Status is either connected, replaying, unspecified or disconnected'
description: t('Sum of VEGA associated with a Vega key'), ),
},
],
backlogLength: [
{
title: t('Backlog'),
goodThreshold: (length: number, blockDuration: number) => {
return (
length < 1000 || (length >= 1000 && blockDuration / 1000000000 <= 1)
);
},
description: t('Number of transactions waiting to be processed'),
},
],
tradesPerSecond: [
{
title: t('Trades / second'),
goodThreshold: (trades: number) => trades >= 2,
description: t('Number of trades processed in the last second'),
},
],
averageOrdersPerBlock: [
{
title: t('Orders / block'),
goodThreshold: (orders: number) => orders >= 2,
description: t(
'Number of new orders processed in the last block. All pegged orders and liquidity provisions count as a single order'
),
},
],
ordersPerSecond: [
{
title: t('Orders / second'),
goodThreshold: (orders: number) => orders >= 2,
description: t(
'Number of orders processed in the last second. All pegged orders and liquidity provisions count as a single order'
),
},
],
txPerBlock: [
{
title: t('Transactions / block'),
goodThreshold: (tx: number) => tx > 2,
description: t('Number of transactions processed in the last block'),
},
],
blockDuration: [
{
title: t('Block time'),
formatter: (duration: string) => {
const dp = 3;
if (duration?.includes('ms')) {
return (parseFloat(duration) / 1000).toFixed(dp).toString();
} else if (duration?.includes('s')) {
return parseFloat(duration).toFixed(dp).toString();
}
return duration
? (Number(duration) / 1000000000).toFixed(dp)
: undefined;
},
goodThreshold: (blockDuration: string) => {
if (blockDuration?.includes('ms')) {
// we only get ms from the api if duration is less than 1s, so this
// automatically passes
return true;
} else if (blockDuration?.includes('s')) {
return parseFloat(blockDuration) <= 2;
} else {
return (
Number(blockDuration) > 0 && Number(blockDuration) <= 2000000000
);
}
},
description: t('Seconds between the two most recent blocks'),
},
],
vegaTime: [
{
title: t('Time'),
formatter: (time: Date) => {
if (!time) {
return;
}
const date = new Date(time);
if (!isValidDate(date)) {
return;
}
return getTimeFormat().format(date);
},
goodThreshold: (time: Date) => {
const diff = new Date().getTime() - new Date(time).getTime();
return diff > 0 && diff < 5000;
},
description: t('The time on the blockchain'),
},
],
appVersion: [
{
title: t('App'),
description: t('Vega node software version on this node'),
},
],
chainVersion: [
{
title: t('Tendermint'),
description: t('Tendermint software version on this node'),
},
],
genesisTime: [
{
title: t('Uptime'),
formatter: (t: string) => {
if (!t) {
return '-';
}
const date = new Date(t);
if (!isValidDate(date)) {
return '-';
}
const secSinceStart = (new Date().getTime() - date.getTime()) / 1000;
const days = Math.floor(secSinceStart / 60 / 60 / 24);
const hours = Math.floor((secSinceStart / 60 / 60) % 24);
const mins = Math.floor((secSinceStart / 60) % 60);
const secs = Math.floor(secSinceStart % 60);
return `${days}d ${hours}h ${mins}m ${secs}s`;
},
promoted: true,
description: t('Time since genesis'),
},
{
title: t('Up since'),
formatter: (t: string) => {
if (!t) {
return '-';
}
const date = new Date(t);
if (!isValidDate(date)) {
return '-';
}
return getDateTimeFormat().format(date) || '-';
},
description: t('Genesis'),
},
],
chainId: [
{
title: t('Chain ID'),
description: t('Identifier'),
},
],
}; };
const BLOCK_HEIGHT: Stats = {
field: 'blockHeight',
title: t('Block height'),
goodThreshold: (height: number) => height >= 60,
description: t('Block height'),
};
const TOTAL_NODES: Stats = {
field: 'totalNodes',
title: t('Total nodes'),
description: t('The total number of nodes registered on the network'),
};
const TOTAL_STAKED: Stats = {
field: 'stakedTotal',
title: t('Total staked'),
formatter: (total: string) => {
return addDecimalsFormatNumber(total, 18, 2);
},
description: t('Sum of VEGA associated with a Vega key'),
};
const BACKLOG_LENGTH: Stats = {
field: 'backlogLength',
title: t('Backlog'),
goodThreshold: (length: number, blockDuration: number) => {
return length < 1000 || (length >= 1000 && blockDuration / 1000000000 <= 1);
},
description: t('Number of transactions waiting to be processed'),
};
const TRADES_PER_SECOND: Stats = {
field: 'tradesPerSecond',
title: t('Trades / second'),
goodThreshold: (trades: number) => trades >= 2,
description: t('Number of trades processed in the last second'),
};
const AVERAGE_ORDERS_PER_BLOCK: Stats = {
field: 'averageOrdersPerBlock',
title: t('Orders / block'),
goodThreshold: (orders: number) => orders >= 2,
description: t(
'Number of new orders processed in the last block. All pegged orders and liquidity provisions count as a single order'
),
};
const ORDERS_PER_SECOND: Stats = {
field: 'ordersPerSecond',
title: t('Orders / second'),
goodThreshold: (orders: number) => orders >= 2,
description: t(
'Number of orders processed in the last second. All pegged orders and liquidity provisions count as a single order'
),
};
const TX_PER_BLOCK: Stats = {
field: 'txPerBlock',
title: t('Transactions / block'),
goodThreshold: (tx: number) => tx > 2,
description: t('Number of transactions processed in the last block'),
};
const BLOCK_DURATION: Stats = {
field: 'blockDuration',
title: t('Block time'),
formatter: (duration: string) => {
const dp = 3;
if (duration?.includes('ms')) {
return (parseFloat(duration) / 1000).toFixed(dp).toString();
} else if (duration?.includes('s')) {
return parseFloat(duration).toFixed(dp).toString();
}
return duration ? (Number(duration) / 1000000000).toFixed(dp) : undefined;
},
goodThreshold: (blockDuration: string) => {
if (blockDuration?.includes('ms')) {
// we only get ms from the api if duration is less than 1s, so this
// automatically passes
return true;
} else if (blockDuration?.includes('s')) {
return parseFloat(blockDuration) <= 2;
} else {
return Number(blockDuration) > 0 && Number(blockDuration) <= 2000000000;
}
},
description: t('Seconds between the two most recent blocks'),
};
const VEGA_TIME: Stats = {
field: 'vegaTime',
title: t('Time'),
formatter: (time: Date) => {
if (!time) {
return;
}
const date = new Date(time);
if (!isValidDate(date)) {
return;
}
return getTimeFormat().format(date);
},
goodThreshold: (time: Date) => {
const diff = new Date().getTime() - new Date(time).getTime();
return diff > 0 && diff < 5000;
},
description: t('The time on the blockchain'),
};
const APP_VERSION: Stats = {
field: 'appVersion',
title: t('App'),
description: t('Vega node software version on this node'),
};
const CHAIN_VERSION: Stats = {
field: 'chainVersion',
title: t('Tendermint'),
description: t('Tendermint software version on this node'),
};
const UPTIME: Stats = {
field: 'genesisTime',
title: t('Uptime'),
formatter: (t: string) => {
if (!t) {
return '-';
}
const date = new Date(t);
if (!isValidDate(date)) {
return '-';
}
const secSinceStart = (new Date().getTime() - date.getTime()) / 1000;
const days = Math.floor(secSinceStart / 60 / 60 / 24);
const hours = Math.floor((secSinceStart / 60 / 60) % 24);
const mins = Math.floor((secSinceStart / 60) % 60);
const secs = Math.floor(secSinceStart % 60);
return `${days}d ${hours}h ${mins}m ${secs}s`;
},
description: t('Time since genesis'),
};
const UP_SINCE: Stats = {
field: 'genesisTime',
title: t('Up since'),
formatter: (t: string) => {
if (!t) {
return '-';
}
const date = new Date(t);
if (!isValidDate(date)) {
return '-';
}
return getDateTimeFormat().format(date) || '-';
},
description: t('Genesis'),
};
const CHAIN_ID: Stats = {
field: 'chainId',
title: t('Chain ID'),
description: t('Identifier'),
};
export const fieldsDefinition: Stats[] = [
STATUS,
BLOCK_HEIGHT,
UPTIME,
TOTAL_NODES,
TOTAL_STAKED,
BACKLOG_LENGTH,
TRADES_PER_SECOND,
AVERAGE_ORDERS_PER_BLOCK,
ORDERS_PER_SECOND,
TX_PER_BLOCK,
BLOCK_DURATION,
VEGA_TIME,
APP_VERSION,
CHAIN_VERSION,
UP_SINCE,
CHAIN_ID,
];

View File

@ -1,26 +0,0 @@
import type * as Schema from '@vegaprotocol/types';
import type { StatsQuery } from '../components/stats-manager/__generated__/Stats';
type NodeDataKeys = 'stakedTotal' | 'totalNodes' | 'inactiveNodes';
export type Stats = Pick<Schema.NodeData, NodeDataKeys> &
Omit<StatsQuery['statistics'], '__typename'>;
// eslint-disable-next-line
export type value = any;
export type goodThreshold = (...args: value[]) => boolean;
export interface StatFields {
title: string;
goodThreshold?: goodThreshold;
// eslint-disable-next-line
formatter?: (arg0: value) => any;
promoted?: boolean;
value?: value;
description?: string;
}
export interface StructuredStats {
promoted: StatFields[];
table: StatFields[];
}

View File

@ -1,11 +1,19 @@
export const BackgroundVideo = () => { import classNames from 'classnames';
type BackgroundVideoProps = {
className?: string;
};
export const BackgroundVideo = ({ className }: BackgroundVideoProps) => {
return ( return (
<video <video
autoPlay autoPlay
muted muted
loop loop
playsInline playsInline
className="absolute left-0 top-0 w-full h-full object-cover" className={classNames(
'absolute left-0 top-0 w-full h-full object-cover',
className
)}
poster="https://vega.xyz/poster-image.jpg" poster="https://vega.xyz/poster-image.jpg"
> >
<source <source

View File

@ -0,0 +1,20 @@
import type { HTMLAttributes, ReactNode } from 'react';
import { useMatches } from 'react-router-dom';
import { Breadcrumbs } from './breadcrumbs';
type Breadcrumbable = {
handle: {
breadcrumb: (data: unknown) => ReactNode | string;
};
};
export const BreadcrumbsContainer = (
props: HTMLAttributes<HTMLOListElement>
) => {
const matches = useMatches();
const crumbs = matches
.filter((m) => Boolean((m as Breadcrumbable)?.handle?.breadcrumb))
.map((m) => (m as Breadcrumbable).handle.breadcrumb(m.params));
return <Breadcrumbs elements={crumbs} {...props} />;
};

View File

@ -0,0 +1,34 @@
import { render } from '@testing-library/react';
import { Link, MemoryRouter } from 'react-router-dom';
import { Breadcrumbs } from './breadcrumbs';
describe('Breadcrumbs', () => {
it('does not display breadcrumbs if no elements are provided', () => {
const { queryAllByTestId } = render(
<Breadcrumbs elements={[]} data-testid="crumbs" />
);
expect(queryAllByTestId('crumbs')).toHaveLength(0);
});
it('does display given elements', () => {
const { getByTestId, container } = render(
<MemoryRouter>
<Breadcrumbs
elements={['1', <Link to="/two">2</Link>, '3']}
data-testid="crumbs"
/>
</MemoryRouter>
);
const crumbs = Array.from(
container.querySelectorAll('[data-testid="crumbs"] li')
);
expect(getByTestId('crumbs')).toBeInTheDocument();
expect(crumbs).toHaveLength(3);
expect(crumbs[0].textContent).toBe('1');
expect(crumbs[1].textContent).toBe('2');
expect(crumbs[2].textContent).toBe('3');
});
});

View File

@ -0,0 +1,19 @@
import type { Story, ComponentMeta } from '@storybook/react';
import type { ComponentProps } from 'react';
import { Breadcrumbs } from './breadcrumbs';
export default {
component: Breadcrumbs,
title: 'Breadcrumbs',
} as ComponentMeta<typeof Breadcrumbs>;
const Template: Story = (args) => (
<div className="mb-8">
<Breadcrumbs {...(args as ComponentProps<typeof Breadcrumbs>)} />
</div>
);
export const Default = Template.bind({});
Default.args = {
elements: ['Home', 'Transactions', 'B540A…A68DB'],
};

View File

@ -0,0 +1,35 @@
import classNames from 'classnames';
import type { HTMLAttributes, ReactNode } from 'react';
type BreadcrumbsProps = {
elements: (ReactNode | string)[];
};
export const Breadcrumbs = ({
elements,
className,
...props
}: BreadcrumbsProps & HTMLAttributes<HTMLOListElement>) => {
const crumbs = elements.map((crumb, i) => (
<li
key={i}
className={classNames(
'before:content-["/"] before:pr-2 before:text-vega-light-300 dark:before:text-vega-dark-300',
'overflow-hidden text-ellipsis'
)}
>
{crumb}
</li>
));
return crumbs.length > 0 ? (
<ol
className={classNames(
['flex flex-row flex-wrap gap-2', 'text-sm sm:text-base'],
className
)}
{...props}
>
{crumbs}
</ol>
) : null;
};

View File

@ -0,0 +1,2 @@
export * from './breadcrumbs';
export * from './breadcrumbs-container';

View File

@ -3,6 +3,7 @@ export * from './announcement-banner';
export * from './arrows'; export * from './arrows';
export * from './async-renderer'; export * from './async-renderer';
export * from './background-video'; export * from './background-video';
export * from './breadcrumbs';
export * from './button'; export * from './button';
export * from './callout'; export * from './callout';
export * from './checkbox'; export * from './checkbox';

View File

@ -70,7 +70,7 @@
"react-i18next": "^11.11.4", "react-i18next": "^11.11.4",
"react-intersection-observer": "^9.2.2", "react-intersection-observer": "^9.2.2",
"react-markdown": "^8.0.5", "react-markdown": "^8.0.5",
"react-router-dom": "6.3.0", "react-router-dom": "^6.9.0",
"react-syntax-highlighter": "^15.4.5", "react-syntax-highlighter": "^15.4.5",
"react-use-websocket": "^3.0.0", "react-use-websocket": "^3.0.0",
"react-virtualized-auto-sizer": "^1.0.6", "react-virtualized-auto-sizer": "^1.0.6",
@ -137,7 +137,7 @@
"@types/react": "18.0.17", "@types/react": "18.0.17",
"@types/react-copy-to-clipboard": "^5.0.2", "@types/react-copy-to-clipboard": "^5.0.2",
"@types/react-dom": "18.0.6", "@types/react-dom": "18.0.6",
"@types/react-router-dom": "5.3.1", "@types/react-router-dom": "^5.3.3",
"@types/react-syntax-highlighter": "^15.5.5", "@types/react-syntax-highlighter": "^15.5.5",
"@types/react-virtualized-auto-sizer": "^1.0.1", "@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/react-window": "^1.8.5", "@types/react-window": "^1.8.5",

View File

@ -1491,13 +1491,20 @@
dependencies: dependencies:
regenerator-runtime "^0.13.10" regenerator-runtime "^0.13.10"
"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.13.10", "@babel/runtime@^7.14.5", "@babel/runtime@^7.14.8", "@babel/runtime@^7.18.9", "@babel/runtime@^7.19.0", "@babel/runtime@^7.3.1", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.9.2": "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.13.10", "@babel/runtime@^7.14.5", "@babel/runtime@^7.14.8", "@babel/runtime@^7.18.9", "@babel/runtime@^7.19.0", "@babel/runtime@^7.3.1", "@babel/runtime@^7.7.2", "@babel/runtime@^7.9.2":
version "7.19.4" version "7.19.4"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.19.4.tgz#a42f814502ee467d55b38dd1c256f53a7b885c78" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.19.4.tgz#a42f814502ee467d55b38dd1c256f53a7b885c78"
integrity sha512-EXpLCrk55f+cYqmHsSR+yD/0gAIMxxA9QK9lnQWzhMCvt+YmoBN7Zx94s++Kv0+unHk39vxNO8t+CMA2WSS3wA== integrity sha512-EXpLCrk55f+cYqmHsSR+yD/0gAIMxxA9QK9lnQWzhMCvt+YmoBN7Zx94s++Kv0+unHk39vxNO8t+CMA2WSS3wA==
dependencies: dependencies:
regenerator-runtime "^0.13.4" regenerator-runtime "^0.13.4"
"@babel/runtime@^7.7.6":
version "7.21.0"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.21.0.tgz#5b55c9d394e5fcf304909a8b00c07dc217b56673"
integrity sha512-xwII0//EObnq89Ji5AKYQaRYiW/nZ3llSv29d49IuxPhKbtJoLP+9QUUZ4nVragQVtaVGeZrpB+ZtG/Pdy/POw==
dependencies:
regenerator-runtime "^0.13.11"
"@babel/template@^7.12.13", "@babel/template@^7.12.7", "@babel/template@^7.18.10", "@babel/template@^7.3.3": "@babel/template@^7.12.13", "@babel/template@^7.12.7", "@babel/template@^7.18.10", "@babel/template@^7.3.3":
version "7.18.10" version "7.18.10"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.10.tgz#6f9134835970d1dbf0835c0d100c9f38de0c5e71" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.10.tgz#6f9134835970d1dbf0835c0d100c9f38de0c5e71"
@ -4496,6 +4503,11 @@
dependencies: dependencies:
"@babel/runtime" "^7.13.10" "@babel/runtime" "^7.13.10"
"@remix-run/router@1.4.0":
version "1.4.0"
resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.4.0.tgz#74935d538e4df8893e47831a7aea362f295bcd39"
integrity sha512-BJ9SxXux8zAg991UmT8slpwpsd31K1dHHbD3Ba4VzD+liLQ4WAMSxQp2d2ZPRPfN0jN2NPRowcSSoM7lCaF08Q==
"@repeaterjs/repeater@^3.0.4": "@repeaterjs/repeater@^3.0.4":
version "3.0.4" version "3.0.4"
resolved "https://registry.yarnpkg.com/@repeaterjs/repeater/-/repeater-3.0.4.tgz#a04d63f4d1bf5540a41b01a921c9a7fddc3bd1ca" resolved "https://registry.yarnpkg.com/@repeaterjs/repeater/-/repeater-3.0.4.tgz#a04d63f4d1bf5540a41b01a921c9a7fddc3bd1ca"
@ -6621,13 +6633,6 @@
dependencies: dependencies:
"@types/unist" "*" "@types/unist" "*"
"@types/history@*":
version "5.0.0"
resolved "https://registry.yarnpkg.com/@types/history/-/history-5.0.0.tgz#29f919f0c8e302763798118f45b19cab4a886f14"
integrity sha512-hy8b7Y1J8OGe6LbAjj3xniQrj3v6lsivCcrmf4TzSgPzLkhIeKgc5IZnT7ReIqmEuodjfO8EYAuoFvIrHi/+jQ==
dependencies:
history "*"
"@types/history@^4.7.11": "@types/history@^4.7.11":
version "4.7.11" version "4.7.11"
resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.11.tgz#56588b17ae8f50c53983a524fc3cc47437969d64" resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.11.tgz#56588b17ae8f50c53983a524fc3cc47437969d64"
@ -6886,12 +6891,12 @@
dependencies: dependencies:
"@types/react" "*" "@types/react" "*"
"@types/react-router-dom@5.3.1": "@types/react-router-dom@^5.3.3":
version "5.3.1" version "5.3.3"
resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-5.3.1.tgz#76700ccce6529413ec723024b71f01fc77a4a980" resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-5.3.3.tgz#e9d6b4a66fcdbd651a5f106c2656a30088cc1e83"
integrity sha512-UvyRy73318QI83haXlaMwmklHHzV9hjl3u71MmM6wYNu0hOVk9NLTa0vGukf8zXUqnwz4O06ig876YSPpeK28A== integrity sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==
dependencies: dependencies:
"@types/history" "*" "@types/history" "^4.7.11"
"@types/react" "*" "@types/react" "*"
"@types/react-router" "*" "@types/react-router" "*"
@ -13907,13 +13912,6 @@ highlight.js@^10.4.1, highlight.js@~10.7.0:
resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.7.3.tgz#697272e3991356e40c3cac566a74eef681756531" resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.7.3.tgz#697272e3991356e40c3cac566a74eef681756531"
integrity sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A== integrity sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==
history@*, history@^5.2.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/history/-/history-5.3.0.tgz#1548abaa245ba47992f063a0783db91ef201c73b"
integrity sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==
dependencies:
"@babel/runtime" "^7.7.6"
hmac-drbg@^1.0.1: hmac-drbg@^1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1"
@ -19437,20 +19435,20 @@ react-resize-detector@^7.1.2:
dependencies: dependencies:
lodash "^4.17.21" lodash "^4.17.21"
react-router-dom@6.3.0: react-router-dom@^6.9.0:
version "6.3.0" version "6.9.0"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.3.0.tgz#a0216da813454e521905b5fa55e0e5176123f43d" resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.9.0.tgz#dd8b4e461453bd4cad2e6404493d1a5b4bfea758"
integrity sha512-uaJj7LKytRxZNQV8+RbzJWnJ8K2nPsOOEuX7aQstlMZKQT0164C+X2w6bnkqU3sjtLvpd5ojrezAyfZ1+0sStw== integrity sha512-/seUAPY01VAuwkGyVBPCn1OXfVbaWGGu4QN9uj0kCPcTyNYgL1ldZpxZUpRU7BLheKQI4Twtl/OW2nHRF1u26Q==
dependencies: dependencies:
history "^5.2.0" "@remix-run/router" "1.4.0"
react-router "6.3.0" react-router "6.9.0"
react-router@6.3.0: react-router@6.9.0:
version "6.3.0" version "6.9.0"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.3.0.tgz#3970cc64b4cb4eae0c1ea5203a80334fdd175557" resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.9.0.tgz#0f503d9becbc62d9e4ddc0f9bd4026e0fd29fbf5"
integrity sha512-7Wh1DzVQ+tlFjkeo+ujvjSqSJmkt1+8JO+T5xklPlgrh70y7ogx75ODRW0ThWhY7S+6yEDks8TYrtQe/aoboBQ== integrity sha512-51lKevGNUHrt6kLuX3e/ihrXoXCa9ixY/nVWRLlob4r/l0f45x3SzBvYJe3ctleLUQQ5fVa4RGgJOTH7D9Umhw==
dependencies: dependencies:
history "^5.2.0" "@remix-run/router" "1.4.0"
react-shallow-renderer@^16.15.0: react-shallow-renderer@^16.15.0:
version "16.15.0" version "16.15.0"
@ -19734,11 +19732,16 @@ regenerator-runtime@0.13.7:
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz#cac2dacc8a1ea675feaabaeb8ae833898ae46f55" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz#cac2dacc8a1ea675feaabaeb8ae833898ae46f55"
integrity sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew== integrity sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==
regenerator-runtime@^0.13.10, regenerator-runtime@^0.13.4, regenerator-runtime@^0.13.7: regenerator-runtime@^0.13.10, regenerator-runtime@^0.13.7:
version "0.13.10" version "0.13.10"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.10.tgz#ed07b19616bcbec5da6274ebc75ae95634bfc2ee" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.10.tgz#ed07b19616bcbec5da6274ebc75ae95634bfc2ee"
integrity sha512-KepLsg4dU12hryUO7bp/axHAKvwGOCV0sGloQtpagJ12ai+ojVDqkeGSiRX1zlq+kjIMZ1t7gpze+26QqtdGqw== integrity sha512-KepLsg4dU12hryUO7bp/axHAKvwGOCV0sGloQtpagJ12ai+ojVDqkeGSiRX1zlq+kjIMZ1t7gpze+26QqtdGqw==
regenerator-runtime@^0.13.11, regenerator-runtime@^0.13.4:
version "0.13.11"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9"
integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==
regenerator-transform@^0.15.0: regenerator-transform@^0.15.0:
version "0.15.0" version "0.15.0"
resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.15.0.tgz#cbd9ead5d77fae1a48d957cf889ad0586adb6537" resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.15.0.tgz#cbd9ead5d77fae1a48d957cf889ad0586adb6537"