From d7e82b075f438fbeb529eac26f83dc5af3328dd9 Mon Sep 17 00:00:00 2001 From: Gala Date: Fri, 6 May 2022 21:50:26 +0200 Subject: [PATCH] Network Explorer: add economic dynamics (#1223) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * adding delegators number info on mixnode details * add PM, Delegators and Avg. Uptime fields to the node list hardcoded * make delegations number dynamic * fixing bg color bug * wip node info statistics * adding basic tooltip new section and some ui * tooltip customisation * progress bar styles * remove not used import * fix info icons color * remove discord icon * Economic dynamics stats endpoint on the explorer API with dummy fixture data * fetching economic-dynamics-stats * Populating the endpoint with real data aggregated from validator api * Introduced new cache functionalities * using explorer-api data * adding marging profit * adding average update * Update network-explorer.yml * adding more info on mix nodes page * display only part of wallet and node id * typo * remove log * adding new values on node response and fix a typo * remove delegators number column * Endpoints for average mixnode uptime * remove TODO * Clippy * some ui fixes for percentage linear progress * GitHub Actions: build storybook for the Network Explorer and add to notification * Fix file extension to `.ts` * Fix up formatting and types * Add storybook * Add story for mix node details economics * Fix unused warning * adding percentage symbol on uptime in mix nodes * Change eslint config * some refactor * progress bar story * wip refactoring * more refactor * adding empty state to the story * change default values for empty state * refactor naming and progress bar contrast * adding hardcoded selection chance and update the storybook * adding selection chance stories * adding the progress bar back * tooltip button padding fix * Endpoints for average mixnode uptime * Fix unused warning * Rustfmt * moking selection chance response and new colors * remove log * fix camelCases issue * remove hardcoded code * remove avg_uptime at mixnodes table * Add jsonchema to uptimeresponse struct - add the route for avg_uptimes * adding space between words * update selection chance colours * adding the 2 missing tooltips * fix up uptimeresponse * fix duplicate entry * fmt * validator-client: use statement * explorer: PR requests Co-authored-by: JΔ™drzej StuczyΕ„ski Co-authored-by: Fouad Co-authored-by: Jon HΓ€ggblad Co-authored-by: Mark Sinclair Co-authored-by: tommy --- .github/workflows/network-explorer.yml | 14 ++ .../network-explorer/templates/success | 1 + explorer/.eslintrc.js | 14 ++ explorer/.eslintrc.json | 5 - explorer/.storybook/main.js | 63 +++++++ explorer/.storybook/preview.js | 56 ++++++ explorer/package.json | 16 +- explorer/src/api/index.ts | 4 + explorer/src/components/DetailTable.tsx | 5 +- .../src/components/MixNodes/BondBreakdown.tsx | 49 ++++- .../components/MixNodes/Economics/Columns.ts | 49 +++++ .../Economics/EconomicsProgress.stories.tsx | 31 ++++ .../MixNodes/Economics/EconomicsProgress.tsx | 38 ++++ .../Economics/MixNodeEconomics.stories.tsx | 129 +++++++++++++ .../src/components/MixNodes/Economics/Rows.ts | 55 ++++++ .../components/MixNodes/Economics/Table.tsx | 175 ++++++++++++++++++ .../components/MixNodes/Economics/index.ts | 3 + .../components/MixNodes/Economics/types.ts | 15 ++ explorer/src/components/MixNodes/index.ts | 3 + explorer/src/components/Socials.tsx | 9 +- .../src/components/delegatorsInfo/types.ts | 15 ++ explorer/src/context/mixnode.tsx | 21 ++- explorer/src/pages/MixnodeDetail/index.tsx | 11 ++ explorer/src/pages/Mixnodes/index.tsx | 28 ++- explorer/src/stories/Introduction.stories.mdx | 7 + explorer/src/typeDefs/explorer-api.ts | 10 + explorer/src/utils/index.ts | 9 + ts-packages/mui-theme/src/theme/common.ts | 15 ++ .../mui-theme/src/theme/network-explorer.ts | 10 + 29 files changed, 837 insertions(+), 23 deletions(-) create mode 100644 explorer/.eslintrc.js delete mode 100644 explorer/.eslintrc.json create mode 100644 explorer/.storybook/main.js create mode 100644 explorer/.storybook/preview.js create mode 100644 explorer/src/components/MixNodes/Economics/Columns.ts create mode 100644 explorer/src/components/MixNodes/Economics/EconomicsProgress.stories.tsx create mode 100644 explorer/src/components/MixNodes/Economics/EconomicsProgress.tsx create mode 100644 explorer/src/components/MixNodes/Economics/MixNodeEconomics.stories.tsx create mode 100644 explorer/src/components/MixNodes/Economics/Rows.ts create mode 100644 explorer/src/components/MixNodes/Economics/Table.tsx create mode 100644 explorer/src/components/MixNodes/Economics/index.ts create mode 100644 explorer/src/components/MixNodes/Economics/types.ts create mode 100644 explorer/src/components/delegatorsInfo/types.ts create mode 100644 explorer/src/stories/Introduction.stories.mdx diff --git a/.github/workflows/network-explorer.yml b/.github/workflows/network-explorer.yml index 447cec41ec..742a70c1b8 100644 --- a/.github/workflows/network-explorer.yml +++ b/.github/workflows/network-explorer.yml @@ -31,6 +31,8 @@ jobs: # continue-on-error: true - run: yarn && yarn build continue-on-error: true + - run: yarn storybook:build + name: Build storybook - name: Deploy branch to CI www continue-on-error: true uses: easingthemes/ssh-deploy@main @@ -42,6 +44,17 @@ jobs: REMOTE_USER: ${{ secrets.CI_WWW_REMOTE_USER }} TARGET: ${{ secrets.CI_WWW_REMOTE_TARGET }}/network-explorer-${{ env.GITHUB_REF_SLUG }} EXCLUDE: "/dist/, /node_modules/" + - name: Deploy storybook to CI www + continue-on-error: true + uses: easingthemes/ssh-deploy@main + env: + SSH_PRIVATE_KEY: ${{ secrets.CI_WWW_SSH_PRIVATE_KEY }} + ARGS: "-rltgoDzvO --delete" + SOURCE: "explorer/storybook-static/" + REMOTE_HOST: ${{ secrets.CI_WWW_REMOTE_HOST }} + REMOTE_USER: ${{ secrets.CI_WWW_REMOTE_USER }} + TARGET: ${{ secrets.CI_WWW_REMOTE_TARGET }}/ne-sb-${{ env.GITHUB_REF_SLUG }} + EXCLUDE: "/dist/, /node_modules/" - name: Keybase - Node Install run: npm install working-directory: .github/workflows/support-files @@ -51,6 +64,7 @@ jobs: NYM_PROJECT_NAME: "Network Explorer" NYM_CI_WWW_BASE: "${{ secrets.NYM_CI_WWW_BASE }}" NYM_CI_WWW_LOCATION: "network-explorer-${{ env.GITHUB_REF_SLUG }}" + NYM_CI_WWW_LOCATION_STORYBOOK: "ne-sb-${{ env.GITHUB_REF_SLUG }}" GIT_COMMIT_MESSAGE: "${{ github.event.head_commit.message }}" GIT_BRANCH: "${GITHUB_REF##*/}" KEYBASE_NYMBOT_USERNAME: "${{ secrets.KEYBASE_NYMBOT_USERNAME }}" diff --git a/.github/workflows/support-files/network-explorer/templates/success b/.github/workflows/support-files/network-explorer/templates/success index 7e7d02dd64..1f380b79b9 100644 --- a/.github/workflows/support-files/network-explorer/templates/success +++ b/.github/workflows/support-files/network-explorer/templates/success @@ -1,5 +1,6 @@ 🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩 > :rocket: {{ env.NYM_PROJECT_NAME }} ➑️➑️➑️➑️➑️ **View output:** https://{{ env.NYM_CI_WWW_LOCATION }}.{{ env.NYM_CI_WWW_BASE }}/ +> `storybook`: https://{{ env.NYM_CI_WWW_LOCATION_STORYBOOK }}.{{ env.NYM_CI_WWW_BASE }} > βœ… **SUCCESS** > `branch` {{ env.GITHUB_SERVER_URL }}/{{ env.GITHUB_REPOSITORY }}/tree/{{ env.GIT_BRANCH_NAME }} > `commit` {{ env.GITHUB_SERVER_URL }}/{{ env.GITHUB_REPOSITORY }}/commit/{{ env.GITHUB_SHA }} diff --git a/explorer/.eslintrc.js b/explorer/.eslintrc.js new file mode 100644 index 0000000000..5feba99084 --- /dev/null +++ b/explorer/.eslintrc.js @@ -0,0 +1,14 @@ +module.exports = { + extends: [ + '@nymproject/eslint-config-react-typescript' + ], + overrides: [ + { + files: ['*.ts'], + parserOptions: { + project: 'tsconfig.json', + tsconfigRootDir: __dirname, + } + } + ] +} diff --git a/explorer/.eslintrc.json b/explorer/.eslintrc.json deleted file mode 100644 index 7f84f4b42a..0000000000 --- a/explorer/.eslintrc.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "extends": [ - "@nymproject/eslint-config-react-typescript" - ] -} diff --git a/explorer/.storybook/main.js b/explorer/.storybook/main.js new file mode 100644 index 0000000000..1c2a975321 --- /dev/null +++ b/explorer/.storybook/main.js @@ -0,0 +1,63 @@ +/* eslint-disable no-param-reassign */ +const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin'); +const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); + +module.exports = { + stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], + addons: ['@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-interactions'], + framework: '@storybook/react', + core: { + builder: 'webpack5', + }, + // webpackFinal: async (config, { configType }) => { + // // `configType` has a value of 'DEVELOPMENT' or 'PRODUCTION' + // // You can change the configuration based on that. + // // 'PRODUCTION' is used when building the static version of storybook. + webpackFinal: async (config) => { + config.module.rules.forEach((rule) => { + // look for SVG import rule and replace + // NOTE: the rule before modification is /\.(svg|ico|jpg|jpeg|png|apng|gif|eot|otf|webp|ttf|woff|woff2|cur|ani|pdf)(\?.*)?$/ + if (rule.test?.toString().includes('svg')) { + rule.test = /\.(ico|jpg|jpeg|png|apng|gif|eot|otf|webp|ttf|woff|woff2|cur|ani|pdf)(\?.*)?$/; + } + }); + + // handle asset loading with this + config.module.rules.unshift({ + test: /\.svg(\?.*)?$/i, + issuer: /\.[jt]sx?$/, + use: ['@svgr/webpack'], + }); + + config.resolve.extensions = ['.tsx', '.ts', '.js']; + config.resolve.plugins = [new TsconfigPathsPlugin()]; + + config.resolve.fallback = { + fs: false, + tls: false, + path: false, + http: false, + https: false, + stream: false, + crypto: false, + net: false, + zlib: false, + }; + + config.plugins.push(new ForkTsCheckerWebpackPlugin({ + typescript: { + mode: 'write-references', + diagnosticOptions: { + semantic: true, + syntactic: true, + }, + }, + })); + + // Return the altered config + return config; + }, + features: { + emotionAlias: false, + }, +}; diff --git a/explorer/.storybook/preview.js b/explorer/.storybook/preview.js new file mode 100644 index 0000000000..5d97f3b5ab --- /dev/null +++ b/explorer/.storybook/preview.js @@ -0,0 +1,56 @@ +/* eslint-disable react/react-in-jsx-scope */ +import { NymNetworkExplorerThemeProvider } from '@nymproject/mui-theme'; +import { Box } from '@mui/material'; + +export const parameters = { + actions: { argTypesRegex: "^on[A-Z].*" }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, +} + +const withThemeProvider = (Story, context) => ( +
+
+ + theme.palette.background.default, + color: (theme) => theme.palette.text.primary, + }} + > + + + +

Light mode

+
+
+
+
+ + theme.palette.background.default, + color: (theme) => theme.palette.text.primary, + }} + > + + + +

Dark mode

+
+
+
+
+); + +export const decorators = [withThemeProvider]; diff --git a/explorer/package.json b/explorer/package.json index 053022471d..95207ba309 100644 --- a/explorer/package.json +++ b/explorer/package.json @@ -30,8 +30,15 @@ "use-clipboard-copy": "^0.2.0" }, "devDependencies": { + "@babel/core": "^7.15.0", "@nymproject/eslint-config-react-typescript": "^1.0.0", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.4", + "@storybook/addon-actions": "^6.4.19", + "@storybook/addon-essentials": "^6.4.19", + "@storybook/addon-interactions": "^6.4.19", + "@storybook/addon-links": "^6.4.19", + "@storybook/react": "^6.4.19", + "@storybook/testing-library": "^0.0.9", "@svgr/webpack": "^6.1.1", "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^12.0.0", @@ -95,10 +102,13 @@ "build:serve": "npx serve dist", "test": "jest", "test:watch": "jest --watch", - "tsc": "tsc", - "tsc:watch": "tsc --watch", + "tsc": "tsc --noEmit true", + "tsc:watch": "tsc --watch --noEmit true", "lint": "eslint src", - "lint:fix": "eslint src --fix" + "lint:fix": "eslint src --fix", + "prestorybook": "yarn --cwd .. build", + "storybook": "start-storybook -p 6006", + "storybook:build": "build-storybook" }, "browserslist": { "production": [ diff --git a/explorer/src/api/index.ts b/explorer/src/api/index.ts index d0949d1796..8c0c087f46 100644 --- a/explorer/src/api/index.ts +++ b/explorer/src/api/index.ts @@ -18,6 +18,7 @@ import { MixNodeResponse, MixNodeResponseItem, MixnodeStatus, + MixNodeEconomicDynamicsStatsResponse, StatsResponse, StatusResponse, SummaryOverviewResponse, @@ -122,6 +123,9 @@ export class Api { static fetchMixnodeDescriptionById = async (id: string): Promise => (await fetch(`${MIXNODE_API}/${id}/description`)).json(); + static fetchMixnodeEconomicDynamicsStatsById = async (id: string): Promise => + (await fetch(`${MIXNODE_API}/${id}/economic-dynamics-stats`)).json(); + static fetchStatusById = async (id: string): Promise => (await fetch(`${MIXNODE_PING}/${id}`)).json(); static fetchUptimeStoryById = async (id: string): Promise => diff --git a/explorer/src/components/DetailTable.tsx b/explorer/src/components/DetailTable.tsx index 9dc7d34c74..81bd6982f1 100644 --- a/explorer/src/components/DetailTable.tsx +++ b/explorer/src/components/DetailTable.tsx @@ -12,12 +12,13 @@ export type ColumnsType = { headerAlign: string; flex?: number; width?: number; + tooltipInfo?: string; }; -export interface UniversalTableProps { +export interface UniversalTableProps { tableName: string; columnsData: ColumnsType[]; - rows: any[]; + rows: T[]; } function formatCellValues(val: string | number, field: string) { diff --git a/explorer/src/components/MixNodes/BondBreakdown.tsx b/explorer/src/components/MixNodes/BondBreakdown.tsx index 4332b182e1..90789ab9bf 100644 --- a/explorer/src/components/MixNodes/BondBreakdown.tsx +++ b/explorer/src/components/MixNodes/BondBreakdown.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Alert, Box, CircularProgress, useMediaQuery } from '@mui/material'; +import { Alert, Box, CircularProgress, useMediaQuery, Typography } from '@mui/material'; import { useTheme } from '@mui/material/styles'; import Table from '@mui/material/Table'; import TableBody from '@mui/material/TableBody'; @@ -127,21 +127,62 @@ export const BondBreakdownTable: React.FC = () => { sx={{ maxHeight: 400, overflowY: 'scroll', + p: 2, + background: theme.palette.background.paper, }} > + + + Delegations   + + + {`(${delegations?.data?.length} delegators)`} + + - + Delegators - + Stake ; + +const Template: ComponentStory = (args) => ; + +export const Empty = Template.bind({}); +Empty.args = {}; + +export const OverThreshold = Template.bind({}); +OverThreshold.args = { + threshold: 100, + value: 120, +}; + +export const UnderThreshold = Template.bind({}); +UnderThreshold.args = { + threshold: 100, + value: 80, +}; + +export const OnThreshold = Template.bind({}); +OnThreshold.args = { + threshold: 100, + value: 100, +}; diff --git a/explorer/src/components/MixNodes/Economics/EconomicsProgress.tsx b/explorer/src/components/MixNodes/Economics/EconomicsProgress.tsx new file mode 100644 index 0000000000..1779d8d18f --- /dev/null +++ b/explorer/src/components/MixNodes/Economics/EconomicsProgress.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; +import LinearProgress, { LinearProgressProps } from '@mui/material/LinearProgress'; +import { useTheme } from '@mui/material/styles'; +import { Box } from '@mui/system'; + +const parseToNumber = (value: number | undefined | string) => + typeof value === 'string' ? parseInt(value || '', 10) : value || 0; + +export const EconomicsProgress: React.FC< + LinearProgressProps & { + threshold?: number; + } +> = ({ threshold, ...props }) => { + const theme = useTheme(); + const { value } = props; + + const valueNumber: number = parseToNumber(value); + const thresholdNumber: number = parseToNumber(threshold); + const percentageColor = valueNumber > (threshold || 100) ? 'warning' : 'inherit'; + const percentageToDisplay = Math.min(valueNumber, thresholdNumber); + + return ( + (threshold || 100) ? theme.palette.warning.main : theme.palette.nym.wallet.fee, + }} + > + + + ); +}; diff --git a/explorer/src/components/MixNodes/Economics/MixNodeEconomics.stories.tsx b/explorer/src/components/MixNodes/Economics/MixNodeEconomics.stories.tsx new file mode 100644 index 0000000000..be9c4c94f0 --- /dev/null +++ b/explorer/src/components/MixNodes/Economics/MixNodeEconomics.stories.tsx @@ -0,0 +1,129 @@ +import * as React from 'react'; +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { DelegatorsInfoTable } from './Table'; +import { EconomicsInfoColumns } from './Columns'; +import { EconomicsInfoRowWithIndex } from './types'; + +export default { + title: 'Mix Node Detail/Economics', + component: DelegatorsInfoTable, +} as ComponentMeta; + +const row: EconomicsInfoRowWithIndex = { + id: 1, + selectionChance: { + value: 'High', + }, + avgUptime: { + value: '65 %', + }, + estimatedOperatorReward: { + value: '80000.123456 NYM', + }, + estimatedTotalReward: { + value: '80000.123456 NYM', + }, + profitMargin: { + value: '10 %', + }, + stakeSaturation: { + value: '80 %', + progressBarValue: 80, + }, +}; + +const rowVeryHighProbabilitySelection: EconomicsInfoRowWithIndex = { + ...row, + selectionChance: { + value: 'Very High', + }, +}; + +const rowModerateProbabilitySelection: EconomicsInfoRowWithIndex = { + ...row, + selectionChance: { + value: 'Moderate', + }, +}; + +const rowLowProbabilitySelection: EconomicsInfoRowWithIndex = { + ...row, + selectionChance: { + value: 'Low', + }, +}; + +const rowVeryLowProbabilitySelection: EconomicsInfoRowWithIndex = { + ...row, + selectionChance: { + value: 'Very Low', + }, +}; + +const emptyRow: EconomicsInfoRowWithIndex = { + id: 1, + selectionChance: { + value: '-', + progressBarValue: 0, + }, + avgUptime: { + value: '-', + }, + estimatedOperatorReward: { + value: '-', + }, + estimatedTotalReward: { + value: '-', + }, + profitMargin: { + value: '-', + }, + stakeSaturation: { + value: '-', + progressBarValue: 0, + }, +}; + +const Template: ComponentStory = (args) => ; + +export const Empty = Template.bind({}); +Empty.args = { + rows: [emptyRow], + columnsData: EconomicsInfoColumns, + tableName: 'storybook', +}; + +export const selectionChanceVeryHigh = Template.bind({}); +selectionChanceVeryHigh.args = { + rows: [rowVeryHighProbabilitySelection], + columnsData: EconomicsInfoColumns, + tableName: 'storybook', +}; + +export const selectionChanceHigh = Template.bind({}); +selectionChanceHigh.args = { + rows: [row], + columnsData: EconomicsInfoColumns, + tableName: 'storybook', +}; + +export const selectionChanceModerate = Template.bind({}); +selectionChanceModerate.args = { + rows: [rowModerateProbabilitySelection], + columnsData: EconomicsInfoColumns, + tableName: 'storybook', +}; + +export const selectionChanceLow = Template.bind({}); +selectionChanceLow.args = { + rows: [rowLowProbabilitySelection], + columnsData: EconomicsInfoColumns, + tableName: 'storybook', +}; + +export const selectionChanceVeryLow = Template.bind({}); +selectionChanceVeryLow.args = { + rows: [rowVeryLowProbabilitySelection], + columnsData: EconomicsInfoColumns, + tableName: 'storybook', +}; diff --git a/explorer/src/components/MixNodes/Economics/Rows.ts b/explorer/src/components/MixNodes/Economics/Rows.ts new file mode 100644 index 0000000000..0a620318d7 --- /dev/null +++ b/explorer/src/components/MixNodes/Economics/Rows.ts @@ -0,0 +1,55 @@ +import { currencyToString } from '../../../utils/currency'; +import { useMixnodeContext } from '../../../context/mixnode'; +import { ApiState, MixNodeEconomicDynamicsStatsResponse } from '../../../typeDefs/explorer-api'; +import { EconomicsInfoRowWithIndex } from './types'; + +const selectionChance = (economicDynamicsStats: ApiState | undefined) => { + const inclusionProbability = economicDynamicsStats?.data?.active_set_inclusion_probability; + switch (inclusionProbability) { + case 'High': + case 'Moderate': + case 'Low': + return inclusionProbability; + case 'VeryHigh': + return 'Very High'; + case 'VeryLow': + return 'Very Low'; + default: + return '-'; + } +}; + +export const EconomicsInfoRows = (): EconomicsInfoRowWithIndex => { + const { economicDynamicsStats, mixNode } = useMixnodeContext(); + + const estimatedNodeRewards = + currencyToString((economicDynamicsStats?.data?.estimated_total_node_reward || '').toString()) || '-'; + const estimatedOperatorRewards = + currencyToString((economicDynamicsStats?.data?.estimated_operator_reward || '').toString()) || '-'; + const stakeSaturation = economicDynamicsStats?.data?.stake_saturation || '-'; + const profitMargin = mixNode?.data?.mix_node.profit_margin_percent || '-'; + const avgUptime = economicDynamicsStats?.data?.current_interval_uptime; + + return { + id: 1, + estimatedTotalReward: { + value: estimatedNodeRewards, + }, + estimatedOperatorReward: { + value: estimatedOperatorRewards, + }, + selectionChance: { + value: selectionChance(economicDynamicsStats), + }, + stakeSaturation: { + progressBarValue: typeof stakeSaturation === 'number' ? stakeSaturation * 100 : 0, + value: typeof stakeSaturation === 'number' ? `${(stakeSaturation * 100).toFixed(2)} %` : '-', + }, + profitMargin: { + value: profitMargin ? `${profitMargin} %` : '-', + }, + avgUptime: { + value: avgUptime ? `${avgUptime} %` : '-', + }, + }; +}; diff --git a/explorer/src/components/MixNodes/Economics/Table.tsx b/explorer/src/components/MixNodes/Economics/Table.tsx new file mode 100644 index 0000000000..9c3f1ab2b4 --- /dev/null +++ b/explorer/src/components/MixNodes/Economics/Table.tsx @@ -0,0 +1,175 @@ +import * as React from 'react'; +import { + IconButton, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, + useMediaQuery, +} from '@mui/material'; +import { Box } from '@mui/system'; +import { styled, useTheme, Theme } from '@mui/material/styles'; +import Tooltip, { tooltipClasses, TooltipProps } from '@mui/material/Tooltip'; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; +import { EconomicsRowsType, EconomicsInfoRowWithIndex } from './types'; +import { EconomicsProgress } from './EconomicsProgress'; +import { cellStyles } from '../../Universal-DataGrid'; +import { UniversalTableProps } from '../../DetailTable'; + +const tooltipBackGroundColor = '#A0AED1'; +const threshold = 100; + +const textColour = (value: EconomicsRowsType, field: string, theme: Theme) => { + const progressBarValue = value?.progressBarValue || 0; + const fieldValue = value.value; + + if (progressBarValue > 100) { + return theme.palette.warning.main; + } + if (field === 'selectionChance') { + switch (fieldValue) { + case 'High': + case 'Very High': + return theme.palette.nym.networkExplorer.selectionChance.overModerate; + case 'Moderate': + return theme.palette.nym.networkExplorer.selectionChance.moderate; + case 'Low': + case 'Very Low': + return theme.palette.nym.networkExplorer.selectionChance.underModerate; + default: + return theme.palette.nym.wallet.fee; + } + } + return theme.palette.nym.wallet.fee; +}; + +const formatCellValues = (value: EconomicsRowsType, field: string, theme: Theme) => { + const isTablet = useMediaQuery(theme.breakpoints.down('lg')); + if (value.progressBarValue) { + return ( + + + {value.value} + + + + ); + } + return ( + + + {value.value} + + + ); +}; + +export const DelegatorsInfoTable: React.FC> = ({ + tableName, + columnsData, + rows, +}) => { + const theme = useTheme(); + + const CustomTooltip = styled(({ className, ...props }: TooltipProps) => ( + + ))({ + [`& .${tooltipClasses.tooltip}`]: { + maxWidth: 230, + background: tooltipBackGroundColor, + color: theme.palette.nym.networkExplorer.nav.hover, + }, + }); + + return ( + +
+ + + {columnsData?.map(({ field, title, flex, tooltipInfo }) => ( + + + {tooltipInfo && ( + + + + + + + + )} + {title} + + + ))} + + + + {rows?.map((eachRow) => ( + + {columnsData?.map((_, index: number) => { + const { field } = columnsData[index]; + const value: EconomicsRowsType = (eachRow as any)[field]; + + return ( + + {formatCellValues(value, columnsData[index].field, theme)} + + ); + })} + + ))} + +
+ + ); +}; diff --git a/explorer/src/components/MixNodes/Economics/index.ts b/explorer/src/components/MixNodes/Economics/index.ts new file mode 100644 index 0000000000..6dec752246 --- /dev/null +++ b/explorer/src/components/MixNodes/Economics/index.ts @@ -0,0 +1,3 @@ +export { DelegatorsInfoTable } from './Table'; +export { EconomicsInfoColumns } from './Columns'; +export { EconomicsInfoRows } from './Rows'; diff --git a/explorer/src/components/MixNodes/Economics/types.ts b/explorer/src/components/MixNodes/Economics/types.ts new file mode 100644 index 0000000000..b82b35f139 --- /dev/null +++ b/explorer/src/components/MixNodes/Economics/types.ts @@ -0,0 +1,15 @@ +export type EconomicsRowsType = { + progressBarValue?: number; + value: string; +}; + +export interface EconomicsInfoRow { + estimatedTotalReward: EconomicsRowsType; + estimatedOperatorReward: EconomicsRowsType; + selectionChance: EconomicsRowsType; + stakeSaturation: EconomicsRowsType; + profitMargin: EconomicsRowsType; + avgUptime: EconomicsRowsType; +} + +export type EconomicsInfoRowWithIndex = EconomicsInfoRow & { id: number }; diff --git a/explorer/src/components/MixNodes/index.ts b/explorer/src/components/MixNodes/index.ts index 2745785d41..d803c2e5ea 100644 --- a/explorer/src/components/MixNodes/index.ts +++ b/explorer/src/components/MixNodes/index.ts @@ -11,6 +11,7 @@ export type MixnodeRowType = { self_percentage: string; host: string; layer: string; + profit_percentage: string; }; export function mixnodeToGridRow(arrayOfMixnodes?: MixNodeResponse): MixnodeRowType[] { @@ -22,6 +23,7 @@ export function mixNodeResponseItemToMixnodeRowType(item: MixNodeResponseItem): const delegations = Number(item.total_delegation.amount) || 0; const totalBond = pledge + delegations; const selfPercentage = ((pledge * 100) / totalBond).toFixed(2); + const profitPercentage = item.mix_node.profit_margin_percent || 0; return { id: item.owner, status: item.status, @@ -32,5 +34,6 @@ export function mixNodeResponseItemToMixnodeRowType(item: MixNodeResponseItem): self_percentage: selfPercentage, host: item?.mix_node?.host || '', layer: item?.layer || '', + profit_percentage: `${profitPercentage}%`, }; } diff --git a/explorer/src/components/Socials.tsx b/explorer/src/components/Socials.tsx index f84aa7bcc3..ded74ffe98 100644 --- a/explorer/src/components/Socials.tsx +++ b/explorer/src/components/Socials.tsx @@ -22,9 +22,12 @@ export const Socials: React.FC<{ isFooter?: boolean }> = ({ isFooter }) => { - - - + {false && ( + + + + )} + diff --git a/explorer/src/components/delegatorsInfo/types.ts b/explorer/src/components/delegatorsInfo/types.ts new file mode 100644 index 0000000000..b0e3fd6e99 --- /dev/null +++ b/explorer/src/components/delegatorsInfo/types.ts @@ -0,0 +1,15 @@ +export type RowsType = { + value?: string | number; + visualProgressValue?: number; +}; + +export interface DelegatorsInfoRow { + estimated_total_reward: RowsType; + estimated_operator_reward: RowsType; + active_set_probability: RowsType; + stake_saturation: RowsType; + profit_margin: RowsType; + avg_uptime: RowsType; +} + +export type DelegatorsInfoRowWithIndex = DelegatorsInfoRow & { id: number }; diff --git a/explorer/src/context/mixnode.tsx b/explorer/src/context/mixnode.tsx index c71036e61d..c84ee60517 100644 --- a/explorer/src/context/mixnode.tsx +++ b/explorer/src/context/mixnode.tsx @@ -3,6 +3,7 @@ import { ApiState, DelegationsResponse, MixNodeDescriptionResponse, + MixNodeEconomicDynamicsStatsResponse, MixNodeResponseItem, StatsResponse, StatusResponse, @@ -18,6 +19,7 @@ import { mixNodeResponseItemToMixnodeRowType, MixnodeRowType } from '../componen interface MixnodeState { delegations?: ApiState; description?: ApiState; + economicDynamicsStats?: ApiState; mixNode?: ApiState; mixNodeRow?: MixnodeRowType; stats?: ApiState; @@ -71,6 +73,13 @@ export const MixnodeContextProvider: React.FC = ({ 'Failed to fetch mixnode description', ); + const [economicDynamicsStats, fetchEconomicDynamicsStats, clearEconomicDynamicsStats] = + useApiState( + mixNodeIdentityKey, + Api.fetchMixnodeEconomicDynamicsStatsById, + 'Failed to fetch mixnode dynamics stats by id', + ); + const [uptimeStory, fetchUptimeHistory, clearUptimeHistory] = useApiState( mixNodeIdentityKey, Api.fetchUptimeStoryById, @@ -84,6 +93,7 @@ export const MixnodeContextProvider: React.FC = ({ clearStatus(); clearStats(); clearDescription(); + clearEconomicDynamicsStats(); clearUptimeHistory(); // fetch the mixnode, then get all the other stuff @@ -93,7 +103,14 @@ export const MixnodeContextProvider: React.FC = ({ return; } setMixnodeRow(mixNodeResponseItemToMixnodeRowType(value.data)); - Promise.all([fetchDelegations(), fetchStatus(), fetchStats(), fetchDescription(), fetchUptimeHistory()]); + Promise.all([ + fetchDelegations(), + fetchStatus(), + fetchStats(), + fetchDescription(), + fetchEconomicDynamicsStats(), + fetchUptimeHistory(), + ]); }); }, [mixNodeIdentityKey]); @@ -103,6 +120,7 @@ export const MixnodeContextProvider: React.FC = ({ mixNode, mixNodeRow, description, + economicDynamicsStats, stats, status, uptimeStory, @@ -113,6 +131,7 @@ export const MixnodeContextProvider: React.FC = ({ mixNode, mixNodeRow, description, + economicDynamicsStats, stats, status, uptimeStory, diff --git a/explorer/src/pages/MixnodeDetail/index.tsx b/explorer/src/pages/MixnodeDetail/index.tsx index a9fd29dbee..50cba3c790 100644 --- a/explorer/src/pages/MixnodeDetail/index.tsx +++ b/explorer/src/pages/MixnodeDetail/index.tsx @@ -3,6 +3,7 @@ import { Alert, AlertTitle, Box, CircularProgress, Grid, Typography } from '@mui import { useParams } from 'react-router-dom'; import { ColumnsType, DetailTable } from '../../components/DetailTable'; import { BondBreakdownTable } from '../../components/MixNodes/BondBreakdown'; +import { DelegatorsInfoTable, EconomicsInfoColumns, EconomicsInfoRows } from '../../components/MixNodes/Economics'; import { ComponentError } from '../../components/ComponentError'; import { ContentCard } from '../../components/ContentCard'; import { TwoColSmallTable } from '../../components/TwoColSmallTable'; @@ -82,6 +83,16 @@ const PageMixnodeDetailWithState: React.FC = () => { + + + + + + diff --git a/explorer/src/pages/Mixnodes/index.tsx b/explorer/src/pages/Mixnodes/index.tsx index e2919e430b..1386aa38cc 100644 --- a/explorer/src/pages/Mixnodes/index.tsx +++ b/explorer/src/pages/Mixnodes/index.tsx @@ -16,6 +16,7 @@ import { CustomColumnHeading } from '../../components/CustomColumnHeading'; import { Title } from '../../components/Title'; import { cellStyles, UniversalDataGrid } from '../../components/Universal-DataGrid'; import { currencyToString } from '../../utils/currency'; +import { splice } from '../../utils'; import { getMixNodeStatusColor } from '../../components/MixNodes/Status'; import { MixNodeStatusDropdown } from '../../components/MixNodes/StatusDropdown'; @@ -85,7 +86,7 @@ export const PageMixnodes: React.FC = () => { field: 'owner', headerName: 'Owner', renderHeader: () => , - width: 380, + width: 200, headerAlign: 'left', headerClassName: 'MuiDataGrid-header-override', renderCell: (params: GridRenderCellParams) => ( @@ -95,7 +96,7 @@ export const PageMixnodes: React.FC = () => { sx={getCellStyles(theme, params.row)} data-testid="big-dipper-link" > - {params.value} + {splice(7, 29, params.value)} ), }, @@ -104,7 +105,7 @@ export const PageMixnodes: React.FC = () => { headerName: 'Identity Key', renderHeader: () => , headerClassName: 'MuiDataGrid-header-override', - width: 380, + width: 180, headerAlign: 'left', renderCell: (params: GridRenderCellParams) => ( <> @@ -119,7 +120,7 @@ export const PageMixnodes: React.FC = () => { to={`/network-components/mixnode/${params.value}`} data-testid="identity-link" > - {params.value} + {splice(7, 29, params.value)} ), @@ -130,7 +131,7 @@ export const PageMixnodes: React.FC = () => { renderHeader: () => , type: 'number', headerClassName: 'MuiDataGrid-header-override', - width: 150, + width: 200, headerAlign: 'left', renderCell: (params: GridRenderCellParams) => ( { ), }, + { + field: 'profit_percentage', + headerName: 'Profit Margin', + renderHeader: () => , + headerClassName: 'MuiDataGrid-header-override', + width: 140, + headerAlign: 'left', + renderCell: (params: GridRenderCellParams) => ( + + {params.value} + + ), + }, ]; const handlePageSize = (event: SelectChangeEvent) => { diff --git a/explorer/src/stories/Introduction.stories.mdx b/explorer/src/stories/Introduction.stories.mdx new file mode 100644 index 0000000000..dafab13de0 --- /dev/null +++ b/explorer/src/stories/Introduction.stories.mdx @@ -0,0 +1,7 @@ +import { Meta } from '@storybook/addon-docs'; + + + +# Nym Network Explorer Storybook + +This is the Storybook for the Nym Network Explorer. diff --git a/explorer/src/typeDefs/explorer-api.ts b/explorer/src/typeDefs/explorer-api.ts index 482db72596..9285f78b42 100644 --- a/explorer/src/typeDefs/explorer-api.ts +++ b/explorer/src/typeDefs/explorer-api.ts @@ -203,3 +203,13 @@ export type UptimeStoryResponse = { identity: string; owner: string; }; + +export type MixNodeEconomicDynamicsStatsResponse = { + stake_saturation: number; + active_set_inclusion_probability: 'VeryHigh' | 'High' | 'Moderate' | 'Low' | 'VeryLow'; + reserve_set_inclusion_probability: 'VeryHigh' | 'High' | 'Moderate' | 'Low' | 'VeryLow'; + estimated_total_node_reward: number; + estimated_operator_reward: number; + estimated_delegators_reward: number; + current_interval_uptime: number; +}; diff --git a/explorer/src/utils/index.ts b/explorer/src/utils/index.ts index f7e0768cbf..a44515b10c 100644 --- a/explorer/src/utils/index.ts +++ b/explorer/src/utils/index.ts @@ -36,3 +36,12 @@ export function countryDataToGridRow(countriesData: CountryData[]): CountryDataR const sorted = formatted.sort((a, b) => (a.nodes < b.nodes ? 1 : -1)); return sorted; } + +export const splice = (start: number, deleteCount: number, address?: string): string => { + if (address) { + const array = address.split(''); + array.splice(start, deleteCount, '...'); + return array.join(''); + } + return ''; +}; diff --git a/ts-packages/mui-theme/src/theme/common.ts b/ts-packages/mui-theme/src/theme/common.ts index e460aeee50..798991863d 100644 --- a/ts-packages/mui-theme/src/theme/common.ts +++ b/ts-packages/mui-theme/src/theme/common.ts @@ -46,6 +46,11 @@ export interface NymPaletteVariant { standby: string; }; }; + selectionChance: { + overModerate: string; + moderate: string; + underModerate: string; + }, } // ------------------------------------------------------------------------------------------------------------------- @@ -104,6 +109,11 @@ export const darkMode: NymPaletteVariant = { standby: '#5FD7EF', }, }, + selectionChance: { + overModerate: '#20D073', + moderate: '#EBA53D', + underModerate: '#DA465B', + }, }; /** @@ -132,6 +142,11 @@ export const lightMode: NymPaletteVariant = { standby: '#55C1D7', }, }, + selectionChance: { + overModerate: '#20D073', + moderate: '#EBA53D', + underModerate: '#DA465B', + }, }; /** diff --git a/ts-packages/mui-theme/src/theme/network-explorer.ts b/ts-packages/mui-theme/src/theme/network-explorer.ts index 55f624cd71..f633b8ccf9 100644 --- a/ts-packages/mui-theme/src/theme/network-explorer.ts +++ b/ts-packages/mui-theme/src/theme/network-explorer.ts @@ -40,6 +40,11 @@ export interface NetworkExplorerPalette { inactive: string; }; }; + selectionChance: { + overModerate: string; + moderate: string; + underModerate: string; + } }; } @@ -84,6 +89,11 @@ export const networkExplorerPalette = (variant: NymPaletteVariant): NetworkExplo inactive: variant.text.main, }, }, + selectionChance: { + overModerate: variant.selectionChance.overModerate, + moderate: variant.selectionChance.moderate, + underModerate: variant.selectionChance.underModerate, + } }, });