From a3fcd6b7dc428f684ed4b9382afc8c3f88ebdde7 Mon Sep 17 00:00:00 2001 From: Art Date: Mon, 27 Feb 2023 10:17:23 +0100 Subject: [PATCH] feat(explorer): validators page (#2982) --- apps/explorer-e2e/src/integration/asset.cy.js | 6 +- .../src/integration/validator.cy.js | 300 +--------------- .../src/app/components/page-helpers/index.ts | 1 + .../components/page-helpers/page-actions.tsx | 10 + .../components/page-helpers/page-title.tsx | 45 +++ .../app/hooks/use-tendermint-validators.ts | 55 +++ .../src/app/routes/assets/asset-page.tsx | 21 +- .../src/app/routes/markets/market-page.tsx | 23 +- .../explorer/src/app/routes/router-config.tsx | 4 +- .../src/app/routes/validators/index.tsx | 48 +-- .../tendermint-validator-response.tsx | 24 -- .../app/routes/validators/validators-page.tsx | 340 ++++++++++++++++++ libs/assets/src/lib/asset-details-table.tsx | 16 +- libs/cypress/src/index.ts | 2 + libs/cypress/src/lib/commands/get-assets.ts | 11 +- libs/cypress/src/lib/commands/get-nodes.ts | 55 +++ .../vega-wallet-receive-fauceted-asset.ts | 14 +- libs/cypress/src/lib/utils.ts | 8 + .../src/components/contract-address-link.tsx | 13 + libs/environment/src/components/index.ts | 1 + libs/environment/src/hooks/use-links.ts | 1 + libs/react-helpers/src/lib/flag-emoji.spec.ts | 267 ++++++++++++++ libs/react-helpers/src/lib/flag-emoji.ts | 24 ++ libs/react-helpers/src/lib/index.ts | 1 + .../key-value-table/key-value-table.tsx | 8 +- 25 files changed, 878 insertions(+), 420 deletions(-) create mode 100644 apps/explorer/src/app/components/page-helpers/index.ts create mode 100644 apps/explorer/src/app/components/page-helpers/page-actions.tsx create mode 100644 apps/explorer/src/app/components/page-helpers/page-title.tsx create mode 100644 apps/explorer/src/app/hooks/use-tendermint-validators.ts delete mode 100644 apps/explorer/src/app/routes/validators/tendermint-validator-response.tsx create mode 100644 apps/explorer/src/app/routes/validators/validators-page.tsx create mode 100644 libs/cypress/src/lib/commands/get-nodes.ts create mode 100644 libs/environment/src/components/contract-address-link.tsx create mode 100644 libs/react-helpers/src/lib/flag-emoji.spec.ts create mode 100644 libs/react-helpers/src/lib/flag-emoji.ts diff --git a/apps/explorer-e2e/src/integration/asset.cy.js b/apps/explorer-e2e/src/integration/asset.cy.js index 03b7bba78..aa17c4642 100644 --- a/apps/explorer-e2e/src/integration/asset.cy.js +++ b/apps/explorer-e2e/src/integration/asset.cy.js @@ -8,7 +8,7 @@ context('Asset page', { tags: '@regression' }, () => { it('should be able to see full assets list', () => { cy.getAssets().then((assets) => { - Object.values(assets).forEach((asset) => { + assets.forEach((asset) => { cy.get(`[row-id="${asset.id}"]`).should('be.visible'); }); }); @@ -25,7 +25,7 @@ context('Asset page', { tags: '@regression' }, () => { }); cy.getAssets().then((assets) => { - Object.values(assets).forEach((asset) => { + assets.forEach((asset) => { cy.get(`[row-id="${asset.id}"]`).should('be.visible'); }); }); @@ -33,7 +33,7 @@ context('Asset page', { tags: '@regression' }, () => { it('should open details page when clicked on "View details"', () => { cy.getAssets().then((assets) => { - Object.values(assets).forEach((asset) => { + assets.forEach((asset) => { cy.get(`[row-id="${asset.id}"] [col-id="actions"] button`) .eq(0) .should('contain.text', 'View details'); diff --git a/apps/explorer-e2e/src/integration/validator.cy.js b/apps/explorer-e2e/src/integration/validator.cy.js index 4b0a44266..62ab2c65a 100644 --- a/apps/explorer-e2e/src/integration/validator.cy.js +++ b/apps/explorer-e2e/src/integration/validator.cy.js @@ -1,303 +1,15 @@ context('Validator page', { tags: '@smoke' }, function () { - const validatorMenuHeading = 'a[href="/validators"]'; - const tendermintDataHeader = '[data-testid="tendermint-header"]'; - const vegaDataHeader = '[data-testid="vega-header"]'; - const jsonSection = '.language-json'; - before('Visit validators page and obtain data', function () { - cy.visit('/'); - cy.get(validatorMenuHeading).click(); - cy.get_validators().as('validators'); - cy.get_nodes().as('nodes'); + cy.visit('/validators'); }); describe('Verify elements on page', function () { - before('Ensure at least two validators are present', function () { - assert.isAtLeast( - this.validators.length, - 2, - 'Ensuring at least two validators exist' - ); - }); - - it('should be able to see validator page sections', function () { - cy.get(vegaDataHeader) - .contains('Vega data') - .and('is.visible') - .next() - .within(() => { - cy.get(jsonSection).should('not.be.empty'); - }); - - cy.get(tendermintDataHeader) - .contains('Tendermint data') - .and('is.visible') - .next() - .within(() => { - cy.get(jsonSection).should('not.be.empty'); - }); - }); - - it('should be able to see relevant validator information in tendermint section', function () { - cy.get(tendermintDataHeader) - .contains('Tendermint data') - .next() - .within(() => { - cy.get(jsonSection) - .invoke('text') - .convert_string_json_to_js_object() - .then((validatorsInJson) => { - this.validators.forEach((validator, index) => { - const validatorInJson = - validatorsInJson.result.validators[index]; - - assert.equal( - validatorInJson.address, - validator.address, - `Checking that validator address shown in json matches system data` - ); - cy.contains(validator.address).should('be.visible'); - - assert.equal( - validatorInJson.pub_key.type, - validator.pub_key.type, - `Checking that validator public key type shown in json matches system data` - ); - cy.contains(validator.pub_key.type).should('be.visible'); - - assert.equal( - validatorInJson.pub_key.value, - validator.pub_key.value, - `Checking that validator public key value shown in json matches system data` - ); - cy.contains(validator.pub_key.value).should('be.visible'); - - assert.equal( - validatorInJson.voting_power, - validator.voting_power, - `Checking that validator voting power in json matches system data` - ); - cy.contains(validator.voting_power).should('be.visible'); - - // Proposer priority can change frequently mid test - // Therefore only checking the field name is present. - cy.contains('proposer_priority').should('be.visible'); - }); - }); - }); - }); - - // Test disabled 2022/11/15 during the 0.62.1 upgrade. The JSON structure changed, and - // this test failed. Rather than fix it, it will be replaced when the validator view displays - // something useful rather than just dumping out the JSON. - xit('should be able to see relevant node information in vega data section', function () { - cy.get(vegaDataHeader) - .contains('Vega data') - .next() - .within(() => { - cy.get(jsonSection) - .invoke('text') - .convert_string_json_to_js_object() - .then((nodesInJson) => { - this.nodes.forEach((node, index) => { - const nodeInJson = nodesInJson.edges[index].node; - - // Vegacapsule shows no info or null for following fields: - // name, infoURL, avatarUrl, location, epoch data - // Therefore, these values remain unchecked. - - assert.equal( - nodeInJson.__typename, - node.__typename, - `Checking that node __typename shown in json matches system data` - ); - cy.contains(node.__typename).should('be.visible'); - - assert.equal( - nodeInJson.id, - node.id, - `Checking that node id shown in json matches system data` - ); - cy.contains(node.id).should('be.visible'); - - assert.equal( - nodeInJson.pubkey, - node.pubkey, - `Checking that node pubkey shown in json matches system data` - ); - cy.contains(node.pubkey).should('be.visible'); - - assert.equal( - nodeInJson.tmPubkey, - node.tmPubkey, - `Checking that node tmPubkey shown in json matches system data` - ); - cy.contains(node.tmPubkey).should('be.visible'); - - assert.equal( - nodeInJson.ethereumAddress, - node.ethereumAddress, - `Checking that node ethereumAddress shown in json matches system data` - ); - cy.contains(node.ethereumAddress).should('be.visible'); - - assert.equal( - nodeInJson.stakedByOperator, - node.stakedByOperator, - `Checking that node stakedByOperator value shown in json matches system data` - ); - cy.contains(node.stakedByOperator).should('be.visible'); - - assert.equal( - nodeInJson.stakedByDelegates, - node.stakedByDelegates, - `Checking that node stakedByDelegates value shown in json matches system data` - ); - cy.contains(node.stakedByDelegates).should('be.visible'); - - assert.equal( - nodeInJson.stakedTotal, - node.stakedTotal, - `Checking that node stakedTotal shown in json matches system data` - ); - cy.contains(node.stakedTotal).should('be.visible'); - - assert.equal( - nodeInJson.pendingStake, - node.pendingStake, - `Checking that node pendingStake shown in json matches system data` - ); - cy.contains(node.pendingStake).should('be.visible'); - - assert.equal( - nodeInJson.status, - node.status, - `Checking that node status shown in json matches system data` - ); - cy.contains(node.status).should('be.visible'); - }); - }); - }); - }); - - it('should be able to see validator page displayed on mobile', function () { - cy.common_switch_to_mobile_and_click_toggle(); - cy.get(validatorMenuHeading).click(); - cy.get(vegaDataHeader) - .contains('Vega data') - .and('is.visible') - .next() - .within(() => { - cy.get(jsonSection).should('not.be.empty'); - }); - - cy.get(tendermintDataHeader) - .contains('Tendermint data') - .and('is.visible') - .next() - .within(() => { - cy.get(jsonSection).should('not.be.empty'); - }); - - cy.get(tendermintDataHeader) - .contains('Tendermint data') - .next() - .within(() => { - this.validators.forEach((validator) => { - cy.contains(validator.address).should('be.visible'); - cy.contains(validator.pub_key.type).should('be.visible'); - cy.contains(validator.pub_key.value).should('be.visible'); - cy.contains(validator.voting_power).should('be.visible'); - // Proposer priority can change frequently mid test - // Therefore only checking the field name is present. - cy.contains('proposer_priority').should('be.visible'); - }); - }); - }); - - it('should be able to switch validator page between light and dark mode', function () { - const whiteThemeSelectedMenuOptionColor = 'rgb(255, 7, 127)'; - const whiteThemeJsonFieldBackColor = 'rgb(255, 255, 255)'; - const whiteThemeSideMenuBackgroundColor = 'rgb(255, 255, 255)'; - const darkThemeSelectedMenuOptionColor = 'rgb(215, 251, 80)'; - const darkThemeJsonFieldBackColor = 'rgb(38, 38, 38)'; - const darkThemeSideMenuBackgroundColor = 'rgb(0, 0, 0)'; - const themeSwitcher = '[data-testid="theme-switcher"]'; - const jsonFields = '.hljs'; - const sideMenuBackground = '.absolute'; - - // Engage dark mode if not allready set - cy.get(sideMenuBackground) - .should('have.css', 'background-color') - .then((background_color) => { - if (background_color.includes(whiteThemeSideMenuBackgroundColor)) - cy.get(themeSwitcher).click(); - }); - - // Engage white mode - cy.get(themeSwitcher).click(); - - // White Mode - cy.get(validatorMenuHeading) - .should('have.css', 'background-color') - .and('include', whiteThemeSelectedMenuOptionColor); - cy.get(jsonFields) - .should('have.css', 'background-color') - .and('include', whiteThemeJsonFieldBackColor); - cy.get(sideMenuBackground) - .should('have.css', 'background-color') - .and('include', whiteThemeSideMenuBackgroundColor); - - // Dark Mode - cy.get(themeSwitcher).click(); - cy.get(validatorMenuHeading) - .should('have.css', 'background-color') - .and('include', darkThemeSelectedMenuOptionColor); - cy.get(jsonFields) - .should('have.css', 'background-color') - .and('include', darkThemeJsonFieldBackColor); - cy.get(sideMenuBackground) - .should('have.css', 'background-color') - .and('include', darkThemeSideMenuBackgroundColor); - }); - - Cypress.Commands.add('get_validators', () => { - cy.request({ - method: 'GET', - url: `http://localhost:26617/validators`, - headers: { 'content-type': 'application/json' }, - }) - .its(`body.result.validators`) - .then(function (response) { - let validators = []; - response.forEach((account, index) => { - validators[index] = account; - }); - return validators; - }); - }); - - Cypress.Commands.add('get_nodes', () => { - const mutation = - '{nodesConnection { edges { node { id name infoUrl avatarUrl pubkey tmPubkey ethereumAddress \ - location stakedByOperator stakedByDelegates stakedTotal pendingStake \ - epochData { total offline online __typename } status name __typename}}}}'; - cy.request({ - method: 'POST', - url: `http://localhost:3028/query`, - body: { - query: mutation, - }, - headers: { 'content-type': 'application/json' }, - }) - .its(`body.data.nodesConnection.edges`) - .then(function (response) { - let nodes = []; - response.forEach((node) => { - nodes.push(node); - }); - return nodes; + it('should be able to see validator tiles', function () { + cy.getNodes().then((nodes) => { + nodes.forEach((node) => { + cy.get(`[validator-id="${node.id}"]`).should('be.visible'); }); + }); }); }); }); diff --git a/apps/explorer/src/app/components/page-helpers/index.ts b/apps/explorer/src/app/components/page-helpers/index.ts new file mode 100644 index 000000000..7aa49aefb --- /dev/null +++ b/apps/explorer/src/app/components/page-helpers/index.ts @@ -0,0 +1 @@ +export * from './page-actions'; diff --git a/apps/explorer/src/app/components/page-helpers/page-actions.tsx b/apps/explorer/src/app/components/page-helpers/page-actions.tsx new file mode 100644 index 000000000..c23aa08ae --- /dev/null +++ b/apps/explorer/src/app/components/page-helpers/page-actions.tsx @@ -0,0 +1,10 @@ +import type { HTMLAttributes } from 'react'; + +export const PageActions = ({ + children, + ...props +}: HTMLAttributes) => ( +
+ {children} +
+); diff --git a/apps/explorer/src/app/components/page-helpers/page-title.tsx b/apps/explorer/src/app/components/page-helpers/page-title.tsx new file mode 100644 index 000000000..5dea63aed --- /dev/null +++ b/apps/explorer/src/app/components/page-helpers/page-title.tsx @@ -0,0 +1,45 @@ +import classNames from 'classnames'; +import type { HTMLAttributes, ReactNode } from 'react'; +import { useDocumentTitle } from '../../hooks/use-document-title'; +import { RouteTitle } from '../route-title'; +import { PageActions } from './page-actions'; + +type PageTitleProps = { + /** + * The page title + * (also sets the document title unless overwritten by + * `documentTitle` property) + */ + title: string; + /** + * The react node that consists of CTA buttons, links, etc. + */ + actions?: ReactNode; + /** + * Overwrites the document title + */ + documentTitle?: Parameters[0]; +} & Omit, 'children'>; + +export const PageTitle = ({ + title, + actions, + documentTitle, + className, + ...props +}: PageTitleProps) => { + useDocumentTitle( + documentTitle && documentTitle.length > 0 ? documentTitle : [title] + ); + return ( +
+ + {title} + + {actions && {actions}} +
+ ); +}; diff --git a/apps/explorer/src/app/hooks/use-tendermint-validators.ts b/apps/explorer/src/app/hooks/use-tendermint-validators.ts new file mode 100644 index 000000000..c208fb500 --- /dev/null +++ b/apps/explorer/src/app/hooks/use-tendermint-validators.ts @@ -0,0 +1,55 @@ +import { useFetch } from '@vegaprotocol/react-helpers'; +import { useEffect, useRef } from 'react'; +import { DATA_SOURCES } from '../config'; + +type PubKey = { + type: string; + value: string; +}; + +type Validator = { + address: string; + pub_key: PubKey; + voting_power: string; + proposer_priority: string; +}; + +type Result = { + block_height: string; + validators: Validator[]; + count: string; + total: string; +}; + +type TendermintValidatorsResponse = { + jsonrpc: string; + id: number; + result: Result; +}; + +export const useTendermintValidators = (pollInterval?: number) => { + const { + state: { data, loading, error }, + refetch, + } = useFetch( + `${DATA_SOURCES.tendermintUrl}/validators` + ); + + const ref = useRef(undefined); + useEffect(() => { + if (data) ref.current = data; + }, [data]); + + useEffect(() => { + const interval = + pollInterval && + setInterval(() => { + refetch(); + }, pollInterval); + return () => { + clearInterval(interval); + }; + }, [pollInterval, refetch]); + + return { data: ref.current, loading, error, refetch }; +}; diff --git a/apps/explorer/src/app/routes/assets/asset-page.tsx b/apps/explorer/src/app/routes/assets/asset-page.tsx index 63621b8d0..d3b3cac3b 100644 --- a/apps/explorer/src/app/routes/assets/asset-page.tsx +++ b/apps/explorer/src/app/routes/assets/asset-page.tsx @@ -1,5 +1,4 @@ import { t } from '@vegaprotocol/react-helpers'; -import { RouteTitle } from '../../components/route-title'; import { AsyncRenderer, Button } from '@vegaprotocol/ui-toolkit'; import { useScrollToLocation } from '../../hooks/scroll-to-location'; import { useDocumentTitle } from '../../hooks/use-document-title'; @@ -8,6 +7,7 @@ import { AssetDetailsTable, useAssetDataProvider } from '@vegaprotocol/assets'; import { useParams } from 'react-router-dom'; import { JsonViewerDialog } from '../../components/dialogs/json-viewer-dialog'; import { useState } from 'react'; +import { PageTitle } from '../../components/page-helpers/page-title'; export const AssetPage = () => { useDocumentTitle(['Assets']); @@ -22,18 +22,25 @@ export const AssetPage = () => { return ( <>
- {title} + setDialogOpen(true)} + > + {t('View JSON')} + + } + /> -
- -
diff --git a/apps/explorer/src/app/routes/markets/market-page.tsx b/apps/explorer/src/app/routes/markets/market-page.tsx index 7c90251e5..d1483a935 100644 --- a/apps/explorer/src/app/routes/markets/market-page.tsx +++ b/apps/explorer/src/app/routes/markets/market-page.tsx @@ -3,12 +3,12 @@ import { AsyncRenderer, Button } from '@vegaprotocol/ui-toolkit'; import { useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; import { MarketDetails } from '../../components/markets/market-details'; -import { RouteTitle } from '../../components/route-title'; import { useScrollToLocation } from '../../hooks/scroll-to-location'; import { useDocumentTitle } from '../../hooks/use-document-title'; import compact from 'lodash/compact'; import { JsonViewerDialog } from '../../components/dialogs/json-viewer-dialog'; import { marketInfoNoCandlesDataProvider } from '@vegaprotocol/market-info'; +import { PageTitle } from '../../components/page-helpers/page-title'; export const MarketPage = () => { useScrollToLocation(); @@ -40,20 +40,25 @@ export const MarketPage = () => { return ( <>
- - {data?.market?.tradableInstrument.instrument.name} - + setDialogOpen(true)} + > + {t('View JSON')} + + } + /> -
- -
diff --git a/apps/explorer/src/app/routes/router-config.tsx b/apps/explorer/src/app/routes/router-config.tsx index 198f65769..ade048209 100644 --- a/apps/explorer/src/app/routes/router-config.tsx +++ b/apps/explorer/src/app/routes/router-config.tsx @@ -9,7 +9,7 @@ import Party from './parties'; import { Parties } from './parties/home'; import { Party as PartySingle } from './parties/id'; import Txs from './txs'; -import Validators from './validators'; +import { ValidatorsPage } from './validators'; import Genesis from './genesis'; import { Block } from './blocks/id'; import { Blocks } from './blocks/home'; @@ -125,7 +125,7 @@ const validators: Route[] = flags.validators path: Routes.VALIDATORS, name: 'Validators', text: t('Validators'), - element: , + element: , }, ] : []; diff --git a/apps/explorer/src/app/routes/validators/index.tsx b/apps/explorer/src/app/routes/validators/index.tsx index 84b8195eb..27b2aecd9 100644 --- a/apps/explorer/src/app/routes/validators/index.tsx +++ b/apps/explorer/src/app/routes/validators/index.tsx @@ -1,47 +1 @@ -import { t } from '@vegaprotocol/react-helpers'; -import { RouteTitle } from '../../components/route-title'; -import { SubHeading } from '../../components/sub-heading'; -import { Loader, SyntaxHighlighter } from '@vegaprotocol/ui-toolkit'; -import { DATA_SOURCES } from '../../config'; -import { useFetch } from '@vegaprotocol/react-helpers'; -import type { TendermintValidatorsResponse } from './tendermint-validator-response'; -import { useExplorerNodesQuery } from './__generated__/Nodes'; -import { useDocumentTitle } from '../../hooks/use-document-title'; - -const Validators = () => { - const { - state: { data: validators }, - } = useFetch( - `${DATA_SOURCES.tendermintUrl}/validators` - ); - - useDocumentTitle(['Validators']); - - const { data } = useExplorerNodesQuery(); - - return ( -
- {t('Validators')} - {data ? ( - <> - {t('Vega data')} - - - ) : ( - - )} - {validators ? ( - <> - - {t('Tendermint data')} - - - - ) : ( - - )} -
- ); -}; - -export default Validators; +export * from './validators-page'; diff --git a/apps/explorer/src/app/routes/validators/tendermint-validator-response.tsx b/apps/explorer/src/app/routes/validators/tendermint-validator-response.tsx deleted file mode 100644 index 5e5a59b96..000000000 --- a/apps/explorer/src/app/routes/validators/tendermint-validator-response.tsx +++ /dev/null @@ -1,24 +0,0 @@ -export interface PubKey { - type: string; - value: string; -} - -export interface Validator { - address: string; - pub_key: PubKey; - voting_power: string; - proposer_priority: string; -} - -export interface Result { - block_height: string; - validators: Validator[]; - count: string; - total: string; -} - -export interface TendermintValidatorsResponse { - jsonrpc: string; - id: number; - result: Result; -} diff --git a/apps/explorer/src/app/routes/validators/validators-page.tsx b/apps/explorer/src/app/routes/validators/validators-page.tsx new file mode 100644 index 000000000..1cbc6851f --- /dev/null +++ b/apps/explorer/src/app/routes/validators/validators-page.tsx @@ -0,0 +1,340 @@ +import { countryCodeToFlagEmoji, t } from '@vegaprotocol/react-helpers'; +import { + AsyncRenderer, + Button, + CopyWithTooltip, + ExternalLink, + Icon, + KeyValueTable, + KeyValueTableRow, + Tooltip, + truncateMiddle, +} from '@vegaprotocol/ui-toolkit'; + +import { useExplorerNodesQuery } from './__generated__/Nodes'; +import { useDocumentTitle } from '../../hooks/use-document-title'; +import compact from 'lodash/compact'; +import { useTendermintValidators } from '../../hooks/use-tendermint-validators'; +import { useMemo, useState } from 'react'; +import { JsonViewerDialog } from '../../components/dialogs/json-viewer-dialog'; +import { PageTitle } from '../../components/page-helpers/page-title'; +import BigNumber from 'bignumber.js'; +import { + ContractAddressLink, + DApp, + TOKEN_VALIDATOR, + useLinks, +} from '@vegaprotocol/environment'; +import classNames from 'classnames'; +import { NodeStatus, NodeStatusMapping } from '@vegaprotocol/types'; + +type RateProps = { + value: BigNumber | number | undefined; + className?: string; + colour?: 'green' | 'blue' | 'pink' | 'orange'; + asPoint?: boolean; + zero?: boolean; +}; +const Rate = ({ + value, + className, + colour = 'blue', + asPoint = false, +}: RateProps) => { + const val = + typeof value === 'undefined' + ? new BigNumber(0) + : typeof value === 'number' + ? new BigNumber(value) + : value; + const bar = asPoint + ? { + right: `${val.times(100).toFixed(2)}%`, + } + : { width: `${val.times(100).toFixed(2)}%` }; + return ( +
+
+
+
+
+ ); +}; + +export const ValidatorsPage = () => { + useDocumentTitle(['Validators']); + + const { data: tmData } = useTendermintValidators(5000); + const { data, loading, error, refetch } = useExplorerNodesQuery(); + + const validators = compact(data?.nodesConnection.edges?.map((e) => e?.node)); + + // voting power + const powers = compact(tmData?.result.validators).map( + (v) => new BigNumber(v.voting_power) + ); + const totalVotingPower = BigNumber.sum(...powers); + + // proposer priority + const priorities = compact(tmData?.result.validators).map( + (v) => new BigNumber(v.proposer_priority) + ); + const absoluteProposerPriority = BigNumber.max( + ...priorities.map((p) => p.abs()) + ); + + const tmValidators = useMemo(() => { + return tmData?.result.validators.map((v) => { + const data = { + key: v.pub_key.value, + votingPower: new BigNumber(v.voting_power), + proposerPriority: new BigNumber(v.proposer_priority), + }; + return { + ...data, + votingPowerRatio: data.votingPower.dividedBy(totalVotingPower), + proposerPriorityRatio: absoluteProposerPriority + .plus(data.proposerPriority) + .dividedBy(absoluteProposerPriority.times(2)), + }; + }); + }, [tmData?.result.validators, totalVotingPower, absoluteProposerPriority]); + + const totalStaked = BigNumber.sum( + ...validators.map((v) => new BigNumber(v.stakedTotal)) + ); + + const [vegaDialog, setVegaDialog] = useState(false); + const [tmDialog, setTmDialog] = useState(false); + + const tokenLink = useLinks(DApp.Token); + + return ( + <> +
+ + + { + + } + + } + /> + +
    + {validators.map((v) => { + const tm = tmValidators?.find((tmv) => tmv.key === v.tmPubkey); + const stakedRatio = new BigNumber(v.stakedTotal).dividedBy( + totalStaked + ); + const validatorPage = tokenLink( + TOKEN_VALIDATOR.replace(':id', v.id) + ); + const validatorName = + v.name && v.name.length > 0 ? v.name : truncateMiddle(v.id); + return ( +
  • +
    + {v.avatarUrl && ( +
    + + {validatorName} + +
    + )} +
    +

    + + {validatorName} + +

    + + +
    {t('ID')}
    +
    {v.id}
    +
    + +
    {t('Status')}
    +
    + + + + {NodeStatusMapping[v.status]} +
    +
    + +
    {t('Location')}
    +
    + {countryCodeToFlagEmoji(v.location)}{' '} + {v.location} +
    +
    + +
    {t('Public key')}
    +
    {v.pubkey}
    +
    + +
    {t('Ethereum address')}
    +
    + {' '} + + + +
    +
    + +
    {t('Tendermint public key')}
    +
    {v.tmPubkey}
    +
    + + +
    {t('Voting power')}
    +
    + +
    + {tm?.votingPowerRatio.times(100).toFixed(2)} + {'% '}({tm?.votingPower.toString()}) +
    +
    +
    + +
    {t('Proposer priority')}
    +
    + +
    + {tm?.proposerPriority.toString()} +
    +
    +
    + + +
    {t('Stake share')}
    +
    + +
    + + +
    {t('Staked by operator')}
    +
    {v.stakedByOperator}
    +
    + +
    {t('Staked by delegates')}
    +
    {v.stakedByDelegates}
    +
    + +
    {t('Staked (total)')}
    +
    {v.stakedTotal}
    +
    + + } + > + + {stakedRatio.times(100).toFixed(2)}% + +
    +
    +
    +
    +
    +
    +
    +
  • + ); + })} +
+
+
+ setVegaDialog(isOpen)} + title={t('Vega Validators')} + content={data} + /> + setTmDialog(isOpen)} + title={t('Tendermint Validators')} + content={tmData} + /> + + ); +}; diff --git a/libs/assets/src/lib/asset-details-table.tsx b/libs/assets/src/lib/asset-details-table.tsx index d20adf3c7..f92e01484 100644 --- a/libs/assets/src/lib/asset-details-table.tsx +++ b/libs/assets/src/lib/asset-details-table.tsx @@ -1,9 +1,8 @@ -import { useEtherscanLink } from '@vegaprotocol/environment'; +import { ContractAddressLink } from '@vegaprotocol/environment'; import { addDecimalsFormatNumber, t } from '@vegaprotocol/react-helpers'; import type * as Schema from '@vegaprotocol/types'; import type { KeyValueTableRowProps } from '@vegaprotocol/ui-toolkit'; import { CopyWithTooltip, Icon } from '@vegaprotocol/ui-toolkit'; -import { Link } from '@vegaprotocol/ui-toolkit'; import { KeyValueTable, KeyValueTableRow, @@ -276,16 +275,3 @@ export const AssetDetailsTable = ({ ); }; - -// Separate component for the link as otherwise eslint will complain -// about useEnvironment being used in a component -// named with a lowercase 'value' -const ContractAddressLink = ({ address }: { address: string }) => { - const etherscanLink = useEtherscanLink(); - const href = etherscanLink(`/address/${address}`); - return ( - - {address} - - ); -}; diff --git a/libs/cypress/src/index.ts b/libs/cypress/src/index.ts index ce6086e98..99ad4811c 100644 --- a/libs/cypress/src/index.ts +++ b/libs/cypress/src/index.ts @@ -18,6 +18,7 @@ import { addMockTransactionResponse } from './lib/commands/mock-transaction-resp import { addCreateMarket } from './lib/commands/create-market'; import { addConnectPublicKey } from './lib/commands/add-connect-public-key'; import { addVegaWalletSubmitProposal } from './lib/commands/vega-wallet-submit-proposal'; +import { addGetNodes } from './lib/commands/get-nodes'; addGetTestIdcommand(); addSlackCommand(); @@ -28,6 +29,7 @@ addMockWeb3ProviderCommand(); addHighlightLog(); addVegaWalletReceiveFaucetedAsset(); addGetAssets(); +addGetNodes(); addContainsExactly(); addGetNetworkParameters(); addUpdateCapsuleMultiSig(); diff --git a/libs/cypress/src/lib/commands/get-assets.ts b/libs/cypress/src/lib/commands/get-assets.ts index 4facc624e..6d4f9d152 100644 --- a/libs/cypress/src/lib/commands/get-assets.ts +++ b/libs/cypress/src/lib/commands/get-assets.ts @@ -1,13 +1,14 @@ import { gql } from '@apollo/client'; import { print } from 'graphql'; import type { AssetFieldsFragment } from '@vegaprotocol/assets'; +import { edgesToList } from '../utils'; declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace Cypress { // eslint-disable-next-line @typescript-eslint/no-unused-vars interface Chainable { - getAssets(): Chainable>; + getAssets(): Chainable>; } } } @@ -72,12 +73,6 @@ export function addGetAssets() { headers: { 'content-type': 'application/json' }, }) .its('body.data.assetsConnection.edges') - .then((edges) => { - // @ts-ignore - ignoring Cypress type error which gets resolved when Cypress uses the command - return edges.reduce((list, edge) => { - list[edge.node.name] = edge.node; - return list; - }, {}); - }); + .then(edgesToList); }); } diff --git a/libs/cypress/src/lib/commands/get-nodes.ts b/libs/cypress/src/lib/commands/get-nodes.ts new file mode 100644 index 000000000..d7206df7b --- /dev/null +++ b/libs/cypress/src/lib/commands/get-nodes.ts @@ -0,0 +1,55 @@ +import { gql } from '@apollo/client'; +import type { Node } from '@vegaprotocol/types'; +import { print } from 'graphql'; +import { edgesToList } from '../utils'; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface Chainable { + getNodes(): Chainable>>; + } + } +} + +export function addGetNodes() { + // @ts-ignore - ignoring Cypress type error which gets resolved when Cypress uses the command + Cypress.Commands.add('getNodes', () => { + const query = gql` + query Nodes { + nodesConnection { + edges { + node { + __typename + avatarUrl + ethereumAddress + id + infoUrl + location + name + pendingStake + pubkey + stakedByDelegates + stakedByOperator + stakedTotal + status + tmPubkey + } + } + } + } + `; + + cy.request({ + method: 'POST', + url: `http://localhost:3028/query`, + body: { + query: print(query), + }, + headers: { 'content-type': 'application/json' }, + }) + .its(`body.data.nodesConnection.edges`) + .then(edgesToList); + }); +} diff --git a/libs/cypress/src/lib/commands/vega-wallet-receive-fauceted-asset.ts b/libs/cypress/src/lib/commands/vega-wallet-receive-fauceted-asset.ts index 1fce8180e..adb617278 100644 --- a/libs/cypress/src/lib/commands/vega-wallet-receive-fauceted-asset.ts +++ b/libs/cypress/src/lib/commands/vega-wallet-receive-fauceted-asset.ts @@ -23,8 +23,8 @@ export function addVegaWalletReceiveFaucetedAsset() { // @ts-ignore - ignoring Cypress type error which gets resolved when Cypress uses the command cy.getAssets().then((assets) => { console.log(assets); - const asset = assets[assetName]; - if (assets[assetName] !== undefined) { + const asset = assets.find((a) => a.name === assetName); + if (asset) { for (let i = 0; i < asset.decimals; i++) amount += '0'; cy.exec( `curl -X POST -d '{"amount": "${amount}", "asset": "${asset.id}", "party": "${vegaWalletPublicKey}"}' http://localhost:1790/api/v1/mint` @@ -38,15 +38,9 @@ export function addVegaWalletReceiveFaucetedAsset() { ); }); } else { - const validAssets = Object.keys(assets) - .filter((key) => key.includes('fake')) - .reduce((obj, key) => { - return Object.assign(obj, { - [key]: assets[key], - }); - }, {}); + const validAssets = assets.filter((a) => a.name.includes('fake')); assert.exists( - assets[assetName], + asset, `${assetName} is not a faucet-able asset, only the following assets can be faucet-ed: ${validAssets}` ); } diff --git a/libs/cypress/src/lib/utils.ts b/libs/cypress/src/lib/utils.ts index 9e13e3563..e6c81d754 100644 --- a/libs/cypress/src/lib/utils.ts +++ b/libs/cypress/src/lib/utils.ts @@ -62,3 +62,11 @@ const checkSortChange = (tabsArr: string[], column: string) => { }); }); }; + +type Edges = { node: unknown }[]; +export function edgesToList(edges: Edges) { + // @ts-ignore - ignoring Cypress type error which gets resolved when Cypress uses the command + return edges.map((edge) => { + return edge.node; + }); +} diff --git a/libs/environment/src/components/contract-address-link.tsx b/libs/environment/src/components/contract-address-link.tsx new file mode 100644 index 000000000..4ade72b35 --- /dev/null +++ b/libs/environment/src/components/contract-address-link.tsx @@ -0,0 +1,13 @@ +import { t } from '@vegaprotocol/react-helpers'; +import { Link } from '@vegaprotocol/ui-toolkit'; +import { useEtherscanLink } from '../hooks'; + +export const ContractAddressLink = ({ address }: { address: string }) => { + const etherscanLink = useEtherscanLink(); + const href = etherscanLink(`/address/${address}`); + return ( + + {address} + + ); +}; diff --git a/libs/environment/src/components/index.ts b/libs/environment/src/components/index.ts index 1bdd1b1de..0fe5fe13b 100644 --- a/libs/environment/src/components/index.ts +++ b/libs/environment/src/components/index.ts @@ -2,3 +2,4 @@ export * from './network-loader'; export * from './network-switcher'; export * from './node-guard'; export * from './node-switcher'; +export * from './contract-address-link'; diff --git a/libs/environment/src/hooks/use-links.ts b/libs/environment/src/hooks/use-links.ts index 1600de413..7e7127cdc 100644 --- a/libs/environment/src/hooks/use-links.ts +++ b/libs/environment/src/hooks/use-links.ts @@ -94,6 +94,7 @@ export const TOKEN_NEW_NETWORK_PARAM_PROPOSAL = export const TOKEN_GOVERNANCE = '/proposals'; export const TOKEN_PROPOSALS = '/proposals'; export const TOKEN_PROPOSAL = '/proposals/:id'; +export const TOKEN_VALIDATOR = '/validators/:id'; // Explorer pages export const EXPLORER_TX = '/txs/:hash'; diff --git a/libs/react-helpers/src/lib/flag-emoji.spec.ts b/libs/react-helpers/src/lib/flag-emoji.spec.ts new file mode 100644 index 000000000..c83f5ee29 --- /dev/null +++ b/libs/react-helpers/src/lib/flag-emoji.spec.ts @@ -0,0 +1,267 @@ +import { countryCodeToFlagEmoji, FALLBACK_FLAG } from './flag-emoji'; + +// REGIONAL INDICATOR SYMBOLS: +// 🇦 +// 🇧 +// 🇨 +// 🇩 +// 🇪 +// 🇫 +// 🇬 +// 🇭 +// 🇮 +// 🇯 +// 🇰 +// 🇱 +// 🇲 +// 🇳 +// 🇴 +// 🇵 +// 🇶 +// 🇷 +// 🇸 +// 🇹 +// 🇺 +// 🇻 +// 🇼 +// 🇽 +// 🇾 +// 🇿 + +const cases = [ + ['AC', '🇦🇨'], + ['AD', '🇦🇩'], + ['AE', '🇦🇪'], + ['AF', '🇦🇫'], + ['AG', '🇦🇬'], + ['AI', '🇦🇮'], + ['AL', '🇦🇱'], + ['AM', '🇦🇲'], + ['AO', '🇦🇴'], + ['AR', '🇦🇷'], + ['AS', '🇦🇸'], + ['AT', '🇦🇹'], + ['AQ', '🇦🇶'], + ['AW', '🇦🇼'], + ['AZ', '🇦🇿'], + ['BA', '🇧🇦'], + ['BB', '🇧🇧'], + ['BD', '🇧🇩'], + ['BE', '🇧🇪'], + ['BF', '🇧🇫'], + ['BG', '🇧🇬'], + ['BH', '🇧🇭'], + ['BI', '🇧🇮'], + ['BJ', '🇧🇯'], + ['BL', '🇧🇱'], + ['BM', '🇧🇲'], + ['BN', '🇧🇳'], + ['BO', '🇧🇴'], + ['BR', '🇧🇷'], + ['BS', '🇧🇸'], + ['BT', '🇧🇹'], + ['BQ', '🇧🇶'], + ['BW', '🇧🇼'], + ['BY', '🇧🇾'], + ['BZ', '🇧🇿'], + ['CA', '🇨🇦'], + ['CC', '🇨🇨'], + ['CD', '🇨🇩'], + ['CF', '🇨🇫'], + ['CG', '🇨🇬'], + ['CH', '🇨🇭'], + ['CI', '🇨🇮'], + ['CK', '🇨🇰'], + ['CL', '🇨🇱'], + ['CM', '🇨🇲'], + ['CN', '🇨🇳'], + ['CO', '🇨🇴'], + ['CP', '🇨🇵'], + ['CR', '🇨🇷'], + ['CW', '🇨🇼'], + ['CY', '🇨🇾'], + ['CZ', '🇨🇿'], + ['DE', '🇩🇪'], + ['DG', '🇩🇬'], + ['DJ', '🇩🇯'], + ['DK', '🇩🇰'], + ['DM', '🇩🇲'], + ['DO', '🇩🇴'], + ['DZ', '🇩🇿'], + ['EA', '🇪🇦'], + ['EC', '🇪🇨'], + ['EE', '🇪🇪'], + ['EG', '🇪🇬'], + ['EH', '🇪🇭'], + ['ER', '🇪🇷'], + ['ES', '🇪🇸'], + ['ET', '🇪🇹'], + ['FI', '🇫🇮'], + ['FJ', '🇫🇯'], + ['FK', '🇫🇰'], + ['FM', '🇫🇲'], + ['FO', '🇫🇴'], + ['FR', '🇫🇷'], + ['GA', '🇬🇦'], + ['GB', '🇬🇧'], + ['GD', '🇬🇩'], + ['GE', '🇬🇪'], + ['GF', '🇬🇫'], + ['GG', '🇬🇬'], + ['GH', '🇬🇭'], + ['GI', '🇬🇮'], + ['GL', '🇬🇱'], + ['GM', '🇬🇲'], + ['GN', '🇬🇳'], + ['GP', '🇬🇵'], + ['GR', '🇬🇷'], + ['GS', '🇬🇸'], + ['GT', '🇬🇹'], + ['GQ', '🇬🇶'], + ['GW', '🇬🇼'], + ['GY', '🇬🇾'], + ['HK', '🇭🇰'], + ['HM', '🇭🇲'], + ['HN', '🇭🇳'], + ['HR', '🇭🇷'], + ['HT', '🇭🇹'], + ['IC', '🇮🇨'], + ['ID', '🇮🇩'], + ['IE', '🇮🇪'], + ['IL', '🇮🇱'], + ['IM', '🇮🇲'], + ['IN', '🇮🇳'], + ['IO', '🇮🇴'], + ['IR', '🇮🇷'], + ['IS', '🇮🇸'], + ['IT', '🇮🇹'], + ['IQ', '🇮🇶'], + ['JE', '🇯🇪'], + ['JM', '🇯🇲'], + ['JO', '🇯🇴'], + ['JP', '🇯🇵'], + ['KE', '🇰🇪'], + ['KG', '🇰🇬'], + ['KH', '🇰🇭'], + ['KI', '🇰🇮'], + ['KM', '🇰🇲'], + ['KN', '🇰🇳'], + ['KP', '🇰🇵'], + ['KR', '🇰🇷'], + ['KW', '🇰🇼'], + ['KY', '🇰🇾'], + ['KZ', '🇰🇿'], + ['LA', '🇱🇦'], + ['LB', '🇱🇧'], + ['LC', '🇱🇨'], + ['LI', '🇱🇮'], + ['LK', '🇱🇰'], + ['LR', '🇱🇷'], + ['LS', '🇱🇸'], + ['LT', '🇱🇹'], + ['LY', '🇱🇾'], + ['MA', '🇲🇦'], + ['MC', '🇲🇨'], + ['MD', '🇲🇩'], + ['ME', '🇲🇪'], + ['MF', '🇲🇫'], + ['MG', '🇲🇬'], + ['MH', '🇲🇭'], + ['MK', '🇲🇰'], + ['ML', '🇲🇱'], + ['MM', '🇲🇲'], + ['MN', '🇲🇳'], + ['MO', '🇲🇴'], + ['MP', '🇲🇵'], + ['MR', '🇲🇷'], + ['MS', '🇲🇸'], + ['MT', '🇲🇹'], + ['MQ', '🇲🇶'], + ['MW', '🇲🇼'], + ['MY', '🇲🇾'], + ['MZ', '🇲🇿'], + ['NA', '🇳🇦'], + ['NC', '🇳🇨'], + ['NE', '🇳🇪'], + ['NF', '🇳🇫'], + ['NG', '🇳🇬'], + ['NI', '🇳🇮'], + ['NL', '🇳🇱'], + ['NO', '🇳🇴'], + ['NP', '🇳🇵'], + ['NR', '🇳🇷'], + ['NZ', '🇳🇿'], + ['OM', '🇴🇲'], + ['PA', '🇵🇦'], + ['PE', '🇵🇪'], + ['PF', '🇵🇫'], + ['PG', '🇵🇬'], + ['PH', '🇵🇭'], + ['PK', '🇵🇰'], + ['PL', '🇵🇱'], + ['PM', '🇵🇲'], + ['PN', '🇵🇳'], + ['PR', '🇵🇷'], + ['PS', '🇵🇸'], + ['PT', '🇵🇹'], + ['PW', '🇵🇼'], + ['PY', '🇵🇾'], + ['RE', '🇷🇪'], + ['RO', '🇷🇴'], + ['RS', '🇷🇸'], + ['RW', '🇷🇼'], + ['SA', '🇸🇦'], + ['SB', '🇸🇧'], + ['SC', '🇸🇨'], + ['SD', '🇸🇩'], + ['SE', '🇸🇪'], + ['SG', '🇸🇬'], + ['SH', '🇸🇭'], + ['SI', '🇸🇮'], + ['SJ', '🇸🇯'], + ['SK', '🇸🇰'], + ['SL', '🇸🇱'], + ['SM', '🇸🇲'], + ['SN', '🇸🇳'], + ['SO', '🇸🇴'], + ['SR', '🇸🇷'], + ['SS', '🇸🇸'], + ['ST', '🇸🇹'], + ['SY', '🇸🇾'], + ['SZ', '🇸🇿'], + ['TA', '🇹🇦'], + ['TC', '🇹🇨'], + ['TD', '🇹🇩'], + ['TF', '🇹🇫'], + ['TG', '🇹🇬'], + ['TH', '🇹🇭'], + ['TJ', '🇹🇯'], + ['TK', '🇹🇰'], + ['TL', '🇹🇱'], + ['TM', '🇹🇲'], + ['TN', '🇹🇳'], + ['TO', '🇹🇴'], + ['TR', '🇹🇷'], + ['TT', '🇹🇹'], + ['TW', '🇹🇼'], + ['TZ', '🇹🇿'], + ['QA', '🇶🇦'], + ['VG', '🇻🇬'], + ['WF', '🇼🇫'], + ['WS', '🇼🇸'], + ['YE', '🇾🇪'], + ['YT', '🇾🇹'], + ['ZA', '🇿🇦'], + ['ZM', '🇿🇲'], + ['ZW', '🇿🇼'], + // unknown + ['AA', FALLBACK_FLAG], + ['XX', FALLBACK_FLAG], + ['AAA', FALLBACK_FLAG], +]; + +describe('countryCodeToFlagEmoji', () => { + it.each(cases)('converts %s to %s', (countryCode, flag) => { + expect(countryCodeToFlagEmoji(countryCode)).toEqual(flag); + }); +}); diff --git a/libs/react-helpers/src/lib/flag-emoji.ts b/libs/react-helpers/src/lib/flag-emoji.ts new file mode 100644 index 000000000..236e68672 --- /dev/null +++ b/libs/react-helpers/src/lib/flag-emoji.ts @@ -0,0 +1,24 @@ +import compact from 'lodash/compact'; + +export const FALLBACK_FLAG = '🏳'; + +const KNOWN_CODES = `AC AD AE AF AG AI AL AM AO AR AS AT AQ AW AZ BA BB BD BE BF + BG BH BI BJ BL BM BN BO BR BS BT BQ BW BY BZ CA CC CD CF CG CH CI CK CL CM CN + CO CP CR CW CY CZ DE DG DJ DK DM DO DZ EA EC EE EG EH ER ES ET FI FJ FK FM FO + FR GA GB GD GE GF GG GH GI GL GM GN GP GR GS GT GQ GW GY HK HM HN HR HT IC ID + IE IL IM IN IO IR IS IT IQ JE JM JO JP KE KG KH KI KM KN KP KR KW KY KZ LA LB + LC LI LK LR LS LT LY MA MC MD ME MF MG MH MK ML MM MN MO MP MR MS MT MQ MW MY + MZ NA NC NE NF NG NI NL NO NP NR NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW + PY RE RO RS RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SY SZ TA TC + TD TF TG TH TJ TK TL TM TN TO TR TT TW TZ QA VG WF WS YE YT ZA ZM ZW`; + +export const countryCodeToFlagEmoji = (countryCode: string) => { + const code = countryCode.trim().toUpperCase(); + const known = compact(KNOWN_CODES.split(' ').map((ch) => ch.trim())); + if (known.includes(code)) { + return code.replace(/./g, (char) => + String.fromCodePoint(0x1f1a5 + char.toUpperCase().charCodeAt(0)) + ); + } + return FALLBACK_FLAG; +}; diff --git a/libs/react-helpers/src/lib/index.ts b/libs/react-helpers/src/lib/index.ts index 6dfb7f8e6..4996f0bc9 100644 --- a/libs/react-helpers/src/lib/index.ts +++ b/libs/react-helpers/src/lib/index.ts @@ -19,3 +19,4 @@ export * from './remove-pagination-wrapper'; export * from './storage'; export * from './time'; export * from './validate'; +export * from './flag-emoji'; diff --git a/libs/ui-toolkit/src/components/key-value-table/key-value-table.tsx b/libs/ui-toolkit/src/components/key-value-table/key-value-table.tsx index 8906d6a58..639cbaf57 100644 --- a/libs/ui-toolkit/src/components/key-value-table/key-value-table.tsx +++ b/libs/ui-toolkit/src/components/key-value-table/key-value-table.tsx @@ -7,6 +7,7 @@ export interface KeyValueTableProps children: React.ReactNode; headingLevel?: 1 | 2 | 3 | 4 | 5 | 6; numerical?: boolean; + className?: string; } export const KeyValueTable = ({ @@ -14,6 +15,7 @@ export const KeyValueTable = ({ children, numerical, headingLevel, + className, ...rest }: KeyValueTableProps) => { const TitleTag: keyof JSX.IntrinsicElements = headingLevel @@ -22,7 +24,11 @@ export const KeyValueTable = ({ return ( {title && {title}} -
+
{children && React.Children.map(