Feat/45 - Validator list (#833)
* frontend-monorepo-45: Removed party data from validators table * frontend-monorepo-45: Removed redundant query fields * frontend-monorepo-45: Validator AG Grid working and fields formatted * frontend-monorepo-45: Removed old node list tests (as now handled by AG Grid) * Frontend-monorepo-45: Removed unwanted auto-added imports * Frontend-monorepo-45: Fixing cypress tests * Frontend-monorepo-45: Removed unnecessary class added as part of layout test * Frontend-monorepo-45: Removed unnecessary refactor added as part of layout test * Frontend-monorepo-45: Removed unnecessary grid export added as part of grid setup * frontend-monorepo-45: Removed unwanted font-size and adjusted avatar size in proportion * frontend-monorepo-45: Expanded node-list tests * frontend-monorepo-45: Used toString on translations to make node list coldefs a bit clearer * frontend-monorepo-45: Converted nodeFactory to use lodash merge and PartialDeep * frontend-monorepo-45: Refactored so node list AG grid field names are decoupled from translations * frontend-monorepo-45: Added refreshing when epoch changes * frontend-monorepo-45: Used epoch data already passed to node list instead of a new query * frontend-monorepo-45: Node list - used formatNumber from libs/react-helpers * frontend-monorepo-45: formatNumber now accepts BigNumbers
This commit is contained in:
parent
8186bc3948
commit
6f374264c0
@ -1,5 +1,5 @@
|
|||||||
const guideLink = '[data-testid="staking-guide-link"]';
|
const guideLink = '[data-testid="staking-guide-link"]';
|
||||||
const validators = '[data-testid="node-list-item"]';
|
const validators = '[data-testid="validators-grid"]';
|
||||||
|
|
||||||
context('Staking Page - verify elements on page', function () {
|
context('Staking Page - verify elements on page', function () {
|
||||||
before('navigate to staking page', function () {
|
before('navigate to staking page', function () {
|
||||||
|
@ -548,6 +548,16 @@
|
|||||||
"fail": "Fail",
|
"fail": "Fail",
|
||||||
"rankingScore": "Ranking score",
|
"rankingScore": "Ranking score",
|
||||||
"stakeScore": "Stake score",
|
"stakeScore": "Stake score",
|
||||||
"performanceScore": "Performance score",
|
"performanceScore": "Performance",
|
||||||
"votingPower": "Voting score"
|
"votingPower": "Voting power",
|
||||||
|
"noValidators": "No validators",
|
||||||
|
"validator": "Validator",
|
||||||
|
"totalStakeThisEpoch": "Total stake this epoch",
|
||||||
|
"share": "Share",
|
||||||
|
"validatorStake": "Validator stake",
|
||||||
|
"Nodes": "Nodes",
|
||||||
|
"status-tendermint": "Consensus",
|
||||||
|
"status-ersatz": "Ersatz",
|
||||||
|
"status-pending": "Pending",
|
||||||
|
"status-unspecified": "Unspecified"
|
||||||
}
|
}
|
||||||
|
62
apps/token/src/routes/staking/__generated__/Nodes.ts
generated
62
apps/token/src/routes/staking/__generated__/Nodes.ts
generated
@ -3,28 +3,10 @@
|
|||||||
// @generated
|
// @generated
|
||||||
// This file was automatically generated and should not be edited.
|
// This file was automatically generated and should not be edited.
|
||||||
|
|
||||||
import { NodeStatus } from "@vegaprotocol/types";
|
|
||||||
|
|
||||||
// ====================================================
|
// ====================================================
|
||||||
// GraphQL query operation: Nodes
|
// GraphQL query operation: Nodes
|
||||||
// ====================================================
|
// ====================================================
|
||||||
|
|
||||||
export interface Nodes_nodes_epochData {
|
|
||||||
__typename: "EpochData";
|
|
||||||
/**
|
|
||||||
* Total number of epochs since node was created
|
|
||||||
*/
|
|
||||||
total: number;
|
|
||||||
/**
|
|
||||||
* Total number of offline epochs since node was created
|
|
||||||
*/
|
|
||||||
offline: number;
|
|
||||||
/**
|
|
||||||
* Total number of online epochs since node was created
|
|
||||||
*/
|
|
||||||
online: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Nodes_nodes_rankingScore {
|
export interface Nodes_nodes_rankingScore {
|
||||||
__typename: "RankingScore";
|
__typename: "RankingScore";
|
||||||
/**
|
/**
|
||||||
@ -43,10 +25,15 @@ export interface Nodes_nodes_rankingScore {
|
|||||||
* The tendermint voting power of the validator (uint32)
|
* The tendermint voting power of the validator (uint32)
|
||||||
*/
|
*/
|
||||||
votingPower: string;
|
votingPower: string;
|
||||||
|
/**
|
||||||
|
* The current validation status of the validator
|
||||||
|
*/
|
||||||
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Nodes_nodes {
|
export interface Nodes_nodes {
|
||||||
__typename: "Node";
|
__typename: "Node";
|
||||||
|
avatarUrl: string | null;
|
||||||
/**
|
/**
|
||||||
* The node url eg n01.vega.xyz
|
* The node url eg n01.vega.xyz
|
||||||
*/
|
*/
|
||||||
@ -56,36 +43,15 @@ export interface Nodes_nodes {
|
|||||||
* Pubkey of the node operator
|
* Pubkey of the node operator
|
||||||
*/
|
*/
|
||||||
pubkey: string;
|
pubkey: string;
|
||||||
/**
|
|
||||||
* URL where I can find out more info on the node. Will this be possible?
|
|
||||||
*/
|
|
||||||
infoUrl: string;
|
|
||||||
/**
|
|
||||||
* Country code for the location of the node
|
|
||||||
*/
|
|
||||||
location: string;
|
|
||||||
/**
|
|
||||||
* The amount the node has put up themselves
|
|
||||||
*/
|
|
||||||
stakedByOperator: string;
|
|
||||||
/**
|
|
||||||
* The amount of stake that has been delegated by token holders
|
|
||||||
*/
|
|
||||||
stakedByDelegates: string;
|
|
||||||
/**
|
/**
|
||||||
* Total amount staked on node
|
* Total amount staked on node
|
||||||
*/
|
*/
|
||||||
stakedTotal: string;
|
stakedTotal: string;
|
||||||
|
stakedTotalFormatted: string;
|
||||||
/**
|
/**
|
||||||
* Amount of stake on the next epoch
|
* Amount of stake on the next epoch
|
||||||
*/
|
*/
|
||||||
pendingStake: string;
|
pendingStake: string;
|
||||||
stakedByOperatorFormatted: string;
|
|
||||||
stakedByDelegatesFormatted: string;
|
|
||||||
stakedTotalFormatted: string;
|
|
||||||
pendingStakeFormatted: string;
|
|
||||||
epochData: Nodes_nodes_epochData | null;
|
|
||||||
status: NodeStatus;
|
|
||||||
/**
|
/**
|
||||||
* Ranking scores and status for the validator for the current epoch
|
* Ranking scores and status for the validator for the current epoch
|
||||||
*/
|
*/
|
||||||
@ -99,22 +65,6 @@ export interface Nodes_nodeData {
|
|||||||
*/
|
*/
|
||||||
stakedTotal: string;
|
stakedTotal: string;
|
||||||
stakedTotalFormatted: string;
|
stakedTotalFormatted: string;
|
||||||
/**
|
|
||||||
* Total number of nodes
|
|
||||||
*/
|
|
||||||
totalNodes: number;
|
|
||||||
/**
|
|
||||||
* Number of inactive nodes
|
|
||||||
*/
|
|
||||||
inactiveNodes: number;
|
|
||||||
/**
|
|
||||||
* Number of nodes validating
|
|
||||||
*/
|
|
||||||
validatingNodes: number;
|
|
||||||
/**
|
|
||||||
* Total uptime for all epochs across all nodes. Or specify a number of epochs
|
|
||||||
*/
|
|
||||||
uptime: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Nodes {
|
export interface Nodes {
|
||||||
|
@ -1,49 +1,44 @@
|
|||||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import merge from 'lodash/merge';
|
||||||
import { NodeList, NODES_QUERY } from './node-list';
|
import { NodeList, NODES_QUERY } from './node-list';
|
||||||
import { MockedProvider } from '@apollo/client/testing';
|
import { MockedProvider } from '@apollo/client/testing';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import { addDecimal } from '@vegaprotocol/react-helpers';
|
import { addDecimal } from '@vegaprotocol/react-helpers';
|
||||||
import type { Nodes_nodes } from './__generated__/Nodes';
|
import type { Nodes_nodes } from './__generated__/Nodes';
|
||||||
|
import type { PartialDeep } from 'type-fest';
|
||||||
|
|
||||||
jest.mock('../../components/epoch-countdown', () => ({
|
jest.mock('../../components/epoch-countdown', () => ({
|
||||||
EpochCountdown: () => <div data-testid="epoch-info"></div>,
|
EpochCountdown: () => <div data-testid="epoch-info"></div>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const nodeFactory = (overrides?: Partial<Nodes_nodes>) => ({
|
const nodeFactory = (overrides?: PartialDeep<Nodes_nodes>) => {
|
||||||
id: 'ccc022b7e63a4d0a6d3a193c3940c88574060e58a184964c994998d86835a1b4',
|
const defaultNode = {
|
||||||
name: 'Skynet',
|
id: 'ccc022b7e63a4d0a6d3a193c3940c88574060e58a184964c994998d86835a1b4',
|
||||||
pubkey: '6abc23391a9f888ab240415bf63d6844b03fc360be822f4a1d2cd832d87b2917',
|
name: 'Skynet',
|
||||||
infoUrl: 'https://en.wikipedia.org/wiki/Skynet_(Terminator)',
|
avatarUrl: 'https://upload.wikimedia.org/wikipedia/en/2/25/Marvin-TV-3.jpg',
|
||||||
location: '',
|
pubkey: '6abc23391a9f888ab240415bf63d6844b03fc360be822f4a1d2cd832d87b2917',
|
||||||
stakedByOperator: '3000000000000000000000',
|
infoUrl: 'https://en.wikipedia.org/wiki/Skynet_(Terminator)',
|
||||||
stakedByDelegates: '11182454495731682635157',
|
location: '',
|
||||||
stakedTotal: '14182454495731682635157',
|
stakedByOperator: '3000000000000000000000',
|
||||||
pendingStake: '0',
|
stakedByDelegates: '11182454495731682635157',
|
||||||
stakedByOperatorFormatted: addDecimal(
|
stakedTotal: '14182454495731682635157',
|
||||||
overrides?.stakedByOperator || '3000000000000000000000',
|
stakedTotalFormatted: addDecimal('14182454495731682635157', 18),
|
||||||
18
|
pendingStake: '0',
|
||||||
),
|
pendingStakeFormatted: addDecimal('0', 18),
|
||||||
stakedByDelegatesFormatted: addDecimal(
|
epochData: null,
|
||||||
overrides?.stakedByDelegates || '11182454495731682635157',
|
status: 'Validator',
|
||||||
18
|
rankingScore: {
|
||||||
),
|
rankingScore: '0.67845061012234727427532760837568',
|
||||||
stakedTotalFormatted: addDecimal(
|
stakeScore: '0.3392701644525644',
|
||||||
overrides?.stakedTotal || '14182454495731682635157',
|
performanceScore: '0.9998677767864936',
|
||||||
18
|
votingPower: '2407',
|
||||||
),
|
status: 'tendermint',
|
||||||
pendingStakeFormatted: addDecimal(overrides?.pendingStake || '0', 18),
|
__typename: 'RankingScore',
|
||||||
epochData: null,
|
},
|
||||||
status: 'Validator',
|
__typename: 'Node',
|
||||||
rankingScore: {
|
};
|
||||||
rankingScore: '0.67845061012234727427532760837568',
|
return merge(defaultNode, overrides);
|
||||||
stakeScore: '0.3392701644525644',
|
};
|
||||||
performanceScore: '0.9998677767864936',
|
|
||||||
votingPower: '2407',
|
|
||||||
__typename: 'RankingScore',
|
|
||||||
},
|
|
||||||
__typename: 'Node',
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
const MOCK_NODES = {
|
const MOCK_NODES = {
|
||||||
nodes: [
|
nodes: [
|
||||||
@ -53,15 +48,14 @@ const MOCK_NODES = {
|
|||||||
name: 'T-800 Terminator',
|
name: 'T-800 Terminator',
|
||||||
pubkey:
|
pubkey:
|
||||||
'ccc3b8362c25b09d20df8ea407b0a476d6b24a0e72bc063d0033c8841652ddd4',
|
'ccc3b8362c25b09d20df8ea407b0a476d6b24a0e72bc063d0033c8841652ddd4',
|
||||||
infoUrl: 'https://en.wikipedia.org/wiki/Terminator_(character)',
|
|
||||||
stakedByOperator: '3000000000000000000000',
|
|
||||||
stakedByDelegates: '6618711883996159534058',
|
|
||||||
stakedTotal: '9618711883996159534058',
|
stakedTotal: '9618711883996159534058',
|
||||||
|
stakedTotalFormatted: addDecimal('9618711883996159534058', 18),
|
||||||
rankingScore: {
|
rankingScore: {
|
||||||
rankingScore: '0.4601942440481428',
|
rankingScore: '0.4601942440481428',
|
||||||
stakeScore: '0.2300971220240714',
|
stakeScore: '0.2300971220240714',
|
||||||
performanceScore: '1',
|
performanceScore: '1',
|
||||||
votingPower: '2408',
|
votingPower: '2408',
|
||||||
|
status: 'tendermint',
|
||||||
__typename: 'RankingScore',
|
__typename: 'RankingScore',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@ -70,16 +64,15 @@ const MOCK_NODES = {
|
|||||||
name: 'NCC-1701-E',
|
name: 'NCC-1701-E',
|
||||||
pubkey:
|
pubkey:
|
||||||
'0931a8fd8cc935458f470e435a05414387cea6f329d648be894fcd44bd517a2b',
|
'0931a8fd8cc935458f470e435a05414387cea6f329d648be894fcd44bd517a2b',
|
||||||
infoUrl: 'https://en.wikipedia.org/wiki/USS_Enterprise_(NCC-1701-E)',
|
|
||||||
stakedByOperator: '3000000000000000000000',
|
|
||||||
stakedByDelegates: '1041343338923442976709',
|
|
||||||
stakedTotal: '4041343338923442976709',
|
stakedTotal: '4041343338923442976709',
|
||||||
|
stakedTotalFormatted: addDecimal('4041343338923442976709', 18),
|
||||||
pendingStake: '0',
|
pendingStake: '0',
|
||||||
rankingScore: {
|
rankingScore: {
|
||||||
rankingScore: '0.1932810100133910357676209647912',
|
rankingScore: '0.1932810100133910357676209647912',
|
||||||
stakeScore: '0.0966762995515676',
|
stakeScore: '0.0966762995515676',
|
||||||
performanceScore: '0.999629748500531',
|
performanceScore: '0.999629748500531',
|
||||||
votingPower: '1163',
|
votingPower: '1163',
|
||||||
|
status: 'tendermint',
|
||||||
__typename: 'RankingScore',
|
__typename: 'RankingScore',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@ -95,14 +88,14 @@ const MOCK_NODES = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderNodeList = () => {
|
const renderNodeList = (data = MOCK_NODES) => {
|
||||||
return render(
|
return render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<MockedProvider
|
<MockedProvider
|
||||||
mocks={[
|
mocks={[
|
||||||
{
|
{
|
||||||
request: { query: NODES_QUERY },
|
request: { query: NODES_QUERY },
|
||||||
result: { data: MOCK_NODES },
|
result: { data },
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
@ -117,27 +110,6 @@ const renderNodeList = () => {
|
|||||||
expiry: new Date(1000 * 60 * 60 * 24).toISOString(),
|
expiry: new Date(1000 * 60 * 60 * 24).toISOString(),
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
party={{
|
|
||||||
__typename: 'Party',
|
|
||||||
id: 'foo',
|
|
||||||
delegations: [
|
|
||||||
{
|
|
||||||
__typename: 'Delegation',
|
|
||||||
amount: '0',
|
|
||||||
amountFormatted: '0',
|
|
||||||
epoch: 1,
|
|
||||||
node: {
|
|
||||||
__typename: 'Node',
|
|
||||||
id: 'bar',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
stake: {
|
|
||||||
__typename: 'PartyStake',
|
|
||||||
currentStakeAvailable: '0',
|
|
||||||
currentStakeAvailableFormatted: '0',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</MockedProvider>
|
</MockedProvider>
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
@ -162,43 +134,101 @@ describe('Nodes list', () => {
|
|||||||
expect(screen.getByTestId('epoch-info')).toBeInTheDocument();
|
expect(screen.getByTestId('epoch-info')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should a lit of all nodes', async () => {
|
it('should render a list of all nodes', async () => {
|
||||||
renderNodeList();
|
renderNodeList();
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(MOCK_NODES.nodes[0].name)).toBeInTheDocument();
|
expect(screen.getByText(MOCK_NODES.nodes[0].name)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(MOCK_NODES.nodes[1].name)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(MOCK_NODES.nodes[2].name)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
const items = screen.queryAllByTestId('node-list-item');
|
|
||||||
expect(items).toHaveLength(3);
|
|
||||||
for (const item of MOCK_NODES.nodes) {
|
|
||||||
expect(screen.getByText(item.name)).toBeInTheDocument();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should list the total stake and rewards of each node', async () => {
|
it('should display the correctly formatted fields in the correct columns', async () => {
|
||||||
renderNodeList();
|
const MOCK_NODE = {
|
||||||
|
nodes: [
|
||||||
|
nodeFactory({
|
||||||
|
id: '966438c6bffac737cfb08173ffcb3f393c4692b099ad80cb45a82e2dc0a8cf99',
|
||||||
|
name: 'T-800 Terminator',
|
||||||
|
avatarUrl:
|
||||||
|
'https://upload.wikimedia.org/wikipedia/en/9/94/T-800_%28Model_101%29.png',
|
||||||
|
pubkey:
|
||||||
|
'ccc3b8362c25b09d20df8ea407b0a476d6b24a0e72bc063d0033c8841652ddd4',
|
||||||
|
stakedTotal: '9618711883996159534058',
|
||||||
|
stakedTotalFormatted: addDecimal('9618711883996159534058', 18),
|
||||||
|
rankingScore: {
|
||||||
|
rankingScore: '0.4601942440481428',
|
||||||
|
stakeScore: '0.2300971220240714',
|
||||||
|
performanceScore: '1',
|
||||||
|
votingPower: '2408',
|
||||||
|
status: 'tendermint',
|
||||||
|
__typename: 'RankingScore',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
nodeData: {
|
||||||
|
stakedTotal: '9618711883996159534058',
|
||||||
|
stakedTotalFormatted: addDecimal('9618711883996159534058', 18),
|
||||||
|
totalNodes: 1,
|
||||||
|
inactiveNodes: 0,
|
||||||
|
validatingNodes: 1,
|
||||||
|
uptime: 1560.266845703125,
|
||||||
|
__typename: 'NodeData',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
renderNodeList(MOCK_NODE);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(MOCK_NODES.nodes[0].name)).toBeInTheDocument();
|
expect(screen.getByText(MOCK_NODE.nodes[0].name)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
const items = screen.queryAllByTestId('node-list-item');
|
|
||||||
const item = within(items[0]);
|
|
||||||
const rows = item.getAllByRole('row');
|
|
||||||
|
|
||||||
const expectedValues = [
|
const grid = screen.getByTestId('validators-grid');
|
||||||
['Total stake', '14,182.45 (50.94%)'],
|
|
||||||
['Ranking score', '0.6785'],
|
|
||||||
['Stake score', '0.3393'],
|
|
||||||
['Performance score', '0.9999'],
|
|
||||||
['Voting score', '2,407.0000'],
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const [i, r] of rows.entries()) {
|
expect(
|
||||||
const row = within(r);
|
grid.querySelector('[role="gridcell"][col-id="validator"]')
|
||||||
const cell = row.getByRole('cell');
|
).toHaveTextContent('T-800 Terminator');
|
||||||
const header = row.getByRole('rowheader');
|
|
||||||
expect(header).toHaveTextContent(expectedValues[i][0]);
|
expect(
|
||||||
expect(cell).toHaveTextContent(expectedValues[i][1]);
|
grid.querySelector('[role="gridcell"][col-id="validator"] img')
|
||||||
}
|
).toHaveAttribute(
|
||||||
|
'src',
|
||||||
|
'https://upload.wikimedia.org/wikipedia/en/9/94/T-800_%28Model_101%29.png'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
grid.querySelector('[role="gridcell"][col-id="status"]')
|
||||||
|
).toHaveTextContent('Consensus');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
grid.querySelector('[role="gridcell"][col-id="totalStakeThisEpoch"]')
|
||||||
|
).toHaveTextContent('9,618.71');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
grid.querySelector('[role="gridcell"][col-id="share"]')
|
||||||
|
).toHaveTextContent('100%');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
grid.querySelector('[role="gridcell"][col-id="validatorStake"]')
|
||||||
|
).toHaveTextContent('9,618.71');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
grid.querySelector('[role="gridcell"][col-id="pendingStake"]')
|
||||||
|
).toHaveTextContent('0');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
grid.querySelector('[role="gridcell"][col-id="rankingScore"]')
|
||||||
|
).toHaveTextContent('0.46019');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
grid.querySelector('[role="gridcell"][col-id="stakeScore"]')
|
||||||
|
).toHaveTextContent('0.23010');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
grid.querySelector('[role="gridcell"][col-id="performanceScore"]')
|
||||||
|
).toHaveTextContent('1.00000');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
grid.querySelector('[role="gridcell"][col-id="votingPower"]')
|
||||||
|
).toHaveTextContent('2408');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,153 +1,228 @@
|
|||||||
import { gql, useQuery } from '@apollo/client';
|
import { gql, useQuery } from '@apollo/client';
|
||||||
import { Callout, Intent } from '@vegaprotocol/ui-toolkit';
|
import { useEffect, useMemo, useRef, forwardRef } from 'react';
|
||||||
import React from 'react';
|
import {
|
||||||
|
AgGridDynamic as AgGrid,
|
||||||
|
AsyncRenderer,
|
||||||
|
} from '@vegaprotocol/ui-toolkit';
|
||||||
|
import type { AgGridReact } from 'ag-grid-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { EpochCountdown } from '../../components/epoch-countdown';
|
import { EpochCountdown } from '../../components/epoch-countdown';
|
||||||
import { BigNumber } from '../../lib/bignumber';
|
import { BigNumber } from '../../lib/bignumber';
|
||||||
import { formatNumber } from '../../lib/format-number';
|
import { formatNumber } from '@vegaprotocol/react-helpers';
|
||||||
import { truncateMiddle } from '../../lib/truncate-middle';
|
import type { Nodes } from './__generated__/Nodes';
|
||||||
import type { Nodes, Nodes_nodes_rankingScore } from './__generated__/Nodes';
|
import type { Staking_epoch } from './__generated__/Staking';
|
||||||
import type { Staking_epoch, Staking_party } from './__generated__/Staking';
|
|
||||||
|
const VALIDATOR = 'validator';
|
||||||
|
const STATUS = 'status';
|
||||||
|
const TOTAL_STAKE_THIS_EPOCH = 'totalStakeThisEpoch';
|
||||||
|
const SHARE = 'share';
|
||||||
|
const VALIDATOR_STAKE = 'validatorStake';
|
||||||
|
const PENDING_STAKE = 'pendingStake';
|
||||||
|
const RANKING_SCORE = 'rankingScore';
|
||||||
|
const STAKE_SCORE = 'stakeScore';
|
||||||
|
const PERFORMANCE_SCORE = 'performanceScore';
|
||||||
|
const VOTING_POWER = 'votingPower';
|
||||||
|
|
||||||
export const NODES_QUERY = gql`
|
export const NODES_QUERY = gql`
|
||||||
query Nodes {
|
query Nodes {
|
||||||
nodes {
|
nodes {
|
||||||
|
avatarUrl
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
pubkey
|
pubkey
|
||||||
infoUrl
|
|
||||||
location
|
|
||||||
stakedByOperator
|
|
||||||
stakedByDelegates
|
|
||||||
stakedTotal
|
stakedTotal
|
||||||
pendingStake
|
|
||||||
stakedByOperatorFormatted @client
|
|
||||||
stakedByDelegatesFormatted @client
|
|
||||||
stakedTotalFormatted @client
|
stakedTotalFormatted @client
|
||||||
pendingStakeFormatted @client
|
pendingStake
|
||||||
epochData {
|
|
||||||
total
|
|
||||||
offline
|
|
||||||
online
|
|
||||||
}
|
|
||||||
status
|
|
||||||
rankingScore {
|
rankingScore {
|
||||||
rankingScore
|
rankingScore
|
||||||
stakeScore
|
stakeScore
|
||||||
performanceScore
|
performanceScore
|
||||||
votingPower
|
votingPower
|
||||||
stakeScore
|
status
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
nodeData {
|
nodeData {
|
||||||
stakedTotal
|
stakedTotal
|
||||||
stakedTotalFormatted @client
|
stakedTotalFormatted @client
|
||||||
totalNodes
|
|
||||||
inactiveNodes
|
|
||||||
validatingNodes
|
|
||||||
uptime
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const NodeListItemName = ({ children }: { children: React.ReactNode }) => (
|
|
||||||
<span className="mr-4 underline text-white">{children}</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
const NodeListTr = ({ children }: { children: React.ReactNode }) => (
|
|
||||||
<tr className="flex">{children}</tr>
|
|
||||||
);
|
|
||||||
|
|
||||||
const NodeListTh = ({ children }: { children: React.ReactNode }) => (
|
|
||||||
<th
|
|
||||||
role="rowheader"
|
|
||||||
className="flex-1 break-words py-1 pr-4 pl-0 text-white-60 font-normal"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</th>
|
|
||||||
);
|
|
||||||
|
|
||||||
const NodeListTd = ({ children }: { children: React.ReactNode }) => (
|
|
||||||
<td className="flex-1 break-words py-1 px-4 font-mono text-right">
|
|
||||||
{children}
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
|
|
||||||
interface NodeListProps {
|
interface NodeListProps {
|
||||||
epoch: Staking_epoch | undefined;
|
epoch: Staking_epoch | undefined;
|
||||||
party: Staking_party | null | undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NodeList = ({ epoch, party }: NodeListProps) => {
|
interface ValidatorRendererProps {
|
||||||
const { t } = useTranslation();
|
data: { validator: { avatarUrl: string; name: string } };
|
||||||
const { data, error, loading } = useQuery<Nodes>(NODES_QUERY);
|
}
|
||||||
|
|
||||||
const nodes = React.useMemo<NodeListItemProps[]>(() => {
|
const ValidatorRenderer = ({ data }: ValidatorRendererProps) => {
|
||||||
|
const { avatarUrl, name } = data.validator;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center">
|
||||||
|
{avatarUrl && (
|
||||||
|
<img
|
||||||
|
className="h-24 w-24 rounded-full mr-8"
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={`Avatar icon for ${name}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Custom styling to account for the scrollbar. This is needed because the
|
||||||
|
// AG Grid places the scrollbar over the bottom validator, which obstructs
|
||||||
|
const nodeListGridStyles = `
|
||||||
|
.ag-theme-balham-dark .ag-body-horizontal-scroll {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const NodeList = ({ epoch }: NodeListProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, error, loading, refetch } = useQuery<Nodes>(NODES_QUERY);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const epochInterval = setInterval(() => {
|
||||||
|
if (!epoch?.timestamps.expiry) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const expiry = new Date(epoch.timestamps.expiry).getTime();
|
||||||
|
|
||||||
|
if (now > expiry) {
|
||||||
|
refetch();
|
||||||
|
clearInterval(epochInterval);
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(epochInterval);
|
||||||
|
};
|
||||||
|
}, [epoch?.timestamps.expiry, refetch]);
|
||||||
|
|
||||||
|
const nodes = useMemo(() => {
|
||||||
if (!data?.nodes) return [];
|
if (!data?.nodes) return [];
|
||||||
|
|
||||||
const nodesWithPercentages = data.nodes.map((node) => {
|
return data.nodes.map(
|
||||||
const stakedTotal = new BigNumber(
|
({
|
||||||
data?.nodeData?.stakedTotalFormatted || 0
|
id,
|
||||||
);
|
name,
|
||||||
const stakedOnNode = new BigNumber(node.stakedTotalFormatted);
|
avatarUrl,
|
||||||
const stakedTotalPercentage =
|
stakedTotalFormatted,
|
||||||
stakedTotal.isEqualTo(0) || stakedOnNode.isEqualTo(0)
|
rankingScore: {
|
||||||
? '-'
|
rankingScore,
|
||||||
: stakedOnNode.dividedBy(stakedTotal).times(100).dp(2).toString() +
|
stakeScore,
|
||||||
'%';
|
status,
|
||||||
|
performanceScore,
|
||||||
|
votingPower,
|
||||||
|
},
|
||||||
|
pendingStake,
|
||||||
|
}) => {
|
||||||
|
const stakedTotal = new BigNumber(
|
||||||
|
data?.nodeData?.stakedTotalFormatted || 0
|
||||||
|
);
|
||||||
|
const stakedOnNode = new BigNumber(stakedTotalFormatted);
|
||||||
|
const stakedTotalPercentage =
|
||||||
|
stakedTotal.isEqualTo(0) || stakedOnNode.isEqualTo(0)
|
||||||
|
? '-'
|
||||||
|
: stakedOnNode.dividedBy(stakedTotal).times(100).dp(2).toString() +
|
||||||
|
'%';
|
||||||
|
const statusTranslated = t(`status-${status}`);
|
||||||
|
|
||||||
const userStake = party?.delegations?.length
|
return {
|
||||||
? party?.delegations
|
id,
|
||||||
?.filter((d) => d.node.id === node.id)
|
[VALIDATOR]: {
|
||||||
?.filter((d) => d.epoch === Number(epoch?.id))
|
avatarUrl,
|
||||||
.reduce((sum, d) => {
|
name,
|
||||||
const value = new BigNumber(d.amountFormatted);
|
},
|
||||||
return sum.plus(value);
|
[STATUS]: statusTranslated,
|
||||||
}, new BigNumber(0))
|
[TOTAL_STAKE_THIS_EPOCH]: formatNumber(stakedTotal, 2),
|
||||||
: new BigNumber(0);
|
[SHARE]: stakedTotalPercentage,
|
||||||
|
[VALIDATOR_STAKE]: formatNumber(stakedOnNode, 2),
|
||||||
const userStakePercentage =
|
[PENDING_STAKE]: pendingStake,
|
||||||
userStake.isEqualTo(0) || stakedOnNode.isEqualTo(0)
|
[RANKING_SCORE]: formatNumber(new BigNumber(rankingScore), 5),
|
||||||
? '-'
|
[STAKE_SCORE]: formatNumber(new BigNumber(stakeScore), 5),
|
||||||
: userStake.dividedBy(stakedOnNode).times(100).dp(2).toString() + '%';
|
[PERFORMANCE_SCORE]: formatNumber(new BigNumber(performanceScore), 5),
|
||||||
|
[VOTING_POWER]: votingPower,
|
||||||
return {
|
};
|
||||||
id: node.id,
|
}
|
||||||
name: node.name,
|
);
|
||||||
pubkey: node.pubkey,
|
}, [data, t]);
|
||||||
stakedTotal,
|
|
||||||
stakedOnNode,
|
const gridRef = useRef<AgGridReact | null>(null);
|
||||||
stakedTotalPercentage,
|
|
||||||
userStake,
|
const NodeListTable = forwardRef<AgGridReact>((_, ref) => {
|
||||||
userStakePercentage,
|
const colDefs = useMemo(
|
||||||
epoch,
|
() => [
|
||||||
scores: node.rankingScore,
|
{
|
||||||
};
|
field: VALIDATOR,
|
||||||
});
|
headerName: t('validator').toString(),
|
||||||
|
cellRenderer: ValidatorRenderer,
|
||||||
return nodesWithPercentages;
|
},
|
||||||
}, [data, epoch, party]);
|
{ field: STATUS, headerName: t('status').toString() },
|
||||||
|
{
|
||||||
if (error) {
|
field: TOTAL_STAKE_THIS_EPOCH,
|
||||||
return (
|
headerName: t('totalStakeThisEpoch').toString(),
|
||||||
<Callout intent={Intent.Danger} title={t('Something went wrong')}>
|
},
|
||||||
<pre>{error.message}</pre>
|
{ field: SHARE, headerName: t('share').toString() },
|
||||||
</Callout>
|
{ field: VALIDATOR_STAKE, headerName: t('validatorStake').toString() },
|
||||||
|
{ field: PENDING_STAKE, headerName: t('nextEpoch').toString() },
|
||||||
|
{ field: RANKING_SCORE, headerName: t('rankingScore').toString() },
|
||||||
|
{ field: STAKE_SCORE, headerName: t('stakeScore').toString() },
|
||||||
|
{
|
||||||
|
field: PERFORMANCE_SCORE,
|
||||||
|
headerName: t('performanceScore').toString(),
|
||||||
|
},
|
||||||
|
{ field: VOTING_POWER, headerName: t('votingPower').toString() },
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultColDef = useMemo(
|
||||||
|
() => ({
|
||||||
|
sortable: true,
|
||||||
|
}),
|
||||||
|
[]
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div data-testid="validators-grid">
|
||||||
<p>{t('Loading')}</p>
|
<AgGrid
|
||||||
|
domLayout="autoHeight"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
customThemeParams={nodeListGridStyles}
|
||||||
|
overlayNoRowsTemplate={t('noValidators')}
|
||||||
|
ref={ref}
|
||||||
|
rowData={nodes}
|
||||||
|
rowHeight={32}
|
||||||
|
columnDefs={colDefs}
|
||||||
|
defaultColDef={defaultColDef}
|
||||||
|
animateRows={true}
|
||||||
|
suppressCellFocus={true}
|
||||||
|
onGridReady={(event) => {
|
||||||
|
event.columnApi.applyColumnState({
|
||||||
|
state: [
|
||||||
|
{
|
||||||
|
colId: t('rankingScore'),
|
||||||
|
sort: 'desc',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
event.columnApi.autoSizeAllColumns(false);
|
||||||
|
}}
|
||||||
|
onCellClicked={(event) => {
|
||||||
|
navigate(event.data.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<AsyncRenderer loading={loading} error={error} data={nodes}>
|
||||||
{epoch && epoch.timestamps.start && epoch.timestamps.expiry && (
|
{epoch && epoch.timestamps.start && epoch.timestamps.expiry && (
|
||||||
<EpochCountdown
|
<EpochCountdown
|
||||||
id={epoch.id}
|
id={epoch.id}
|
||||||
@ -155,82 +230,7 @@ export const NodeList = ({ epoch, party }: NodeListProps) => {
|
|||||||
endDate={new Date(epoch.timestamps.expiry)}
|
endDate={new Date(epoch.timestamps.expiry)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ul role="list" className="mt-24">
|
<NodeListTable ref={gridRef} />
|
||||||
{nodes.map((n, i) => {
|
</AsyncRenderer>
|
||||||
return <NodeListItem key={i} {...n} />;
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface NodeListItemProps {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
stakedOnNode: BigNumber;
|
|
||||||
stakedTotalPercentage: string;
|
|
||||||
userStake: BigNumber;
|
|
||||||
userStakePercentage: string;
|
|
||||||
scores: Nodes_nodes_rankingScore;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NodeListItem = ({
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
stakedOnNode,
|
|
||||||
stakedTotalPercentage,
|
|
||||||
userStake,
|
|
||||||
userStakePercentage,
|
|
||||||
scores,
|
|
||||||
}: NodeListItemProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
className="break-words flex flex-col justify-between mb-16 last:mb-0"
|
|
||||||
data-testid="node-list-item"
|
|
||||||
>
|
|
||||||
<Link to={id}>
|
|
||||||
{name ? (
|
|
||||||
<NodeListItemName>{name}</NodeListItemName>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<NodeListItemName>{t('validatorTitleFallback')}</NodeListItemName>
|
|
||||||
<span
|
|
||||||
className="uppercase text-white-60"
|
|
||||||
title={`${t('id')}: ${id}`}
|
|
||||||
data-testid="node-list-item-name"
|
|
||||||
>
|
|
||||||
{truncateMiddle(id)}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
<table
|
|
||||||
className="flex-1 text-body border-collapse mt-4"
|
|
||||||
data-testid="node-list-item-table"
|
|
||||||
>
|
|
||||||
<tbody>
|
|
||||||
<NodeListTr>
|
|
||||||
<NodeListTh>{t('Total stake')}</NodeListTh>
|
|
||||||
<NodeListTd>
|
|
||||||
{formatNumber(stakedOnNode, 2)} ({stakedTotalPercentage})
|
|
||||||
</NodeListTd>
|
|
||||||
</NodeListTr>
|
|
||||||
{scores
|
|
||||||
? Object.entries(scores)
|
|
||||||
.filter(([key]) => key !== '__typename')
|
|
||||||
.map(([key, value]) => (
|
|
||||||
<NodeListTr key={`${id}_${key}`}>
|
|
||||||
<NodeListTh>{t(key)}</NodeListTh>
|
|
||||||
<NodeListTd>
|
|
||||||
{formatNumber(new BigNumber(value), 4)}
|
|
||||||
</NodeListTd>
|
|
||||||
</NodeListTr>
|
|
||||||
))
|
|
||||||
: null}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</li>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -9,7 +9,7 @@ export const Staking = ({ data }: { data?: StakingQueryResult }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section data-testid="staking-description" className="mb-24">
|
<section className="mb-20">
|
||||||
<Callout
|
<Callout
|
||||||
intent={Intent.Primary}
|
intent={Intent.Primary}
|
||||||
iconName="help"
|
iconName="help"
|
||||||
@ -33,18 +33,9 @@ export const Staking = ({ data }: { data?: StakingQueryResult }) => {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<StakingStepSelectNode data={data} />
|
<h2 className="text-h4 uppercase">{t('Nodes')}</h2>
|
||||||
|
<NodeList data-testid="node-list" epoch={data?.epoch} />
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const StakingStepSelectNode = ({
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
data?: StakingQueryResult;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<NodeList data-testid="node-list" epoch={data?.epoch} party={data?.party} />
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
@ -46,7 +46,10 @@ export const getDecimalSeparator = memoize(
|
|||||||
.find((part) => part.type === 'decimal')?.value
|
.find((part) => part.type === 'decimal')?.value
|
||||||
);
|
);
|
||||||
|
|
||||||
export const formatNumber = (rawValue: string | number, formatDecimals = 0) => {
|
export const formatNumber = (
|
||||||
|
rawValue: string | number | BigNumber,
|
||||||
|
formatDecimals = 0
|
||||||
|
) => {
|
||||||
return getNumberFormat(formatDecimals).format(Number(rawValue));
|
return getNumberFormat(formatDecimals).format(Number(rawValue));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user