Explorer NextJS Rebuild (#4534)

* bootstrap next app + add overview page

* fix AssetList type

* fix up nav stuff

* Refactor Nav component and add network components pages

* Refactor WorldMap component and update TelegramIcon, GitHubIcon, NymVpnIcon, DiscordIcon, and TwitterIcon components

* add service providers page

* mixnodes page

* delegations page + use material react table for all tables

* nodes map page

* Refactor StyledLink component and remove unnecessary console.log statements

* Refactor ESLint configuration, remove unused dependencies, and update component imports

* update deps

* Refactor imports and update dependencies

* fix dark mode

* build single mixnode page

* build single gateway page

* Refactor handleOnDelegate function to use useCallback in mixnodes page.tsx

* Add defaults for constants

---------

Co-authored-by: Mark Sinclair <mmsinclair@users.noreply.github.com>
This commit is contained in:
Fouad
2024-05-16 16:12:06 +01:00
committed by GitHub
parent a6fda391ae
commit e5f41731ae
130 changed files with 52209 additions and 3547 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals"]
}
+36
View File
@@ -0,0 +1,36 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
+11
View File
@@ -0,0 +1,11 @@
import React from 'react'
import { Navbar } from './components/Nav/Navbar'
import { Providers } from './providers'
const App = ({ children }: { children: React.ReactNode }) => (
<Providers>
<Navbar>{children}</Navbar>
</Providers>
)
export { App }
+34
View File
@@ -0,0 +1,34 @@
// master APIs
export const API_BASE_URL = process.env.NEXT_PUBLIC_EXPLORER_API_URL || 'https://explorer.nymtech.net/api/v1';
export const NYM_API_BASE_URL = process.env.NEXT_PUBLIC_NYM_API_URL || 'https://validator.nymtech.net';
export const NYX_RPC_BASE_URL = process.env.NEXT_PUBLIC_NYX_RPC_BASE_URL || 'https://rpc.nymtech.net';
export const VALIDATOR_BASE_URL = process.env.NEXT_PUBLIC_VALIDATOR_URL || 'https://rpc.nymtech.net';
export const BIG_DIPPER = process.env.NEXT_PUBLIC_BIG_DIPPER_URL || 'https://nym.explorers.guru';
// specific API routes
export const OVERVIEW_API = `${API_BASE_URL}/overview`;
export const MIXNODE_PING = `${API_BASE_URL}/ping`;
export const MIXNODES_API = `${API_BASE_URL}/mix-nodes`;
export const MIXNODE_API = `${API_BASE_URL}/mix-node`;
export const GATEWAYS_EXPLORER_API = `${API_BASE_URL}/gateways`;
export const GATEWAYS_API = `${NYM_API_BASE_URL}/api/v1/status/gateways/detailed`;
export const VALIDATORS_API = `${NYX_RPC_BASE_URL}/validators`;
export const BLOCK_API = `${NYX_RPC_BASE_URL}/block`;
export const COUNTRY_DATA_API = `${API_BASE_URL}/countries`;
export const UPTIME_STORY_API = `${NYM_API_BASE_URL}/api/v1/status/mixnode`; // add ID then '/history' to this.
export const UPTIME_STORY_API_GATEWAY = `${NYM_API_BASE_URL}/api/v1/status/gateway`; // add ID then '/history' or '/report' to this
export const SERVICE_PROVIDERS = `${API_BASE_URL}/service-providers`;
// errors
export const MIXNODE_API_ERROR = "We're having trouble finding that record, please try again or Contact Us.";
export const NYM_WEBSITE = 'https://nymtech.net';
export const NYM_BIG_DIPPER = 'https://mixnet.explorers.guru';
export const NYM_MIXNET_CONTRACT =
process.env.NYM_MIXNET_CONTRACT || 'n17srjznxl9dvzdkpwpw24gg668wc73val88a6m5ajg6ankwvz9wtst0cznr';
export const COSMOS_KIT_USE_CHAIN = process.env.NEXT_PUBLIC_COSMOS_KIT_USE_CHAIN || 'sandbox';
export const WALLET_CONNECT_PROJECT_ID = process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID || '';
+173
View File
@@ -0,0 +1,173 @@
import keyBy from 'lodash/keyBy';
import {
API_BASE_URL,
BLOCK_API,
COUNTRY_DATA_API,
GATEWAYS_API,
UPTIME_STORY_API_GATEWAY,
MIXNODE_API,
MIXNODE_PING,
MIXNODES_API,
OVERVIEW_API,
UPTIME_STORY_API,
VALIDATORS_API,
SERVICE_PROVIDERS,
GATEWAYS_EXPLORER_API,
} from './constants';
import {
CountryDataResponse,
DelegationsResponse,
UniqDelegationsResponse,
GatewayReportResponse,
UptimeStoryResponse,
MixNodeDescriptionResponse,
MixNodeResponse,
MixNodeResponseItem,
MixnodeStatus,
MixNodeEconomicDynamicsStatsResponse,
StatsResponse,
StatusResponse,
SummaryOverviewResponse,
ValidatorsResponse,
Environment,
GatewayBondAnnotated,
GatewayBond,
DirectoryServiceProvider,
LocatedGateway,
} from '../typeDefs/explorer-api';
function getFromCache(key: string) {
const ts = Number(localStorage.getItem('ts'));
const hasExpired = Date.now() - ts > 5000;
const curr = localStorage.getItem(key);
if (curr && !hasExpired) {
return JSON.parse(curr);
}
return undefined;
}
function storeInCache(key: string, data: any) {
localStorage.setItem(key, data);
localStorage.setItem('ts', Date.now().toString());
}
export class Api {
static fetchOverviewSummary = async (): Promise<SummaryOverviewResponse> => {
const cache = getFromCache('overview-summary');
if (cache) {
return cache;
}
const res = await fetch(`${OVERVIEW_API}/summary`);
const json = await res.json();
storeInCache('overview-summary', JSON.stringify(json));
return json;
};
static fetchMixnodes = async (): Promise<MixNodeResponse> => {
const cachedMixnodes = getFromCache('mixnodes');
if (cachedMixnodes) {
return cachedMixnodes;
}
const res = await fetch(MIXNODES_API);
const json = await res.json();
storeInCache('mixnodes', JSON.stringify(json));
return json;
};
static fetchMixnodesActiveSetByStatus = async (status: MixnodeStatus): Promise<MixNodeResponse> => {
const cachedMixnodes = getFromCache(`mixnodes-${status}`);
if (cachedMixnodes) {
return cachedMixnodes;
}
const res = await fetch(`${MIXNODES_API}/active-set/${status}`);
const json = await res.json();
storeInCache(`mixnodes-${status}`, JSON.stringify(json));
return json;
};
static fetchMixnodeByID = async (id: string): Promise<MixNodeResponseItem | undefined> => {
const response = await fetch(`${MIXNODE_API}/${id}`);
// when the mixnode is not found, returned undefined
if (response.status === 404) {
return undefined;
}
return response.json();
};
static fetchGateways = async (): Promise<GatewayBond[]> => {
const res = await fetch(GATEWAYS_API);
const gatewaysAnnotated: GatewayBondAnnotated[] = await res.json();
const res2 = await fetch(GATEWAYS_EXPLORER_API);
const locatedGateways: LocatedGateway[] = await res2.json();
const locatedGatewaysByOwner = keyBy(locatedGateways, 'owner');
return gatewaysAnnotated.map(({ gateway_bond, node_performance }) => ({
...gateway_bond,
node_performance,
location: locatedGatewaysByOwner[gateway_bond.owner]?.location,
}));
};
static fetchGatewayUptimeStoryById = async (id: string): Promise<UptimeStoryResponse> =>
(await fetch(`${UPTIME_STORY_API_GATEWAY}/${id}/history`)).json();
static fetchGatewayReportById = async (id: string): Promise<GatewayReportResponse> =>
(await fetch(`${UPTIME_STORY_API_GATEWAY}/${id}/report`)).json();
static fetchValidators = async (): Promise<ValidatorsResponse> => {
const res = await fetch(VALIDATORS_API);
const json = await res.json();
return json.result;
};
static fetchBlock = async (): Promise<number> => {
const res = await fetch(BLOCK_API);
const json = await res.json();
const { height } = json.result.block.header;
return height;
};
static fetchCountryData = async (): Promise<CountryDataResponse> => {
const result: CountryDataResponse = {};
const res = await fetch(COUNTRY_DATA_API);
const json = await res.json();
Object.keys(json).forEach((ISO3) => {
result[ISO3] = { ISO3, nodes: json[ISO3] };
});
return result;
};
static fetchDelegationsById = async (id: string): Promise<DelegationsResponse> =>
(await fetch(`${MIXNODE_API}/${id}/delegations`)).json();
static fetchUniqDelegationsById = async (id: string): Promise<UniqDelegationsResponse> =>
(await fetch(`${MIXNODE_API}/${id}/delegations/summed`)).json();
static fetchStatsById = async (id: string): Promise<StatsResponse> =>
(await fetch(`${MIXNODE_API}/${id}/stats`)).json();
static fetchMixnodeDescriptionById = async (id: string): Promise<MixNodeDescriptionResponse> =>
(await fetch(`${MIXNODE_API}/${id}/description`)).json();
static fetchMixnodeEconomicDynamicsStatsById = async (id: string): Promise<MixNodeEconomicDynamicsStatsResponse> =>
(await fetch(`${MIXNODE_API}/${id}/economic-dynamics-stats`)).json();
static fetchStatusById = async (id: string): Promise<StatusResponse> => (await fetch(`${MIXNODE_PING}/${id}`)).json();
static fetchUptimeStoryById = async (id: string): Promise<UptimeStoryResponse> =>
(await fetch(`${UPTIME_STORY_API}/${id}/history`)).json();
static fetchServiceProviders = async (): Promise<DirectoryServiceProvider[]> => {
const res = await fetch(SERVICE_PROVIDERS);
const json = await res.json();
return json;
};
}
export const getEnvironment = (): Environment => {
const matchEnv = (env: Environment) => API_BASE_URL?.toLocaleLowerCase().includes(env) && env;
return matchEnv('sandbox') || matchEnv('qa') || 'mainnet';
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,12 @@
import { Typography } from '@mui/material';
import * as React from 'react';
export const ComponentError: FCWithChildren<{ text: string }> = ({ text }) => (
<Typography
sx={{ marginTop: 2, color: 'primary.main', fontSize: 10 }}
variant="body1"
data-testid="delegation-total-amount"
>
{text}
</Typography>
);
@@ -0,0 +1,38 @@
import { Card, CardHeader, CardContent, Typography } from '@mui/material'
import React, { ReactEventHandler } from 'react'
type ContentCardProps = {
title?: React.ReactNode
subtitle?: string
Icon?: React.ReactNode
Action?: React.ReactNode
errorMsg?: string
onClick?: ReactEventHandler
}
export const ContentCard: FCWithChildren<ContentCardProps> = ({
title,
Icon,
Action,
subtitle,
errorMsg,
children,
onClick,
}) => (
<Card onClick={onClick} sx={{ height: '100%' }}>
{title && (
<CardHeader
title={title || ''}
avatar={Icon}
action={Action}
subheader={subtitle}
/>
)}
{children && <CardContent>{children}</CardContent>}
{errorMsg && (
<Typography variant="body2" sx={{ color: 'danger', padding: 2 }}>
{errorMsg}
</Typography>
)}
</Card>
)
@@ -0,0 +1,30 @@
import * as React from 'react'
import { Box, Typography } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import { Tooltip } from '@nymproject/react/tooltip/Tooltip'
export const CustomColumnHeading: FCWithChildren<{
headingTitle: string
tooltipInfo?: string
}> = ({ headingTitle, tooltipInfo }) => {
const theme = useTheme()
return (
<Box alignItems="center" display="flex">
{tooltipInfo && (
<Tooltip
title={tooltipInfo}
id={headingTitle}
placement="top-start"
textColor={theme.palette.nym.networkExplorer.tooltip.color}
bgColor={theme.palette.nym.networkExplorer.tooltip.background}
maxWidth={230}
arrow
/>
)}
<Typography variant="body2" fontWeight={600} data-testid={headingTitle}>
{headingTitle}
</Typography>
</Box>
)
}
@@ -0,0 +1,83 @@
import React from 'react';
import {
Breakpoint,
Button,
Paper,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
SxProps,
Typography,
} from '@mui/material';
export interface ConfirmationModalProps {
open: boolean;
onConfirm: () => void;
onClose?: () => void;
children?: React.ReactNode;
title: React.ReactNode | string;
subTitle?: React.ReactNode | string;
confirmButton: React.ReactNode | string;
disabled?: boolean;
sx?: SxProps;
fullWidth?: boolean;
maxWidth?: Breakpoint;
backdropProps?: object;
}
export const ConfirmationModal = ({
open,
onConfirm,
onClose,
children,
title,
subTitle,
confirmButton,
disabled,
sx,
fullWidth,
maxWidth,
backdropProps,
}: ConfirmationModalProps) => {
const Title = (
<DialogTitle id="responsive-dialog-title" sx={{ pb: 2 }}>
{title}
{subTitle &&
(typeof subTitle === 'string' ? (
<Typography fontWeight={400} variant="subtitle1" fontSize={12} color="grey">
{subTitle}
</Typography>
) : (
subTitle
))}
</DialogTitle>
);
const ConfirmButton =
typeof confirmButton === 'string' ? (
<Button onClick={onConfirm} variant="contained" fullWidth disabled={disabled} sx={{ py: 1.6 }}>
<Typography variant="button" fontSize="large">
{confirmButton}
</Typography>
</Button>
) : (
confirmButton
);
return (
<Dialog
open={open}
onClose={onClose}
aria-labelledby="responsive-dialog-title"
maxWidth={maxWidth || 'sm'}
sx={{ textAlign: 'center', ...sx }}
fullWidth={fullWidth}
BackdropProps={backdropProps}
PaperComponent={Paper}
PaperProps={{ elevation: 0 }}
>
{Title}
<DialogContent>{children}</DialogContent>
<DialogActions sx={{ px: 3, pb: 3 }}>{ConfirmButton}</DialogActions>
</Dialog>
);
};
@@ -0,0 +1,39 @@
import * as React from 'react'
import { Button, IconButton } from '@mui/material'
import { SxProps } from '@mui/system'
import { useIsMobile } from '@/app/hooks'
import { DelegateIcon } from '@/app/icons/DelevateSVG'
export const DelegateIconButton: FCWithChildren<{
size?: 'small' | 'medium'
disabled?: boolean
tooltip?: React.ReactNode
sx?: SxProps
onDelegate: () => void
}> = ({ onDelegate, sx, disabled, size = 'medium' }) => {
const isMobile = useIsMobile()
const handleOnDelegate = () => {
onDelegate()
}
if (isMobile) {
return (
<IconButton size="small" disabled={disabled} onClick={handleOnDelegate}>
<DelegateIcon fontSize="small" />
</IconButton>
)
}
return (
<Button
variant="outlined"
size={size}
disabled={disabled}
onClick={handleOnDelegate}
sx={sx}
>
Delegate
</Button>
)
}
@@ -0,0 +1,191 @@
'use client'
import React, { useState } from 'react'
import { Box, SxProps } from '@mui/material'
import { IdentityKeyFormField } from '@nymproject/react/mixnodes/IdentityKeyFormField'
import { CurrencyFormField } from '@nymproject/react/currency/CurrencyFormField'
import { CurrencyDenom, DecCoin } from '@nymproject/types'
import { useWalletContext } from '@/app/context/wallet'
import { urls } from '@/app/utils'
import { useDelegationsContext } from '@/app/context/delegations'
import { validateAmount } from '@/app/utils/currency'
import { SimpleModal } from './SimpleModal'
import { ModalListItem } from './ModalListItem'
import { DelegationModalProps } from './DelegationModal'
const MIN_AMOUNT_TO_DELEGATE = 10
type Props = {
mixId: number
identityKey: string
header?: string
buttonText?: string
rewardInterval?: string
estimatedReward?: number
profitMarginPercentage?: string | null
nodeUptimePercentage?: number | null
denom: CurrencyDenom
sx?: SxProps
backdropProps?: object
onClose: () => void
onOk?: (delegationModalProps: DelegationModalProps) => void
}
export const DelegateModal = ({
mixId,
identityKey,
onClose,
onOk,
denom,
sx,
}: Props) => {
const [amount, setAmount] = useState<DecCoin | undefined>({
amount: '10',
denom: 'nym',
})
const [isValidated, setValidated] = useState<boolean>(false)
const [errorAmount, setErrorAmount] = useState<string | undefined>()
const { address, balance } = useWalletContext()
const { handleDelegate } = useDelegationsContext()
const validate = async () => {
let newValidatedValue = true
let errorAmountMessage
if (amount && !(await validateAmount(amount.amount, '0'))) {
newValidatedValue = false
errorAmountMessage = 'Please enter a valid amount'
}
if (amount && +amount.amount < MIN_AMOUNT_TO_DELEGATE) {
errorAmountMessage = `Min. delegation amount: ${MIN_AMOUNT_TO_DELEGATE} ${denom.toUpperCase()}`
newValidatedValue = false
}
if (!amount?.amount.length) {
newValidatedValue = false
}
if (amount && balance.data && +balance.data - +amount.amount <= 0) {
errorAmountMessage = 'Not enough funds'
newValidatedValue = false
}
setErrorAmount(errorAmountMessage)
setValidated(newValidatedValue)
}
const delegateToMixnode = async ({
delegationMixId,
delegationAmount,
}: {
delegationMixId: number
delegationAmount: string
}) => {
try {
const tx = await handleDelegate(delegationMixId, delegationAmount)
return tx
} catch (e) {
console.error('Failed to delegate to mixnode', e)
throw e
}
}
const handleConfirm = async () => {
if (mixId && amount && onOk) {
onOk({
status: 'loading',
})
try {
if (!address) {
throw new Error('Please connect your wallet')
}
const tx = await delegateToMixnode({
delegationMixId: mixId,
delegationAmount: amount.amount,
})
if (!tx) {
throw new Error('Failed to delegate')
}
onOk({
status: 'success',
message: 'Delegation can take up to one hour to process',
transactions: [
{
url: `${urls('MAINNET').blockExplorer}/transaction/${
tx.transactionHash
}`,
hash: tx.transactionHash,
},
],
})
} catch (e) {
console.error('Failed to delegate', e)
onOk({
status: 'error',
message: (e as Error).message,
})
}
}
}
const handleAmountChanged = (newAmount: DecCoin) => {
setAmount(newAmount)
}
React.useEffect(() => {
validate()
}, [amount, identityKey, mixId])
return (
<SimpleModal
open
onClose={onClose}
onOk={handleConfirm}
header="Delegate"
okLabel="Delegate"
okDisabled={!isValidated}
sx={sx}
>
<Box sx={{ mt: 3 }} gap={2}>
<IdentityKeyFormField
required
fullWidth
label="Node identity key"
onChanged={() => undefined}
initialValue={identityKey}
readOnly
showTickOnValid={false}
/>
</Box>
<Box display="flex" gap={2} alignItems="center" sx={{ mt: 3 }}>
<CurrencyFormField
showCoinMark={false}
required
fullWidth
autoFocus
label="Amount"
initialValue={amount?.amount || '10'}
onChanged={handleAmountChanged}
denom={denom}
validationError={errorAmount}
/>
</Box>
<Box sx={{ mt: 3 }}>
<ModalListItem
label="Account balance"
value={`${balance.data} NYM`}
divider
fontWeight={600}
/>
</Box>
<ModalListItem label="Est. fee for this transaction will be calculated in your connected wallet" />
</SimpleModal>
)
}
@@ -0,0 +1,95 @@
import React from 'react'
import { Typography, SxProps, Stack } from '@mui/material'
import { Link } from '@nymproject/react/link/Link'
import { LoadingModal } from './LoadingModal'
import { ConfirmationModal } from './ConfirmationModal'
import { ErrorModal } from './ErrorModal'
export type DelegationModalProps = {
status: 'loading' | 'success' | 'error' | 'info'
message?: string
transactions?: {
url: string
hash: string
}[]
}
export const DelegationModal: FCWithChildren<
DelegationModalProps & {
open: boolean
onClose: () => void
sx?: SxProps
backdropProps?: object
children?: React.ReactNode
}
> = ({
status,
message,
transactions,
open,
onClose,
children,
sx,
backdropProps,
}) => {
if (status === 'loading')
return <LoadingModal sx={sx} backdropProps={backdropProps} />
if (status === 'error') {
return (
<ErrorModal message={message} sx={sx} open={open} onClose={onClose}>
{children}
</ErrorModal>
)
}
if (status === 'info') {
return (
<ConfirmationModal
open={open}
title="Connect wallet"
confirmButton="OK"
onConfirm={onClose}
>
<Typography>{message}</Typography>
</ConfirmationModal>
)
}
return (
<ConfirmationModal
open={open}
onConfirm={onClose || (() => {})}
title="Transaction successful"
confirmButton="Done"
>
<Stack alignItems="center" spacing={2} mb={0}>
{message && <Typography>{message}</Typography>}
{transactions?.length === 1 && (
<Link
href={transactions[0].url}
target="_blank"
sx={{ ml: 1 }}
text="View on blockchain"
noIcon
/>
)}
{transactions && transactions.length > 1 && (
<Stack alignItems="center" spacing={1}>
<Typography>View the transactions on blockchain:</Typography>
{transactions.map(({ url, hash }) => (
<Link
href={url}
target="_blank"
sx={{ ml: 1 }}
text={hash.slice(0, 6)}
key={hash}
noIcon
/>
))}
</Stack>
)}
</Stack>
</ConfirmationModal>
)
}
@@ -0,0 +1,28 @@
import React from 'react';
import { Box, Button, Modal, SxProps, Typography } from '@mui/material';
import { modalStyle } from './SimpleModal';
export const ErrorModal: FCWithChildren<{
open: boolean;
title?: string;
message?: string;
sx?: SxProps;
backdropProps?: object;
onClose: () => void;
children?: React.ReactNode;
}> = ({ children, open, title, message, sx, backdropProps, onClose }) => (
<Modal open={open} onClose={onClose} BackdropProps={backdropProps}>
<Box sx={{ ...modalStyle(), ...sx }} textAlign="center">
<Typography color={(theme) => theme.palette.error.main} mb={1}>
{title || 'Oh no! Something went wrong...'}
</Typography>
<Typography my={5} color="text.primary" sx={{ textOverflow: 'wrap', overflowWrap: 'break-word' }}>
{message}
</Typography>
{children}
<Button variant="contained" onClick={onClose}>
Close
</Button>
</Box>
</Modal>
);
@@ -0,0 +1,18 @@
import React from 'react';
import { Box, CircularProgress, Modal, Stack, Typography, SxProps } from '@mui/material';
import { modalStyle } from './SimpleModal';
export const LoadingModal: FCWithChildren<{
text?: string;
sx?: SxProps;
backdropProps?: object;
}> = ({ sx, text = 'Please wait...' }) => (
<Modal open>
<Box sx={{ ...modalStyle(), ...sx }} textAlign="center">
<Stack spacing={4} direction="row" alignItems="center">
<CircularProgress />
<Typography sx={{ color: 'text.primary' }}>{text}</Typography>
</Stack>
</Box>
</Modal>
);
@@ -0,0 +1,6 @@
import React from 'react';
import { Box, SxProps } from '@mui/material';
export const ModalDivider: FCWithChildren<{
sx?: SxProps;
}> = ({ sx }) => <Box borderTop="1px solid" borderColor="rgba(141, 147, 153, 0.2)" my={1} sx={sx} />;
@@ -0,0 +1,32 @@
import React from 'react';
import { Box, Stack, SxProps, Typography, TypographyProps } from '@mui/material';
import { ModalDivider } from './ModalDivider';
export const ModalListItem: FCWithChildren<{
label: string;
divider?: boolean;
hidden?: boolean;
fontWeight?: TypographyProps['fontWeight'];
fontSize?: TypographyProps['fontSize'];
light?: boolean;
value?: React.ReactNode;
sxValue?: SxProps;
}> = ({ label, value, hidden, fontWeight, fontSize, divider, sxValue }) => (
<Box sx={{ display: hidden ? 'none' : 'block' }}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography fontSize="smaller" fontWeight={fontWeight} sx={{ color: 'text.primary', fontSize: 14 }}>
{label}
</Typography>
{value && (
<Typography
fontSize="smaller"
fontWeight={fontWeight}
sx={{ color: 'text.primary', fontSize: fontSize || 14, ...sxValue }}
>
{value}
</Typography>
)}
</Stack>
{divider && <ModalDivider />}
</Box>
);
@@ -0,0 +1,152 @@
import React from 'react'
import { Box, Button, Modal, Stack, SxProps, Typography } from '@mui/material'
import CloseIcon from '@mui/icons-material/Close'
import ErrorOutline from '@mui/icons-material/ErrorOutline'
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew'
import { useIsMobile } from '@/app/hooks/useIsMobile'
export const modalStyle = (width: number | string = 600) => ({
position: 'absolute' as 'absolute',
top: '50%',
left: '50%',
width,
transform: 'translate(-50%, -50%)',
bgcolor: 'background.paper',
boxShadow: 24,
borderRadius: '16px',
p: 4,
})
export const StyledBackButton = ({
onBack,
label,
fullWidth,
sx,
}: {
onBack: () => void
label?: string
fullWidth?: boolean
sx?: SxProps
}) => (
<Button
disableFocusRipple
size="large"
fullWidth={fullWidth}
variant="outlined"
onClick={onBack}
sx={sx}
>
{label || <ArrowBackIosNewIcon fontSize="small" />}
</Button>
)
export const SimpleModal: FCWithChildren<{
open: boolean
hideCloseIcon?: boolean
displayErrorIcon?: boolean
displayInfoIcon?: boolean
headerStyles?: SxProps
subHeaderStyles?: SxProps
buttonFullWidth?: boolean
onClose?: () => void
onOk?: () => Promise<void>
onBack?: () => void
header: string | React.ReactNode
subHeader?: string
okLabel: string
backLabel?: string
backButtonFullWidth?: boolean
okDisabled?: boolean
sx?: SxProps
children?: React.ReactNode
}> = ({
open,
hideCloseIcon,
displayErrorIcon,
displayInfoIcon,
headerStyles,
buttonFullWidth,
onClose,
okDisabled,
onOk,
onBack,
header,
subHeader,
okLabel,
backLabel,
backButtonFullWidth,
sx,
children,
}) => {
const isMobile = useIsMobile()
return (
<Modal open={open} onClose={onClose}>
<Box sx={{ ...modalStyle(isMobile ? '90%' : 600), ...sx }}>
{displayErrorIcon && <ErrorOutline color="error" sx={{ mb: 3 }} />}
{displayInfoIcon && <InfoOutlinedIcon sx={{ mb: 2, color: 'blue' }} />}
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
>
{typeof header === 'string' ? (
<Typography
fontSize={20}
fontWeight={600}
sx={{ color: 'text.primary', ...headerStyles }}
>
{header}
</Typography>
) : (
header
)}
{!hideCloseIcon && <CloseIcon onClick={onClose} cursor="pointer" />}
</Stack>
<Typography
mt={subHeader ? 0.5 : 0}
mb={3}
fontSize={12}
color={(theme) => theme.palette.text.secondary}
>
{subHeader}
</Typography>
{children}
{(onOk || onBack) && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 2,
mt: 2,
width: buttonFullWidth ? '100%' : null,
}}
>
{onBack && (
<StyledBackButton
onBack={onBack}
label={backLabel}
fullWidth={backButtonFullWidth}
/>
)}
{onOk && (
<Button
variant="contained"
fullWidth
size="large"
onClick={onOk}
disabled={okDisabled}
>
{okLabel}
</Button>
)}
</Box>
)}
</Box>
</Modal>
)
}
@@ -0,0 +1,10 @@
export * from './ConfirmationModal';
export * from './DelegateIconButton';
export * from './DelegationModal';
export * from './DelegateModal';
export * from './ErrorModal';
export * from './LoadingModal';
export * from './ModalDivider';
export * from './ModalListItem';
export * from './SimpleModal';
export * from './styles';
@@ -0,0 +1,21 @@
import { Theme } from '@mui/material/styles';
export const backDropStyles = (theme: Theme) => {
const { mode } = theme.palette;
return {
style: {
left: mode === 'light' ? '0' : '50%',
width: '50%',
},
};
};
export const modalStyles = (theme: Theme) => {
const { mode } = theme.palette;
return { left: mode === 'light' ? '25%' : '75%' };
};
export const dialogStyles = (theme: Theme) => {
const { mode } = theme.palette;
return { left: mode === 'light' ? '-50%' : '50%' };
};
@@ -0,0 +1,145 @@
import * as React from 'react'
import {
Link,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TableCellProps,
} from '@mui/material'
import { useTheme } from '@mui/material/styles'
import { Tooltip } from '@nymproject/react/tooltip/Tooltip'
import { CopyToClipboard } from '@nymproject/react/clipboard/CopyToClipboard'
import { Box } from '@mui/system'
import { unymToNym } from '@/app/utils/currency'
import { GatewayEnrichedRowType } from './Gateways/Gateways'
import { MixnodeRowType } from './MixNodes'
import { StakeSaturationProgressBar } from './MixNodes/Economics/StakeSaturationProgressBar'
export type ColumnsType = {
field: string
title: string
headerAlign?: TableCellProps['align']
width?: string | number
tooltipInfo?: string
}
export interface UniversalTableProps<T = any> {
tableName: string
columnsData: ColumnsType[]
rows: T[]
}
function formatCellValues(val: string | number, field: string) {
if (field === 'identity_key' && typeof val === 'string') {
return (
<Box display="flex" justifyContent="flex-end">
<CopyToClipboard
sx={{ mr: 1, mt: 0.5, fontSize: '18px' }}
value={val}
tooltip={`Copy identity key ${val} to clipboard`}
/>
<span>{val}</span>
</Box>
)
}
if (field === 'bond') {
return unymToNym(val, 6)
}
if (field === 'owner') {
return (
<Link
underline="none"
color="inherit"
target="_blank"
href={`https://mixnet.explorers.guru/account/${val}`}
>
{val}
</Link>
)
}
if (field === 'stake_saturation') {
return <StakeSaturationProgressBar value={Number(val)} threshold={100} />
}
return val
}
export const DetailTable: FCWithChildren<{
tableName: string
columnsData: ColumnsType[]
rows: MixnodeRowType[] | GatewayEnrichedRowType[]
}> = ({ tableName, columnsData, rows }: UniversalTableProps) => {
const theme = useTheme()
return (
<TableContainer component={Paper}>
<Table sx={{ minWidth: 1080 }} aria-label={tableName}>
<TableHead>
<TableRow>
{columnsData?.map(({ field, title, width, tooltipInfo }) => (
<TableCell
key={field}
sx={{ fontSize: 14, fontWeight: 600, width }}
>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
{tooltipInfo && (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Tooltip
title={tooltipInfo}
id={field}
placement="top-start"
textColor={
theme.palette.nym.networkExplorer.tooltip.color
}
bgColor={
theme.palette.nym.networkExplorer.tooltip.background
}
maxWidth={230}
arrow
/>
</Box>
)}
{title}
</Box>
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{rows.map((eachRow) => (
<TableRow
key={eachRow.id}
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
>
{columnsData?.map((data, index) => (
<TableCell
key={data.title}
component="th"
scope="row"
variant="body"
sx={{
padding: 2,
width: 200,
fontSize: 14,
}}
data-testid={`${data.title.replace(/ /g, '-')}-value`}
>
{formatCellValues(
eachRow[columnsData[index].field],
columnsData[index].field
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)
}
@@ -0,0 +1,193 @@
'use client'
import React, { useState, useEffect, useRef, useCallback } from 'react'
import {
Button,
Dialog,
DialogContent,
DialogActions,
DialogTitle,
Slider,
Typography,
Box,
Snackbar,
Slide,
Alert,
} from '@mui/material'
import { useParams } from 'next/navigation'
import { useMainContext } from '@/app/context/main'
import {
MixnodeStatusWithAll,
toMixnodeStatus,
} from '@/app/typeDefs/explorer-api'
import { EnumFilterKey, TFilterItem, TFilters } from '@/app/typeDefs/filters'
import { Api } from '@/app/api'
import { useIsMobile } from '@/app/hooks/useIsMobile'
import { formatOnSave, generateFilterSchema } from './filterSchema'
import FiltersButton from './FiltersButton'
const FilterItem = ({
label,
id,
tooltipInfo,
value,
isSmooth,
marks,
scale,
min,
max,
onChange,
}: TFilterItem & {
onChange: (id: EnumFilterKey, newValue: number[]) => void
}) => (
<Box sx={{ p: 2 }}>
<Typography gutterBottom>{label}</Typography>
<Typography fontSize={12}>{tooltipInfo}</Typography>
<Slider
value={value}
onChange={(e: Event, newValue: number | number[]) =>
onChange(id, newValue as number[])
}
valueLabelDisplay={isSmooth ? 'auto' : 'off'}
marks={marks}
step={isSmooth ? 1 : null}
scale={scale}
min={min}
max={max}
valueLabelFormat={(val: number) =>
val === 100 && id === 'stakeSaturation' ? '>100' : val
}
/>
</Box>
)
export const Filters = () => {
const { filterMixnodes, fetchMixnodes, mixnodes } = useMainContext()
const { status } = useParams<{
status: 'active' | 'standby' | 'inactive' | 'all'
}>()
const isMobile = useIsMobile()
const [showFilters, setShowFilters] = useState(false)
const [isFiltered, setIsFiltered] = useState(false)
const [filters, setFilters] = React.useState<TFilters>()
const [upperSaturationValue, setUpperSaturationValue] =
React.useState<number>(100)
const baseFilters = useRef<TFilters>()
const prevFilters = useRef<TFilters>()
const handleToggleShowFilters = () => setShowFilters(!showFilters)
const initialiseFilters = useCallback(async () => {
const allMixnodes = await Api.fetchMixnodes()
if (allMixnodes) {
setUpperSaturationValue(
Math.round(
Math.max(...allMixnodes.map((m) => m.stake_saturation)) * 100 + 1
)
)
const initFilters = generateFilterSchema()
baseFilters.current = initFilters
prevFilters.current = initFilters
setFilters(initFilters)
}
}, [])
const handleOnChange = (id: EnumFilterKey, newValue: number[]) => {
if (id === 'stakeSaturation' && newValue[1] === 100) {
newValue.splice(1, 1, upperSaturationValue)
}
setFilters((ftrs) => {
if (ftrs)
return {
...ftrs,
[id]: {
...ftrs[id],
value: newValue,
},
}
return undefined
})
}
const handleOnSave = async () => {
setShowFilters(false)
await filterMixnodes(formatOnSave(filters!), status)
setIsFiltered(true)
prevFilters.current = filters
}
const handleOnCancel = () => {
setShowFilters(false)
setFilters(prevFilters.current)
}
const resetFilters = () => {
setFilters(baseFilters.current)
setIsFiltered(false)
prevFilters.current = baseFilters.current
}
const onClearFilters = async () => {
await fetchMixnodes(toMixnodeStatus(MixnodeStatusWithAll[status]))
resetFilters()
}
useEffect(() => {
initialiseFilters()
}, [initialiseFilters])
useEffect(() => {
resetFilters()
}, [status])
if (!filters) return null
return (
<>
<Snackbar
open={isFiltered}
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
message="Filters applied"
TransitionComponent={Slide}
transitionDuration={250}
>
<Alert
severity="info"
variant={isMobile ? 'standard' : 'outlined'}
sx={{ color: (t) => t.palette.info.light }}
action={
<Button size="small" onClick={onClearFilters}>
CLEAR FILTERS
</Button>
}
>
{mixnodes?.data?.length} mixnodes matched your criteria
</Alert>
</Snackbar>
<FiltersButton onClick={handleToggleShowFilters} fullWidth />
<Dialog
open={showFilters}
onClose={handleToggleShowFilters}
maxWidth="md"
fullWidth
>
<DialogTitle>Mixnode filters</DialogTitle>
<DialogContent dividers>
{Object.values(filters).map((v) => (
<FilterItem {...v} key={v.id} onChange={handleOnChange} />
))}
</DialogContent>
<DialogActions>
<Button size="large" onClick={handleOnCancel}>
Cancel
</Button>
<Button variant="contained" size="large" onClick={handleOnSave}>
Save
</Button>
</DialogActions>
</Dialog>
</>
)
}
@@ -0,0 +1,34 @@
import React from 'react';
import { Button, IconButton } from '@mui/material';
import { Tune } from '@mui/icons-material';
type FiltersButtonProps = {
iconOnly?: boolean;
fullWidth?: boolean;
onClick: () => void;
};
const FiltersButton = ({ iconOnly, fullWidth, onClick }: FiltersButtonProps) => {
if (iconOnly) {
return (
<IconButton onClick={onClick} color="primary">
<Tune />
</IconButton>
);
}
return (
<Button
fullWidth={fullWidth}
size="large"
variant="contained"
endIcon={<Tune />}
onClick={onClick}
sx={{ textTransform: 'none' }}
>
Filters
</Button>
);
};
export default FiltersButton;
@@ -0,0 +1,69 @@
import { EnumFilterKey, TFilters } from '../../typeDefs/filters';
export const generateFilterSchema = () => ({
profitMargin: {
label: 'Profit margin (%)',
id: EnumFilterKey.profitMargin,
value: [0, 100],
isSmooth: true,
marks: [
{ label: '0', value: 0 },
{ label: '10', value: 10 },
{ label: '20', value: 20 },
{ label: '30', value: 30 },
{ label: '40', value: 40 },
{ label: '50', value: 50 },
{ label: '60', value: 60 },
{ label: '70', value: 70 },
{ label: '80', value: 80 },
{ label: '90', value: 90 },
{ label: '100', value: 100 },
],
tooltipInfo:
'As a delegator you want to chose nodes with lower profit margin, meaning more payout for their delegators',
},
stakeSaturation: {
label: 'Stake saturation (%)',
id: EnumFilterKey.stakeSaturation,
value: [0, 100],
isSmooth: true,
marks: [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100].map((value) => ({
value: value < 100 ? value : 100,
label: value < 100 ? value : '>100',
})),
tooltipInfo: "Select nodes with <100% saturation. Any additional stake above 100% saturation won't get rewards",
},
routingScore: {
label: 'Routing score (%)',
id: EnumFilterKey.routingScore,
value: [0, 100],
isSmooth: true,
marks: [
{ label: '0', value: 0 },
{ label: '10', value: 10 },
{ label: '20', value: 20 },
{ label: '30', value: 30 },
{ label: '40', value: 40 },
{ label: '50', value: 50 },
{ label: '60', value: 60 },
{ label: '70', value: 70 },
{ label: '80', value: 80 },
{ label: '90', value: 90 },
{ label: '100', value: 100 },
],
tooltipInfo: 'The higher the routing score the better the performance of the node and so its rewards',
},
});
const formatStakeSaturationValues = ([value_1, value_2]: number[]) => {
const lowerValue = value_1 / 100;
const upperValue = value_2 / 100;
return [lowerValue, upperValue];
};
export const formatOnSave = (filters: TFilters) => ({
routingScore: filters.routingScore.value,
profitMargin: filters.profitMargin.value,
stakeSaturation: formatStakeSaturationValues(filters.stakeSaturation.value),
});
+56
View File
@@ -0,0 +1,56 @@
import React from 'react'
import Box from '@mui/material/Box'
import MuiLink from '@mui/material/Link'
import Typography from '@mui/material/Typography'
import { useIsMobile } from '../hooks/useIsMobile'
import { NymVpnIcon } from '../icons/NymVpn'
import { Socials } from './Socials'
import Link from 'next/link'
export const Footer: FCWithChildren = () => {
const isMobile = useIsMobile()
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
width: '100%',
height: 'auto',
mt: 3,
pt: 3,
pb: 3,
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
width: 'auto',
justifyContent: 'center',
alignItems: 'center',
mb: 2,
}}
>
<Box marginRight={1}>
<Link href="http://nymvpn.com" target="_blank">
<NymVpnIcon />
</Link>
</Box>
<Socials isFooter />
</Box>
<Typography
sx={{
fontSize: 12,
textAlign: isMobile ? 'center' : 'end',
color: 'nym.muted.onDarkBg',
}}
>
© {new Date().getFullYear()} Nym Technologies SA, all rights reserved
</Typography>
</Box>
)
}
@@ -0,0 +1,52 @@
import { GatewayResponse, GatewayBond, GatewayReportResponse } from '@/app/typeDefs/explorer-api';
import { toPercentInteger } from '@/app/utils';
export type GatewayRowType = {
id: string;
owner: string;
identity_key: string;
bond: number;
host: string;
location: string;
version: string;
node_performance: number;
};
export type GatewayEnrichedRowType = GatewayRowType & {
routingScore: string;
avgUptime: string;
clientsPort: number;
mixPort: number;
};
export function gatewayToGridRow(arrayOfGateways: GatewayResponse): GatewayRowType[] {
return !arrayOfGateways
? []
: arrayOfGateways.map((gw) => ({
id: gw.owner,
owner: gw.owner,
identity_key: gw.gateway.identity_key || '',
location: gw.location?.country_name.toUpperCase() || '',
bond: gw.pledge_amount.amount || 0,
host: gw.gateway.host || '',
version: gw.gateway.version || '',
node_performance: toPercentInteger(gw.node_performance.last_24h),
}));
}
export function gatewayEnrichedToGridRow(gateway: GatewayBond, report: GatewayReportResponse): GatewayEnrichedRowType {
return {
id: gateway.owner,
owner: gateway.owner,
identity_key: gateway.gateway.identity_key || '',
location: gateway.location?.country_name.toUpperCase() || '',
bond: gateway.pledge_amount.amount || 0,
host: gateway.gateway.host || '',
version: gateway.gateway.version || '',
clientsPort: gateway.gateway.clients_port || 0,
mixPort: gateway.gateway.mix_port || 0,
routingScore: `${report.most_recent}%`,
avgUptime: `${report.last_day || report.last_hour}%`,
node_performance: toPercentInteger(gateway.node_performance.most_recent),
};
}
@@ -0,0 +1,51 @@
import React from 'react'
import { FormControl, MenuItem, Select } from '@mui/material'
import { useIsMobile } from '@/app/hooks/useIsMobile'
export enum VersionSelectOptions {
latestVersion = 'Latest versions',
olderVersions = 'Older versions',
all = 'All',
}
export const VersionDisplaySelector = ({
selected,
handleChange,
}: {
selected: VersionSelectOptions
handleChange: (option: VersionSelectOptions) => void
}) => {
const isMobile = useIsMobile()
return (
<FormControl size="small">
<Select
value={selected}
onChange={(e) => handleChange(e.target.value as VersionSelectOptions)}
labelId="simple-select-label"
id="simple-select"
sx={{
marginRight: isMobile ? 0 : 2,
}}
>
<MenuItem
value={VersionSelectOptions.latestVersion}
data-testid="show-gateway-latest-version"
>
{VersionSelectOptions.latestVersion}
</MenuItem>
<MenuItem
value={VersionSelectOptions.olderVersions}
data-testid="show-gateway-old-versions"
>
{VersionSelectOptions.olderVersions}
</MenuItem>
<MenuItem
value={VersionSelectOptions.all}
data-testid="show-gateway-all-versions"
>
{VersionSelectOptions.all}
</MenuItem>
</Select>
</FormControl>
)
}
+28
View File
@@ -0,0 +1,28 @@
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import PauseCircleOutlineIcon from '@mui/icons-material/PauseCircleOutline';
import CircleOutlinedIcon from '@mui/icons-material/CircleOutlined';
import { MixnodeStatus } from '../typeDefs/explorer-api';
export const Icons = {
Mixnodes: {
Status: {
Active: CheckCircleOutlineIcon,
Standby: PauseCircleOutlineIcon,
Inactive: CircleOutlinedIcon,
},
},
};
export const getMixNodeIcon = (value: any) => {
if (value && typeof value === 'string') {
switch (value) {
case MixnodeStatus.active:
return Icons.Mixnodes.Status.Active;
case MixnodeStatus.standby:
return Icons.Mixnodes.Status.Standby;
default:
return Icons.Mixnodes.Status.Inactive;
}
}
return Icons.Mixnodes.Status.Inactive;
};
@@ -0,0 +1,213 @@
import * as React from 'react'
import { Alert, Box, CircularProgress, Typography } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import Table from '@mui/material/Table'
import TableBody from '@mui/material/TableBody'
import TableCell from '@mui/material/TableCell'
import TableContainer from '@mui/material/TableContainer'
import TableHead from '@mui/material/TableHead'
import TableRow from '@mui/material/TableRow'
import Paper from '@mui/material/Paper'
import { ExpandMore } from '@mui/icons-material'
import { currencyToString } from '@/app/utils/currency'
import { useMixnodeContext } from '@/app/context/mixnode'
import { useIsMobile } from '@/app/hooks/useIsMobile'
export const BondBreakdownTable: FCWithChildren = () => {
const { mixNode, delegations, uniqDelegations } = useMixnodeContext()
const [showDelegations, toggleShowDelegations] =
React.useState<boolean>(false)
const [bonds, setBonds] = React.useState({
delegations: '0',
pledges: '0',
bondsTotal: '0',
hasLoaded: false,
})
const theme = useTheme()
const isMobile = useIsMobile()
React.useEffect(() => {
if (mixNode?.data) {
// delegations
const decimalisedDelegations = currencyToString({
amount: mixNode.data.total_delegation.amount.toString(),
denom: mixNode.data.total_delegation.denom,
})
// pledges
const decimalisedPledges = currencyToString({
amount: mixNode.data.pledge_amount.amount.toString(),
denom: mixNode.data.pledge_amount.denom,
})
// bonds total (del + pledges)
const pledgesSum = Number(mixNode.data.pledge_amount.amount)
const delegationsSum = Number(mixNode.data.total_delegation.amount)
const bondsTotal = currencyToString({
amount: (pledgesSum + delegationsSum).toString(),
})
setBonds({
delegations: decimalisedDelegations,
pledges: decimalisedPledges,
bondsTotal,
hasLoaded: true,
})
}
}, [mixNode])
const expandDelegations = () => {
if (delegations?.data && delegations.data.length > 0) {
toggleShowDelegations(!showDelegations)
}
}
const calcBondPercentage = (num: number) => {
if (mixNode?.data) {
const rawDelegationAmount = Number(mixNode.data.total_delegation.amount)
const rawPledgeAmount = Number(mixNode.data.pledge_amount.amount)
const rawTotalBondsAmount = rawDelegationAmount + rawPledgeAmount
return ((num * 100) / rawTotalBondsAmount).toFixed(1)
}
return 0
}
if (mixNode?.isLoading || delegations?.isLoading) {
return <CircularProgress />
}
if (mixNode?.error) {
return <Alert severity="error">Mixnode not found</Alert>
}
if (delegations?.error) {
return <Alert severity="error">Unable to get delegations for mixnode</Alert>
}
return (
<TableContainer component={Paper}>
<Table sx={{ minWidth: 650 }} aria-label="bond breakdown totals">
<TableBody>
<TableRow sx={isMobile ? { minWidth: '70vw' } : null}>
<TableCell
sx={{
fontWeight: 400,
width: '150px',
}}
align="left"
>
Stake total
</TableCell>
<TableCell align="left" data-testid="bond-total-amount">
{bonds.bondsTotal}
</TableCell>
</TableRow>
<TableRow>
<TableCell align="left">Bond</TableCell>
<TableCell align="left" data-testid="pledge-total-amount">
{bonds.pledges}
</TableCell>
</TableRow>
<TableRow>
<TableCell onClick={expandDelegations} align="left">
<Box
sx={{
display: 'flex',
alignItems: 'center',
}}
>
Delegation total {'\u00A0'}
{delegations?.data && delegations?.data?.length > 0 && (
<ExpandMore />
)}
</Box>
</TableCell>
<TableCell align="left" data-testid="delegation-total-amount">
{bonds.delegations}
</TableCell>
</TableRow>
</TableBody>
</Table>
{showDelegations && (
<Box
sx={{
maxHeight: 400,
overflowY: 'scroll',
p: 2,
background: theme.palette.background.paper,
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'baseline',
width: '100%',
p: 2,
borderBottom: `1px solid ${theme.palette.divider}`,
}}
data-testid="delegations-total-amount"
>
<Typography
sx={{
fontSize: 16,
fontWeight: 600,
}}
>
Delegations&nbsp;&nbsp;
</Typography>
</Box>
<Table stickyHeader>
<TableHead>
<TableRow>
<TableCell
sx={{
fontWeight: 600,
background: theme.palette.background.paper,
}}
align="left"
>
Delegators
</TableCell>
<TableCell
sx={{
fontWeight: 600,
background: theme.palette.background.paper,
}}
align="left"
>
Amount
</TableCell>
<TableCell
sx={{
fontWeight: 600,
background: theme.palette.background.paper,
width: '200px',
}}
align="left"
>
Share of stake
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{uniqDelegations?.data?.map(({ owner, amount: { amount } }) => (
<TableRow key={owner}>
<TableCell sx={isMobile ? { width: 190 } : null} align="left">
{owner}
</TableCell>
<TableCell align="left">
{currencyToString({ amount: amount.toString() })}
</TableCell>
<TableCell align="left">
{calcBondPercentage(amount)}%
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
)}
</TableContainer>
)
}
@@ -0,0 +1,114 @@
import * as React from 'react'
import { Box, Button, Grid, Typography, useTheme } from '@mui/material'
import Identicon from 'react-identicons'
import { useIsMobile } from '@/app/hooks/useIsMobile'
import { MixNodeDescriptionResponse } from '@/app/typeDefs/explorer-api'
import { getMixNodeStatusText, MixNodeStatus } from './Status'
import { MixnodeRowType } from '.'
interface MixNodeDetailProps {
mixNodeRow: MixnodeRowType
mixnodeDescription: MixNodeDescriptionResponse
}
export const MixNodeDetailSection: FCWithChildren<MixNodeDetailProps> = ({
mixNodeRow,
mixnodeDescription,
}) => {
const theme = useTheme()
const palette = [theme.palette.text.primary]
const isMobile = useIsMobile()
const statusText = React.useMemo(
() => getMixNodeStatusText(mixNodeRow.status),
[mixNodeRow.status]
)
return (
<Grid container>
<Grid item xs={12} md={6}>
<Box
display="flex"
flexDirection={isMobile ? 'column' : 'row'}
width="100%"
>
<Box
width={72}
height={72}
sx={{
minWidth: 72,
minHeight: 72,
borderWidth: 1,
borderColor: theme.palette.text.primary,
borderStyle: 'solid',
borderRadius: '50%',
display: 'grid',
placeItems: 'center',
}}
>
<Identicon
size={43}
string={mixNodeRow.identity_key}
palette={palette}
/>
</Box>
<Box ml={isMobile ? 0 : 2} mt={isMobile ? 2 : 0}>
<Typography fontSize={21}>{mixnodeDescription.name}</Typography>
<Typography>
{(mixnodeDescription.description || '').slice(0, 1000)}
</Typography>
<Button
component="a"
variant="text"
sx={{
mt: isMobile ? 2 : 4,
borderRadius: '30px',
fontWeight: 600,
padding: 0,
}}
href={mixnodeDescription.link}
target="_blank"
>
<Typography
component="span"
textOverflow="ellipsis"
whiteSpace="nowrap"
overflow="hidden"
maxWidth="250px"
>
{mixnodeDescription.link}
</Typography>
</Button>
</Box>
</Box>
</Grid>
<Grid
item
xs={12}
md={6}
display="flex"
justifyContent={isMobile ? 'start' : 'end'}
mt={isMobile ? 3 : undefined}
>
<Box display="flex" flexDirection="column">
<Typography
fontWeight="600"
alignSelf={isMobile ? 'start' : 'self-end'}
>
Node status:
</Typography>
<Box mt={2} alignSelf={isMobile ? 'start' : 'self-end'}>
<MixNodeStatus status={mixNodeRow.status} />
</Box>
<Typography
mt={1}
alignSelf={isMobile ? 'start' : 'self-end'}
color={theme.palette.text.secondary}
fontSize="smaller"
>
This node is {statusText} in this epoch
</Typography>
</Box>
</Grid>
</Grid>
)
}
@@ -0,0 +1,51 @@
import { ColumnsType } from '../../DetailTable';
export const EconomicsInfoColumns: ColumnsType[] = [
{
field: 'estimatedTotalReward',
title: 'Estimated Total Reward',
width: '15%',
tooltipInfo:
'Estimated node reward (total for the operator and delegators) in the current epoch. There are roughly 24 epochs in a day.',
},
{
field: 'estimatedOperatorReward',
title: 'Estimated Operator Reward',
width: '15%',
tooltipInfo:
"Estimated operator's reward (including PM and Operating Cost) in the current epoch. There are roughly 24 epochs in a day.",
},
{
field: 'selectionChance',
title: 'Active Set Probability',
width: '12.5%',
tooltipInfo:
'Probability of getting selected in the reward set (active and standby nodes) in the next epoch. The more your stake, the higher the chances to be selected.',
},
{
field: 'profitMargin',
title: 'Profit Margin',
width: '12.5%',
tooltipInfo:
'Percentage of the delegators rewards that the operator takes as fee before rewards are distributed to the delegators.',
},
{
field: 'operatingCost',
title: 'Operating Cost',
width: '10%',
tooltipInfo:
'Monthly operational cost of running this node. This cost is set by the operator and it influences how the rewards are split between the operator and delegators.',
},
{
field: 'nodePerformance',
title: 'Routing Score',
width: '10%',
tooltipInfo:
"Mixnode's most recent score (measured in the last 15 minutes). Routing score is relative to that of the network. Each time a gateway is tested, the test packets have to go through the full path of the network (gateway + 3 nodes). If a node in the path drop packets it will affect the score of the gateway and other nodes in the test.",
},
{
field: 'avgUptime',
title: 'Avg. Score',
tooltipInfo: "Mixnode's average routing score in the last 24 hour",
},
];
@@ -0,0 +1,31 @@
import * as React from 'react';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { EconomicsProgress } from './EconomicsProgress';
export default {
title: 'Mix Node Detail/Economics/ProgressBar',
component: EconomicsProgress,
} as ComponentMeta<typeof EconomicsProgress>;
const Template: ComponentStory<typeof EconomicsProgress> = (args) => <EconomicsProgress {...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,
};
@@ -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: FCWithChildren<
LinearProgressProps & {
threshold?: number;
color: string;
}
> = ({ threshold, color, ...props }) => {
const theme = useTheme();
const { value } = props;
const valueNumber: number = parseToNumber(value);
const thresholdNumber: number = parseToNumber(threshold);
const percentageToDisplay = Math.min(valueNumber, thresholdNumber);
return (
<Box
sx={{
width: 6 / 10,
color: valueNumber > (threshold || 100) ? theme.palette.warning.main : theme.palette.nym.wallet.fee,
}}
>
<LinearProgress
{...props}
variant="determinate"
color={color}
value={percentageToDisplay}
sx={{ width: '100%', borderRadius: '5px' }}
/>
</Box>
);
};
@@ -0,0 +1,107 @@
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<typeof DelegatorsInfoTable>;
const row: EconomicsInfoRowWithIndex = {
id: 1,
selectionChance: {
value: 'High',
},
estimatedOperatorReward: {
value: '80000.123456 NYM',
},
estimatedTotalReward: {
value: '80000.123456 NYM',
},
profitMargin: {
value: '10 %',
},
operatingCost: {
value: '11121 NYM',
},
avgUptime: {
value: '-',
},
nodePerformance: {
value: '-',
},
};
const rowGoodProbabilitySelection: EconomicsInfoRowWithIndex = {
...row,
selectionChance: {
value: 'Good',
},
};
const rowLowProbabilitySelection: EconomicsInfoRowWithIndex = {
...row,
selectionChance: {
value: 'Low',
},
};
const emptyRow: EconomicsInfoRowWithIndex = {
id: 1,
selectionChance: {
value: '-',
progressBarValue: 0,
},
estimatedOperatorReward: {
value: '-',
},
estimatedTotalReward: {
value: '-',
},
profitMargin: {
value: '-',
},
operatingCost: {
value: '-',
},
avgUptime: {
value: '-',
},
nodePerformance: {
value: '-',
},
};
const Template: ComponentStory<typeof DelegatorsInfoTable> = (args) => <DelegatorsInfoTable {...args} />;
export const Empty = Template.bind({});
Empty.args = {
rows: [emptyRow],
columnsData: EconomicsInfoColumns,
tableName: 'storybook',
};
export const selectionChanceHigh = Template.bind({});
selectionChanceHigh.args = {
rows: [row],
columnsData: EconomicsInfoColumns,
tableName: 'storybook',
};
export const selectionChanceGood = Template.bind({});
selectionChanceGood.args = {
rows: [rowGoodProbabilitySelection],
columnsData: EconomicsInfoColumns,
tableName: 'storybook',
};
export const selectionChanceLow = Template.bind({});
selectionChanceLow.args = {
rows: [rowLowProbabilitySelection],
columnsData: EconomicsInfoColumns,
tableName: 'storybook',
};
@@ -0,0 +1,57 @@
import { currencyToString, unymToNym } from '@/app/utils/currency';
import { useMixnodeContext } from '@/app/context/mixnode';
import { ApiState, MixNodeEconomicDynamicsStatsResponse } from '@/app/typeDefs/explorer-api';
import { toPercentIntegerString } from '@/app/utils';
import { EconomicsInfoRowWithIndex } from './types';
const selectionChance = (economicDynamicsStats: ApiState<MixNodeEconomicDynamicsStatsResponse> | undefined) =>
economicDynamicsStats?.data?.active_set_inclusion_probability || '-';
export const EconomicsInfoRows = (): EconomicsInfoRowWithIndex => {
const { economicDynamicsStats, mixNode } = useMixnodeContext();
const estimatedNodeRewards =
currencyToString({
amount: economicDynamicsStats?.data?.estimated_total_node_reward.toString() || '',
}) || '-';
const estimatedOperatorRewards =
currencyToString({
amount: economicDynamicsStats?.data?.estimated_operator_reward.toString() || '',
}) || '-';
const profitMargin = mixNode?.data?.profit_margin_percent
? toPercentIntegerString(mixNode?.data?.profit_margin_percent)
: '-';
const avgUptime = mixNode?.data?.node_performance
? toPercentIntegerString(mixNode?.data?.node_performance.last_24h)
: '-';
const nodePerformance = mixNode?.data?.node_performance
? toPercentIntegerString(mixNode?.data?.node_performance.most_recent)
: '-';
const opCost = mixNode?.data?.operating_cost;
return {
id: 1,
estimatedTotalReward: {
value: estimatedNodeRewards,
},
estimatedOperatorReward: {
value: estimatedOperatorRewards,
},
selectionChance: {
value: selectionChance(economicDynamicsStats),
},
profitMargin: {
value: profitMargin ? `${profitMargin} %` : '-',
},
operatingCost: {
value: opCost ? `${unymToNym(opCost.amount, 6)} NYM` : '-',
},
avgUptime: {
value: avgUptime ? `${avgUptime} %` : '-',
},
nodePerformance: {
value: nodePerformance,
},
};
};
@@ -0,0 +1,47 @@
import React from 'react'
import { Box, Typography } from '@mui/material'
import { useIsMobile } from '@/app/hooks/useIsMobile'
import { EconomicsProgress } from './EconomicsProgress'
export const StakeSaturationProgressBar = ({
value,
threshold,
}: {
value: number
threshold: number
}) => {
const isTablet = useIsMobile('lg')
const percentageColor = value > (threshold || 100) ? 'warning' : 'inherit'
const textColor =
percentageColor === 'warning' ? 'warning.main' : 'nym.wallet.fee'
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
flexDirection: isTablet ? 'column' : 'row',
}}
id="field"
color={percentageColor}
>
<Typography
sx={{
mr: isTablet ? 0 : 1,
mb: isTablet ? 1 : 0,
fontWeight: '600',
fontSize: '12px',
color: textColor,
}}
id="stake-saturation-progress-bar"
>
{value}%
</Typography>
<EconomicsProgress
value={value}
threshold={threshold}
color={percentageColor}
/>
</Box>
)
}
@@ -0,0 +1,91 @@
import * as React from 'react'
import {
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from '@mui/material'
import { Box } from '@mui/system'
import { useTheme } from '@mui/material/styles'
import { Tooltip } from '@nymproject/react/tooltip/Tooltip'
import { EconomicsRowsType, EconomicsInfoRowWithIndex } from './types'
import { UniversalTableProps } from '@/app/components/DetailTable'
import { textColour } from '@/app/utils'
const formatCellValues = (value: EconomicsRowsType, field: string) => (
<Box sx={{ display: 'flex', alignItems: 'center' }} id="field">
<Typography sx={{ mr: 1, fontWeight: '600', fontSize: '12px' }} id={field}>
{value.value}
</Typography>
</Box>
)
export const DelegatorsInfoTable: FCWithChildren<
UniversalTableProps<EconomicsInfoRowWithIndex>
> = ({ tableName, columnsData, rows }) => {
const theme = useTheme()
return (
<TableContainer component={Paper}>
<Table sx={{ minWidth: 650 }} aria-label={tableName}>
<TableHead>
<TableRow>
{columnsData?.map(({ field, title, tooltipInfo, width }) => (
<TableCell
key={field}
sx={{ fontSize: 14, fontWeight: 600, width }}
>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
{tooltipInfo && (
<Tooltip
title={tooltipInfo}
id={field}
placement="top-start"
textColor={
theme.palette.nym.networkExplorer.tooltip.color
}
bgColor={
theme.palette.nym.networkExplorer.tooltip.background
}
maxWidth={230}
arrow
/>
)}
{title}
</Box>
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{rows?.map((eachRow) => (
<TableRow
key={eachRow.id}
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
>
{columnsData?.map((_, index: number) => {
const { field } = columnsData[index]
const value: EconomicsRowsType = (eachRow as any)[field]
return (
<TableCell
key={_.title}
sx={{
color: textColour(value, field, theme),
}}
data-testid={`${_.title.replace(/ /g, '-')}-value`}
>
{formatCellValues(value, columnsData[index].field)}
</TableCell>
)
})}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)
}
@@ -0,0 +1,3 @@
export { DelegatorsInfoTable } from './Table';
export { EconomicsInfoColumns } from './Columns';
export { EconomicsInfoRows } from './Rows';
@@ -0,0 +1,20 @@
export type EconomicsRowsType = {
progressBarValue?: number;
value: string;
};
type TEconomicsInfoProperties =
| 'estimatedTotalReward'
| 'estimatedOperatorReward'
| 'estimatedOperatorReward'
| 'selectionChance'
| 'profitMargin'
| 'avgUptime'
| 'nodePerformance'
| 'operatingCost';
export type EconomicsInfoRow = {
[k in TEconomicsInfoProperties]: EconomicsRowsType;
};
export type EconomicsInfoRowWithIndex = EconomicsInfoRow & { id: number };
@@ -0,0 +1,36 @@
import * as React from 'react'
import { Typography } from '@mui/material'
import { getMixNodeIcon } from '@/app/components/Icons'
import { MixnodeStatus } from '@/app/typeDefs/explorer-api'
import { useGetMixNodeStatusColor } from '@/app/hooks/useGetMixnodeStatusColor'
interface MixNodeStatusProps {
status: MixnodeStatus
}
// TODO: should be done with i18n
export const getMixNodeStatusText = (status: MixnodeStatus) => {
switch (status) {
case MixnodeStatus.active:
return 'active'
case MixnodeStatus.standby:
return 'on standby'
default:
return 'inactive'
}
}
export const MixNodeStatus: FCWithChildren<MixNodeStatusProps> = ({
status,
}) => {
const Icon = React.useMemo(() => getMixNodeIcon(status), [status])
const color = useGetMixNodeStatusColor(status)
return (
<Typography color={color} display="flex" alignItems="center">
<Icon />
<Typography ml={1} component="span" color="inherit">
{`${status[0].toUpperCase()}${status.slice(1)}`}
</Typography>
</Typography>
)
}
@@ -0,0 +1,83 @@
import * as React from 'react'
import { MenuItem } from '@mui/material'
import Select from '@mui/material/Select'
import { SelectChangeEvent } from '@mui/material/Select/SelectInput'
import { SxProps } from '@mui/system'
import {
MixnodeStatus,
MixnodeStatusWithAll,
} from '@/app/typeDefs/explorer-api'
import { useIsMobile } from '@/app/hooks/useIsMobile'
import { MixNodeStatus } from './Status'
// TODO: replace with i18n
const ALL_NODES = 'All nodes'
interface MixNodeStatusDropdownProps {
status?: MixnodeStatusWithAll
sx?: SxProps
onSelectionChanged?: (status?: MixnodeStatusWithAll) => void
}
export const MixNodeStatusDropdown: FCWithChildren<
MixNodeStatusDropdownProps
> = ({ status, onSelectionChanged, sx }) => {
const isMobile = useIsMobile()
const [statusValue, setStatusValue] = React.useState<MixnodeStatusWithAll>(
status || MixnodeStatusWithAll.all
)
const onChange = React.useCallback(
(event: SelectChangeEvent) => {
setStatusValue(event.target.value as MixnodeStatusWithAll)
if (onSelectionChanged) {
onSelectionChanged(event.target.value as MixnodeStatusWithAll)
}
},
[onSelectionChanged]
)
return (
<Select
labelId="mixnodeStatusSelect_label"
id="mixnodeStatusSelect"
value={statusValue}
onChange={onChange}
renderValue={(value) => {
switch (value) {
case 'active':
case 'standby':
case 'inactive':
return <MixNodeStatus status={value as unknown as MixnodeStatus} />
default:
return ALL_NODES
}
}}
sx={{
width: isMobile ? '50%' : 200,
...sx,
}}
>
<MenuItem
value={MixnodeStatus.active}
data-testid="mixnodeStatusSelectOption_active"
>
<MixNodeStatus status={MixnodeStatus.active} />
</MenuItem>
<MenuItem
value={MixnodeStatus.standby}
data-testid="mixnodeStatusSelectOption_standby"
>
<MixNodeStatus status={MixnodeStatus.standby} />
</MenuItem>
<MenuItem
value={MixnodeStatus.inactive}
data-testid="mixnodeStatusSelectOption_inactive"
>
<MixNodeStatus status={MixnodeStatus.inactive} />
</MenuItem>
<MenuItem value={'all'} data-testid="mixnodeStatusSelectOption_allNodes">
{ALL_NODES}
</MenuItem>
</Select>
)
}
@@ -0,0 +1,3 @@
export * from './Status';
export * from './StatusDropdown';
export * from './mappings';
@@ -0,0 +1,57 @@
/* eslint-disable camelcase */
import { MixNodeResponse, MixNodeResponseItem, MixnodeStatus } from '../../typeDefs/explorer-api';
import { toPercentInteger, toPercentIntegerString } from '@/app/utils';
import { unymToNym } from '@/app/utils/currency';
export type MixnodeRowType = {
mix_id: number;
id: string;
status: MixnodeStatus;
owner: string;
location: string;
identity_key: string;
bond: number;
self_percentage: string;
pledge_amount: number;
host: string;
layer: string;
profit_percentage: number;
avg_uptime: string;
stake_saturation: React.ReactNode;
operating_cost: number;
node_performance: number;
blacklisted: boolean;
};
export function mixnodeToGridRow(arrayOfMixnodes?: MixNodeResponse): MixnodeRowType[] {
return (arrayOfMixnodes || []).map(mixNodeResponseItemToMixnodeRowType);
}
export function mixNodeResponseItemToMixnodeRowType(item: MixNodeResponseItem): MixnodeRowType {
const pledge = Number(item.pledge_amount.amount) || 0;
const delegations = Number(item.total_delegation.amount) || 0;
const totalBond = pledge + delegations;
const selfPercentage = ((pledge * 100) / totalBond).toFixed(2);
const profitPercentage = toPercentInteger(item.profit_margin_percent) || 0;
const uncappedSaturation = typeof item.uncapped_saturation === 'number' ? item.uncapped_saturation * 100 : 0;
return {
mix_id: item.mix_id,
id: item.owner,
status: item.status,
owner: item.owner,
identity_key: item.mix_node.identity_key || '',
bond: totalBond || 0,
location: item?.location?.country_name || '',
self_percentage: selfPercentage,
pledge_amount: pledge,
host: item?.mix_node?.host || '',
layer: item?.layer || '',
profit_percentage: profitPercentage,
avg_uptime: `${toPercentIntegerString(item.node_performance.last_24h)}%`,
stake_saturation: Number(uncappedSaturation.toFixed(2)),
operating_cost: Number(unymToNym(item.operating_cost?.amount, 6)) || 0,
node_performance: toPercentInteger(item.node_performance.most_recent),
blacklisted: item.blacklisted,
};
}
@@ -0,0 +1,373 @@
'use client'
import * as React from 'react'
import { ExpandLess, ExpandMore, Menu } from '@mui/icons-material'
import { CSSObject, styled, Theme, useTheme } from '@mui/material/styles'
import { Link as MuiLink } from '@mui/material'
import Button from '@mui/material/Button'
import Box from '@mui/material/Box'
import ListItem from '@mui/material/ListItem'
import MuiDrawer from '@mui/material/Drawer'
import AppBar from '@mui/material/AppBar'
import Toolbar from '@mui/material/Toolbar'
import Typography from '@mui/material/Typography'
import List from '@mui/material/List'
import IconButton from '@mui/material/IconButton'
import ListItemButton from '@mui/material/ListItemButton'
import ListItemIcon from '@mui/material/ListItemIcon'
import ListItemText from '@mui/material/ListItemText'
import { NYM_WEBSITE } from '@/app/api/constants'
import { useMainContext } from '@/app/context/main'
import { MobileDrawerClose } from '@/app/icons/MobileDrawerClose'
import { NavOptionType, originalNavOptions } from '@/app/context/nav'
import { DarkLightSwitchDesktop } from '@/app/components/Switch'
import { Footer } from '@/app/components/Footer'
import { ConnectKeplrWallet } from '@/app/components/Wallet/ConnectKeplrWallet'
import { usePathname, useRouter } from 'next/navigation'
const drawerWidth = 255
const bannerHeight = 80
const openedMixin = (theme: Theme): CSSObject => ({
width: drawerWidth,
transition: theme.transitions.create('width', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
}),
overflowX: 'hidden',
})
const closedMixin = (theme: Theme): CSSObject => ({
transition: theme.transitions.create('width', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
overflowX: 'hidden',
width: `calc(${theme.spacing(7)} + 1px)`,
})
const DrawerHeader = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
padding: theme.spacing(0, 1),
height: 64,
}))
const Drawer = styled(MuiDrawer, {
shouldForwardProp: (prop) => prop !== 'open',
})(({ theme, open }) => ({
width: drawerWidth,
flexShrink: 0,
whiteSpace: 'nowrap',
boxSizing: 'border-box',
...(open && {
...openedMixin(theme),
'& .MuiDrawer-paper': openedMixin(theme),
}),
...(!open && {
...closedMixin(theme),
'& .MuiDrawer-paper': closedMixin(theme),
}),
}))
type ExpandableButtonType = {
title: string
url: string
isActive?: boolean
Icon?: React.ReactNode
nested?: NavOptionType[]
isChild?: boolean
isMobile: boolean
drawIsTempOpen: boolean
drawIsFixed: boolean
isExternalLink?: boolean
openDrawer: () => void
closeDrawer?: () => void
fixDrawerClose?: () => void
}
export const ExpandableButton: FCWithChildren<ExpandableButtonType> = ({
title,
url,
drawIsTempOpen,
drawIsFixed,
Icon,
nested,
isMobile,
isChild,
isExternalLink,
openDrawer,
closeDrawer,
fixDrawerClose,
}) => {
const { palette } = useTheme()
const pathname = usePathname()
const router = useRouter()
const handleClick = () => {
if (title === 'Network Components') {
return undefined
}
if (isExternalLink) {
window.open(url, '_blank')
return undefined
}
if (!isExternalLink) {
router.push(url, {})
}
if (closeDrawer) {
closeDrawer()
}
}
const selectedStyle = {
background: palette.nym.networkExplorer.nav.selected.main,
borderRight: `3px solid ${palette.nym.highlight}`,
}
return (
<>
<ListItem
disablePadding
disableGutters
sx={{
borderBottom: isChild ? 'none' : '1px solid rgba(255, 255, 255, 0.1)',
...(pathname === url
? selectedStyle
: {
background: palette.nym.networkExplorer.nav.background,
borderRight: 'none',
}),
}}
>
<ListItemButton
onClick={() => handleClick()}
sx={{
pt: 2,
pb: 2,
background: isChild
? palette.nym.networkExplorer.nav.selected.nested
: 'none',
}}
>
<ListItemIcon sx={{ minWidth: '39px' }}>{Icon}</ListItemIcon>
<ListItemText
primary={title}
sx={{
color: palette.nym.networkExplorer.nav.text,
}}
/>
</ListItemButton>
</ListItem>
{nested?.map((each) => (
<ExpandableButton
url={each.url}
key={each.title}
title={each.title}
openDrawer={openDrawer}
drawIsTempOpen={drawIsTempOpen}
closeDrawer={closeDrawer}
drawIsFixed={drawIsFixed}
fixDrawerClose={fixDrawerClose}
isMobile={isMobile}
isChild
isExternalLink={each.isExternal}
/>
))}
</>
)
}
export const Nav: FCWithChildren = ({ children }) => {
const { environment } = useMainContext()
const [drawerIsOpen, setDrawerToOpen] = React.useState(false)
const [fixedOpen, setFixedOpen] = React.useState(false)
// Set maintenance banner to false by default to don't display it
const [openMaintenance, setOpenMaintenance] = React.useState(false)
const theme = useTheme()
const explorerName = environment
? `${environment} Explorer`
: 'Mainnet Explorer'
const switchNetworkText =
environment === 'mainnet' ? 'Switch to Testnet' : 'Switch to Mainnet'
const switchNetworkLink =
environment === 'mainnet'
? 'https://sandbox-explorer.nymtech.net'
: 'https://explorer.nymtech.net'
const fixDrawerOpen = () => {
setFixedOpen(true)
setDrawerToOpen(true)
}
const fixDrawerClose = () => {
setFixedOpen(false)
setDrawerToOpen(false)
}
const tempDrawerOpen = () => {
if (!fixedOpen) {
setDrawerToOpen(true)
}
}
const tempDrawerClose = () => {
if (!fixedOpen) {
setDrawerToOpen(false)
}
}
return (
<Box sx={{ display: 'flex' }}>
<AppBar
sx={{
background: theme.palette.nym.networkExplorer.topNav.appBar,
borderRadius: 0,
}}
>
<Toolbar
disableGutters
sx={{
display: 'flex',
justifyContent: 'space-between',
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
ml: 0.5,
}}
>
<IconButton component="a" href={NYM_WEBSITE} target="_blank">
{/* <NymLogo /> */}
</IconButton>
<Typography
variant="h6"
noWrap
sx={{
color: theme.palette.nym.networkExplorer.nav.text,
fontSize: '18px',
fontWeight: 600,
}}
>
<MuiLink
href="/"
underline="none"
color="inherit"
textTransform="capitalize"
>
{explorerName}
</MuiLink>
<Button
size="small"
variant="outlined"
color="inherit"
href={switchNetworkLink}
sx={{
borderRadius: 2,
textTransform: 'none',
width: 150,
ml: 4,
fontSize: 14,
fontWeight: 600,
}}
>
{switchNetworkText}
</Button>
</Typography>
</Box>
<Box
sx={{
mr: 2,
alignItems: 'center',
display: 'flex',
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
width: 'auto',
pr: 0,
pl: 2,
justifyContent: 'flex-end',
alignItems: 'center',
}}
>
<Box sx={{ mr: 1 }}>
<ConnectKeplrWallet />
</Box>
<DarkLightSwitchDesktop defaultChecked />
</Box>
</Box>
</Toolbar>
</AppBar>
<Drawer
variant="permanent"
open={true}
PaperProps={{
style: {
background: theme.palette.nym.networkExplorer.nav.background,
borderRadius: 0,
top: openMaintenance ? bannerHeight : 0,
},
}}
>
<DrawerHeader
sx={{
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
justifyContent: 'flex-start',
paddingLeft: 0,
display: 'none',
}}
>
<IconButton
onClick={drawerIsOpen ? fixDrawerClose : fixDrawerOpen}
sx={{
padding: 1,
ml: 1,
color: theme.palette.nym.networkExplorer.nav.text,
}}
>
{drawerIsOpen ? <MobileDrawerClose /> : <Menu />}
</IconButton>
</DrawerHeader>
<List
sx={{ pb: 0 }}
onMouseEnter={tempDrawerOpen}
onMouseLeave={tempDrawerClose}
>
{originalNavOptions.map((props) => (
<ExpandableButton
key={props.url}
closeDrawer={tempDrawerClose}
drawIsTempOpen={drawerIsOpen}
drawIsFixed={fixedOpen}
fixDrawerClose={fixDrawerClose}
openDrawer={tempDrawerOpen}
isMobile={false}
{...props}
/>
))}
</List>
</Drawer>
<Box
style={{ width: `calc(100% - ${drawerWidth}px` }}
sx={{ py: 5, px: 6, mt: 7 }}
>
{children}
<Footer />
</Box>
</Box>
)
}
@@ -0,0 +1,136 @@
'use client'
import * as React from 'react'
import { useTheme } from '@mui/material/styles'
import {
AppBar,
Box,
Drawer,
IconButton,
List,
ListItem,
ListItemButton,
ListItemIcon,
Toolbar,
} from '@mui/material'
import { Menu } from '@mui/icons-material'
import { MaintenanceBanner } from '@nymproject/react/banners/MaintenanceBanner'
import { useIsMobile } from '@/app/hooks/useIsMobile'
import { MobileDrawerClose } from '@/app/icons/MobileDrawerClose'
import { Footer } from '../Footer'
import { ExpandableButton } from './DesktopNav'
import { ConnectKeplrWallet } from '../Wallet/ConnectKeplrWallet'
import { NetworkTitle } from '../NetworkTitle'
import { originalNavOptions } from '@/app/context/nav'
export const MobileNav: FCWithChildren = ({ children }) => {
const theme = useTheme()
const [drawerOpen, setDrawerOpen] = React.useState(false)
// Set maintenance banner to false by default to don't display it
const [openMaintenance, setOpenMaintenance] = React.useState(false)
const isSmallMobile = useIsMobile(400)
const toggleDrawer = () => {
setDrawerOpen(!drawerOpen)
}
const openDrawer = () => {
setDrawerOpen(true)
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<AppBar
sx={{
background: theme.palette.nym.networkExplorer.topNav.appBar,
borderRadius: 0,
}}
>
<MaintenanceBanner
open={openMaintenance}
onClick={() => setOpenMaintenance(false)}
/>
<Toolbar
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
}}
>
<IconButton onClick={toggleDrawer}>
<Menu sx={{ color: 'primary.contrastText' }} />
</IconButton>
{!isSmallMobile && <NetworkTitle />}
</Box>
<ConnectKeplrWallet />
</Toolbar>
</AppBar>
<Drawer
anchor="left"
open={drawerOpen}
onClose={toggleDrawer}
PaperProps={{
style: {
background: theme.palette.nym.networkExplorer.nav.background,
},
}}
>
<Box role="presentation">
<List sx={{ pt: 0, pb: 0 }}>
<ListItem
disablePadding
disableGutters
sx={{
height: 64,
background: theme.palette.nym.networkExplorer.nav.background,
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
}}
>
<ListItemButton
onClick={toggleDrawer}
sx={{
pt: 2,
pb: 2,
background: theme.palette.nym.networkExplorer.nav.background,
display: 'flex',
justifyContent: 'flex-start',
}}
>
<ListItemIcon>
<MobileDrawerClose />
</ListItemIcon>
</ListItemButton>
</ListItem>
{originalNavOptions.map((props) => (
<ExpandableButton
key={props.url}
title={props.title}
openDrawer={openDrawer}
url={props.url}
drawIsTempOpen={false}
drawIsFixed={false}
Icon={props.Icon}
nested={props.nested}
closeDrawer={toggleDrawer}
isMobile
/>
))}
</List>
</Box>
</Drawer>
<Box sx={{ width: '100%', p: 4, mt: 7 }}>
{children}
<Footer />
</Box>
</Box>
)
}
@@ -0,0 +1,18 @@
'use client'
import React from 'react'
import { useIsMobile } from '@/app/hooks'
import { MobileNav } from './MobileNav'
import { Nav } from './DesktopNav'
const Navbar = ({ children }: { children: React.ReactNode }) => {
const isMobile = useIsMobile()
if (isMobile) {
return <MobileNav>{children}</MobileNav>
}
return <Nav>{children}</Nav>
}
export { Navbar }
@@ -0,0 +1,57 @@
import React from 'react'
import { Button, Typography, Link as MuiLink } from '@mui/material'
import { useMainContext } from '@/app/context/main'
type NetworkTitleProps = {
showToggleNetwork?: boolean
}
const NetworkTitle = ({ showToggleNetwork }: NetworkTitleProps) => {
const { environment } = useMainContext()
const explorerName =
`${
environment && environment.charAt(0).toUpperCase() + environment.slice(1)
} Explorer` || 'Mainnet Explorer'
const switchNetworkText =
environment === 'mainnet' ? 'Switch to Testnet' : 'Switch to Mainnet'
const switchNetworkLink =
environment === 'mainnet'
? 'https://sandbox-explorer.nymtech.net'
: 'https://explorer.nymtech.net'
return (
<Typography
variant="h6"
noWrap
sx={{
color: 'nym.networkExplorer.nav.text',
fontSize: '18px',
fontWeight: 600,
}}
>
<MuiLink href="/" underline="none" color="inherit" fontWeight={700}>
{explorerName}
</MuiLink>
{showToggleNetwork && (
<Button
variant="outlined"
color="inherit"
href={switchNetworkLink}
sx={{
textTransform: 'none',
width: 114,
fontSize: '12px',
fontWeight: 600,
ml: 1,
}}
>
{switchNetworkText}
</Button>
)}
</Typography>
)
}
export { NetworkTitle }
@@ -0,0 +1,58 @@
import * as React from 'react'
import { Box, IconButton } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import { TelegramIcon } from '../icons/socials/TelegramIcon'
import { GitHubIcon } from '../icons/socials/GitHubIcon'
import { TwitterIcon } from '../icons/socials/TwitterIcon'
import { DiscordIcon } from '../icons/socials/DiscordIcon'
// socials
export const TELEGRAM_LINK = 'https://t.me/nymchan'
export const TWITTER_LINK = 'https://twitter.com/nymproject'
export const GITHUB_LINK = 'https://github.com/nymtech'
export const DISCORD_LINK = 'https://discord.gg/nym'
export const Socials: FCWithChildren<{ isFooter?: boolean }> = ({
isFooter = false,
}) => {
const theme = useTheme()
const color = isFooter
? theme.palette.nym.networkExplorer.footer.socialIcons
: theme.palette.nym.networkExplorer.topNav.socialIcons
return (
<Box>
<IconButton
component="a"
href={TELEGRAM_LINK}
target="_blank"
data-testid="telegram"
>
<TelegramIcon color={color} size={24} />
</IconButton>
<IconButton
component="a"
href={DISCORD_LINK}
target="_blank"
data-testid="discord"
>
<DiscordIcon color={color} size={24} />
</IconButton>
<IconButton
component="a"
href={TWITTER_LINK}
target="_blank"
data-testid="twitter"
>
<TwitterIcon color={color} size={24} />
</IconButton>
<IconButton
component="a"
href={GITHUB_LINK}
target="_blank"
data-testid="github"
>
<GitHubIcon color={color} size={24} />
</IconButton>
</Box>
)
}
@@ -0,0 +1,73 @@
import * as React from 'react'
import { Box, Card, CardContent, IconButton, Typography } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import EastIcon from '@mui/icons-material/East'
interface StatsCardProps {
icon: React.ReactNode
title: string
count?: string | number
errorMsg?: Error | string
onClick?: () => void
color?: string
}
export const StatsCard: FCWithChildren<StatsCardProps> = ({
icon,
title,
count,
onClick,
errorMsg,
color: colorProp,
}) => {
const theme = useTheme()
const color = colorProp || theme.palette.text.primary
return (
<Card onClick={onClick} sx={{ height: '100%' }}>
<CardContent
sx={{
padding: 1.5,
paddingLeft: 3,
'&:last-child': {
paddingBottom: 1.5,
},
cursor: 'pointer',
fontSize: 14,
fontWeight: 600,
}}
>
<Box display="flex" alignItems="center" color={color}>
<Box display="flex">
{icon}
<Typography
ml={3}
mr={0.75}
fontSize="inherit"
fontWeight="inherit"
data-testid={`${title}-amount`}
>
{count === undefined || count === null ? '' : count}
</Typography>
<Typography
mr={1}
fontSize="inherit"
fontWeight="inherit"
data-testid={title}
>
{title}
</Typography>
</Box>
<IconButton color="inherit" sx={{ fontSize: '16px' }}>
<EastIcon fontSize="inherit" />
</IconButton>
</Box>
{errorMsg && (
<Typography variant="body2" sx={{ color: 'danger', padding: 2 }}>
{typeof errorMsg === 'string'
? errorMsg
: errorMsg.message || 'Oh no! An error occurred'}
</Typography>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,34 @@
import React from 'react'
import { Link as MuiLink, SxProps, Typography } from '@mui/material'
import Link from 'next/link'
type StyledLinkProps = {
to: string
children: string
target?: React.HTMLAttributeAnchorTarget
dataTestId?: string
color?: string
sx?: SxProps
}
const StyledLink = ({
to,
children,
dataTestId,
target,
color,
sx,
}: StyledLinkProps) => (
<Link
href={to}
target={target}
data-testid={dataTestId}
style={{ textDecoration: 'none' }}
>
<Typography component="a" sx={{ ...sx }} color={color}>
{children}
</Typography>
</Link>
)
export default StyledLink
+70
View File
@@ -0,0 +1,70 @@
import * as React from 'react';
import { styled } from '@mui/material/styles';
import Switch from '@mui/material/Switch';
import { Button } from '@mui/material';
import { useMainContext } from '../context/main';
import { LightSwitchSVG } from '../icons/LightSwitchSVG';
export const DarkLightSwitch = styled(Switch)(({ theme }) => ({
width: 55,
height: 34,
padding: 7,
'& .MuiSwitch-switchBase': {
margin: 1,
padding: 2,
transform: 'translateX(4px)',
'&.Mui-checked': {
color: '#fff',
transform: 'translateX(22px)',
'& .MuiSwitch-thumb:before': {
backgroundImage:
'url(\'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="20" width="20" viewBox="0 0 20 20"><path fill="black" d="M4.2 2.5l-.7 1.8-1.8.7 1.8.7.7 1.8.6-1.8L6.7 5l-1.9-.7-.6-1.8zm15 8.3a6.7 6.7 0 11-6.6-6.6 5.8 5.8 0 006.6 6.6z"/></svg>\')',
},
'& + .MuiSwitch-track': {
opacity: 1,
backgroundColor: theme.palette.mode === 'dark' ? '#8796A5' : '#aab4be',
},
},
},
'& .MuiSwitch-thumb': {
backgroundColor: theme.palette.nym.networkExplorer.nav.text,
width: 25,
height: 25,
marginTop: '2px',
'&:before': {
content: "''",
position: 'absolute',
width: '100%',
height: '100%',
left: 0,
top: 0,
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
backgroundImage:
'url(\'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="20" width="20" viewBox="0 0 20 20"><path fill="black" d="M9.305 1.667V3.75h1.389V1.667h-1.39zm-4.707 1.95l-.982.982L5.09 6.072l.982-.982-1.473-1.473zm10.802 0L13.927 5.09l.982.982 1.473-1.473-.982-.982zM10 5.139a4.872 4.872 0 00-4.862 4.86A4.872 4.872 0 0010 14.862 4.872 4.872 0 0014.86 10 4.872 4.872 0 0010 5.139zm0 1.389A3.462 3.462 0 0113.471 10a3.462 3.462 0 01-3.473 3.472A3.462 3.462 0 016.527 10 3.462 3.462 0 0110 6.528zM1.665 9.305v1.39h2.083v-1.39H1.666zm14.583 0v1.39h2.084v-1.39h-2.084zM5.09 13.928L3.616 15.4l.982.982 1.473-1.473-.982-.982zm9.82 0l-.982.982 1.473 1.473.982-.982-1.473-1.473zM9.305 16.25v2.083h1.389V16.25h-1.39z"/></svg>\')',
},
},
'& .MuiSwitch-track': {
opacity: 1,
backgroundColor: theme.palette.mode === 'dark' ? '#8796A5' : '#aab4be',
borderRadius: 20 / 2,
},
}));
export const DarkLightSwitchMobile: FCWithChildren = () => {
const { toggleMode } = useMainContext();
return (
<Button onClick={() => toggleMode()} data-testid="switch-button" sx={{ p: 0, minWidth: 0 }}>
<LightSwitchSVG />
</Button>
);
};
export const DarkLightSwitchDesktop: FCWithChildren<{ defaultChecked: boolean }> = ({ defaultChecked }) => {
const { toggleMode } = useMainContext();
return (
<Button sx={{ paddingLeft: 0 }} onClick={() => toggleMode()} data-testid="switch-button">
<DarkLightSwitch defaultChecked={defaultChecked} />
</Button>
);
};
@@ -0,0 +1,61 @@
'use client'
import React from 'react'
import { Box, SelectChangeEvent } from '@mui/material'
import { useIsMobile } from '@/app/hooks/useIsMobile'
import { Filters } from './Filters/Filters'
const fieldsHeight = '42.25px'
type TableToolBarProps = {
childrenBefore?: React.ReactNode
childrenAfter?: React.ReactNode
}
export const TableToolbar: FCWithChildren<TableToolBarProps> = ({
childrenBefore,
childrenAfter,
}) => {
const isMobile = useIsMobile()
return (
<Box
sx={{
width: '100%',
marginBottom: 2,
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
justifyContent: 'space-between',
}}
>
<Box
sx={{
display: 'flex',
flexDirection: isMobile ? 'column-reverse' : 'row',
alignItems: 'middle',
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
height: fieldsHeight,
}}
>
{childrenBefore}
</Box>
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'end',
gap: 1,
marginTop: isMobile ? 2 : 0,
}}
>
{childrenAfter}
</Box>
</Box>
)
}
+14
View File
@@ -0,0 +1,14 @@
import * as React from 'react';
import { Typography } from '@mui/material';
export const Title: FCWithChildren<{ text: string }> = ({ text }) => (
<Typography
variant="h5"
sx={{
fontWeight: 600,
}}
data-testid={text}
>
{text}
</Typography>
);
@@ -0,0 +1,36 @@
import React, { ReactElement } from 'react';
import { Tooltip as MUITooltip, TooltipComponentsPropsOverrides, TooltipProps } from '@mui/material';
type ValueType<T> = T[keyof T];
type Props = {
text: string;
id: string;
placement?: ValueType<Pick<TooltipProps, 'placement'>>;
tooltipSx?: TooltipComponentsPropsOverrides;
children: React.ReactNode;
};
export const Tooltip = ({ text, id, placement, tooltipSx, children }: Props) => (
<MUITooltip
title={text}
id={id}
placement={placement || 'top-start'}
componentsProps={{
tooltip: {
sx: {
maxWidth: 200,
background: (t) => t.palette.nym.networkExplorer.tooltip.background,
color: (t) => t.palette.nym.networkExplorer.tooltip.color,
'& .MuiTooltip-arrow': {
color: (t) => t.palette.nym.networkExplorer.tooltip.background,
},
},
...tooltipSx,
},
}}
arrow
>
{children as ReactElement<any, any>}
</MUITooltip>
);
@@ -0,0 +1,78 @@
import * as React from 'react';
import { CircularProgress, Typography } from '@mui/material';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableRow from '@mui/material/TableRow';
import Paper from '@mui/material/Paper';
import CheckCircleSharpIcon from '@mui/icons-material/CheckCircleSharp';
import ErrorIcon from '@mui/icons-material/Error';
interface TableProps {
title?: string;
icons?: boolean[];
keys: string[];
values: number[];
marginBottom?: boolean;
error?: string;
loading: boolean;
}
export const TwoColSmallTable: FCWithChildren<TableProps> = ({
loading,
title,
icons,
keys,
values,
marginBottom,
error,
}) => (
<>
{title && <Typography sx={{ marginTop: 2 }}>{title}</Typography>}
<TableContainer component={Paper} sx={marginBottom ? { marginBottom: 4, marginTop: 2 } : { marginTop: 2 }}>
<Table aria-label="two col small table">
<TableBody>
{keys.map((each: string, i: number) => (
<TableRow key={each}>
{icons && <TableCell>{icons[i] ? <CheckCircleSharpIcon /> : <ErrorIcon />}</TableCell>}
<TableCell sx={error ? { opacity: 0.4 } : null} data-testid={each.replace(/ /g, '')}>
{each}
</TableCell>
<TableCell
sx={error ? { opacity: 0.4 } : null}
align="right"
data-testid={`${each.replace(/ /g, '-')}-value`}
>
{values[i]}
</TableCell>
{error && (
<TableCell align="right" sx={{ opacity: 0.4 }}>
{values[i]}
</TableCell>
)}
{!error && loading && (
<TableCell align="right">
<CircularProgress />
</TableCell>
)}
{error && !icons && (
<TableCell sx={{ opacity: 0.2 }} align="right">
<ErrorIcon />
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</>
);
TwoColSmallTable.defaultProps = {
title: undefined,
icons: undefined,
marginBottom: false,
error: undefined,
};
@@ -0,0 +1,96 @@
'use client'
import * as React from 'react'
import { makeStyles } from '@mui/styles'
import {
DataGrid,
GridColDef,
GridEventListener,
useGridApiContext,
} from '@mui/x-data-grid'
import Pagination from '@mui/material/Pagination'
import { LinearProgress } from '@mui/material'
import { GridInitialStateCommunity } from '@mui/x-data-grid/models/gridStateCommunity'
const useStyles = makeStyles({
root: {
display: 'flex',
},
})
const CustomPagination = () => {
const apiRef = useGridApiContext()
const classes = useStyles()
console.log(apiRef.current.state)
return (
<Pagination
className={classes.root}
sx={{ mt: 2 }}
color="primary"
count={apiRef.current.state.pagination.paginationModel.pageSize}
page={apiRef.current.state.pagination.paginationModel.page + 1}
onChange={(_, value) => apiRef.current.setPage(value - 1)}
/>
)
}
type DataGridProps = {
columns: GridColDef[]
pagination?: true | undefined
pageSize?: string | undefined
rows: any
loading?: boolean
initialState?: GridInitialStateCommunity
onRowClick?: GridEventListener<'rowClick'> | undefined
}
export const UniversalDataGrid: FCWithChildren<DataGridProps> = ({
rows,
columns,
loading,
pagination,
pageSize,
initialState,
onRowClick,
}) => {
if (loading) return <LinearProgress />
return (
<DataGrid
onRowClick={onRowClick}
pagination={pagination}
rows={rows}
slots={{
pagination: CustomPagination,
}}
columns={columns}
autoHeight
hideFooter={!pagination}
initialState={initialState}
style={{
width: '100%',
border: 'none',
}}
sx={{
'*::-webkit-scrollbar': {
width: '1em',
},
'*::-webkit-scrollbar-track': {
background: (t) => t.palette.nym.networkExplorer.scroll.backgroud,
outline: (t) =>
`1px solid ${t.palette.nym.networkExplorer.scroll.border}`,
boxShadow: 'auto',
borderRadius: 'auto',
},
'*::-webkit-scrollbar-thumb': {
backgroundColor: (t) => t.palette.nym.networkExplorer.scroll.color,
borderRadius: '20px',
width: '.4em',
border: (t) =>
`3px solid ${t.palette.nym.networkExplorer.scroll.backgroud}`,
shadow: 'auto',
},
}}
/>
)
}
@@ -0,0 +1,115 @@
import * as React from 'react';
import { CircularProgress, Typography } from '@mui/material';
import { useTheme } from '@mui/material/styles';
import { Chart } from 'react-google-charts';
import { format } from 'date-fns';
import { ApiState, UptimeStoryResponse } from '../typeDefs/explorer-api';
interface ChartProps {
title?: string;
xLabel: string;
yLabel?: string;
uptimeStory: ApiState<UptimeStoryResponse>;
loading: boolean;
}
type FormattedDateRecord = [string, number];
type FormattedChartHeadings = string[];
type FormattedChartData = [FormattedChartHeadings | FormattedDateRecord];
export const UptimeChart: FCWithChildren<ChartProps> = ({ title, xLabel, yLabel, uptimeStory, loading }) => {
const [formattedChartData, setFormattedChartData] = React.useState<FormattedChartData>();
const theme = useTheme();
const color = theme.palette.text.primary;
React.useEffect(() => {
if (uptimeStory.data?.history) {
const allFormattedChartData: FormattedChartData = [['Date', 'Score']];
uptimeStory.data.history.forEach((eachDate) => {
const formattedDateUptimeRecord: FormattedDateRecord = [
format(new Date(eachDate.date), 'MMM dd'),
eachDate.uptime,
];
allFormattedChartData.push(formattedDateUptimeRecord);
});
setFormattedChartData(allFormattedChartData);
} else {
const emptyData: any = [
['Date', 'Score'],
['Jul 27', 10],
];
setFormattedChartData(emptyData);
}
}, [uptimeStory]);
return (
<>
{title && <Typography>{title}</Typography>}
{loading && <CircularProgress />}
{!loading && uptimeStory && (
<Chart
style={{ minHeight: 480 }}
chartType="LineChart"
loader={<p>...</p>}
data={
uptimeStory.data
? formattedChartData
: [
['Date', 'Routing Score'],
[format(new Date(Date.now()), 'MMM dd'), 0],
]
}
options={{
backgroundColor:
theme.palette.mode === 'dark' ? theme.palette.nym.networkExplorer.background.tertiary : undefined,
color: uptimeStory.error ? 'rgba(255, 255, 255, 0.4)' : 'rgba(255, 255, 255, 1)',
colors: ['#FB7A21'],
legend: {
textStyle: {
color,
opacity: uptimeStory.error ? 0.4 : 1,
},
},
intervals: { style: 'sticks' },
hAxis: {
// horizontal / date
title: xLabel,
titleTextStyle: {
color,
},
textStyle: {
color,
// fontSize: 11
},
gridlines: {
count: -1,
},
},
vAxis: {
// vertical / % Routing Score
viewWindow: {
min: 0,
max: 100,
},
title: yLabel,
titleTextStyle: {
color,
opacity: uptimeStory.error ? 0.4 : 1,
},
textStyle: {
color,
fontSize: 11,
opacity: uptimeStory.error ? 0.4 : 1,
},
},
}}
/>
)}
</>
);
};
UptimeChart.defaultProps = {
title: undefined,
};
@@ -0,0 +1,50 @@
import React from 'react'
import { Button, IconButton, Stack, CircularProgress } from '@mui/material'
import CloseIcon from '@mui/icons-material/Close'
import { useIsMobile } from '@/app/hooks/useIsMobile'
import { useWalletContext } from '@/app/context/wallet'
import { WalletAddress, WalletBalance } from '@/app/components/Wallet'
export const ConnectKeplrWallet = () => {
const {
connectWallet,
disconnectWallet,
isWalletConnected,
isWalletConnecting,
} = useWalletContext()
const isMobile = useIsMobile(1200)
if (!connectWallet || !disconnectWallet) {
return null
}
if (isWalletConnected) {
return (
<Stack direction="row" spacing={1}>
<WalletBalance />
<WalletAddress />
<IconButton
size="small"
onClick={async () => {
await disconnectWallet()
}}
>
<CloseIcon fontSize="small" sx={{ color: 'white' }} />
</IconButton>
</Stack>
)
}
return (
<Button
variant="outlined"
onClick={() => connectWallet()}
disabled={isWalletConnecting}
endIcon={
isWalletConnecting && <CircularProgress size={14} color="inherit" />
}
>
Connect {isMobile ? '' : ' Wallet'}
</Button>
)
}
@@ -0,0 +1,20 @@
import React from 'react'
import { Box, Typography } from '@mui/material'
import { ElipsSVG } from '@/app/icons/ElipsSVG'
import { trimAddress } from '@/app/utils'
import { useWalletContext } from '@/app/context/wallet'
export const WalletAddress = () => {
const { address } = useWalletContext()
const displayAddress = trimAddress(address, 7)
return (
<Box display="flex" alignItems="center" gap={0.5}>
<ElipsSVG />
<Typography variant="body1" fontWeight={600}>
{displayAddress}
</Typography>
</Box>
)
}
@@ -0,0 +1,25 @@
import React from 'react'
import { Box, Typography } from '@mui/material'
import { useWalletContext } from '@/app/context/wallet'
import { useIsMobile } from '@/app/hooks'
import { TokenSVG } from '@/app/icons/TokenSVG'
export const WalletBalance = () => {
const { balance } = useWalletContext()
const isMobile = useIsMobile(1200)
const showBalance = !isMobile && balance.status === 'success'
if (!showBalance) {
return null
}
return (
<Box display="flex" alignItems="center" gap={1}>
<TokenSVG />
<Typography variant="body1" fontWeight={600}>
{balance.data} NYM
</Typography>
</Box>
)
}
@@ -0,0 +1,2 @@
export * from './WalletBalance';
export * from './WalletAddress';
+129
View File
@@ -0,0 +1,129 @@
'use client'
import * as React from 'react'
import { scaleLinear } from 'd3-scale'
import {
ComposableMap,
Geographies,
Geography,
Marker,
ZoomableGroup,
} from 'react-simple-maps'
import ReactTooltip from 'react-tooltip'
import { CircularProgress } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import { ApiState, CountryDataResponse } from '../typeDefs/explorer-api'
import MAP_TOPOJSON from '../assets/world-110m.json'
type MapProps = {
userLocation?: [number, number]
countryData?: ApiState<CountryDataResponse>
loading: boolean
}
export const WorldMap: FCWithChildren<MapProps> = ({
countryData,
userLocation,
loading,
}) => {
const { palette } = useTheme()
const colorScale = React.useMemo(() => {
if (countryData?.data) {
const heighestNumberOfNodes = Math.max(
...Object.values(countryData.data).map((country) => country.nodes)
)
return scaleLinear<string, string>()
.domain([
0,
1,
heighestNumberOfNodes / 4,
heighestNumberOfNodes / 2,
heighestNumberOfNodes,
])
.range(palette.nym.networkExplorer.map.fills)
.unknown(palette.nym.networkExplorer.map.fills[0])
}
return () => palette.nym.networkExplorer.map.fills[0]
}, [countryData, palette])
const [tooltipContent, setTooltipContent] = React.useState<string | null>(
null
)
if (loading) {
return <CircularProgress />
}
return (
<>
<ComposableMap
data-tip=""
style={{
backgroundColor: palette.nym.networkExplorer.background.tertiary,
width: '100%',
height: 'auto',
}}
viewBox="0, 50, 800, 350"
projection="geoMercator"
projectionConfig={{
scale: userLocation ? 200 : 100,
center: userLocation,
}}
>
<ZoomableGroup>
<Geographies geography={MAP_TOPOJSON}>
{({ geographies }) =>
geographies.map((geo) => {
const d = (countryData?.data || {})[geo.properties.ISO_A3]
return (
<Geography
key={geo.rsmKey}
geography={geo}
fill={colorScale(d?.nodes || 0)}
stroke={palette.nym.networkExplorer.map.stroke}
strokeWidth={0.2}
onMouseEnter={() => {
const { NAME_LONG } = geo.properties
if (!userLocation) {
setTooltipContent(`${NAME_LONG} | ${d?.nodes || 0}`)
}
}}
onMouseLeave={() => {
setTooltipContent('')
}}
style={{
hover:
!userLocation && countryData
? {
fill: palette.nym.highlight,
outline: 'white',
}
: undefined,
}}
/>
)
})
}
</Geographies>
{userLocation && (
<Marker coordinates={userLocation}>
<g
fill="grey"
stroke="#FF5533"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
transform="translate(-12, -10)"
>
<circle cx="12" cy="10" r="5" />
</g>
</Marker>
)}
</ZoomableGroup>
</ComposableMap>
<ReactTooltip>{tooltipContent}</ReactTooltip>
</>
)
}
+9
View File
@@ -0,0 +1,9 @@
export * from './CustomColumnHeading';
export * from './Title';
export * from './Universal-DataGrid';
export * from './Tooltip';
export { default as StyledLink } from './StyledLink';
export * from './Delegations';
export * from './MixNodes';
export * from './TableToolbar';
export * from './Icons';
@@ -0,0 +1,73 @@
'use client'
import React from 'react'
import { ChainProvider } from '@cosmos-kit/react'
import { wallets as keplr } from '@cosmos-kit/keplr-extension'
import { assets, chains } from 'chain-registry'
import { Chain, AssetList } from '@chain-registry/types'
import { VALIDATOR_BASE_URL } from '@/app/api/constants'
const nymSandbox: Chain = {
chain_name: 'sandbox',
chain_id: 'sandbox',
bech32_prefix: 'n',
network_type: 'devnet',
pretty_name: 'Nym Sandbox',
status: 'active',
slip44: 118,
apis: {
rpc: [
{
address: 'https://rpc.sandbox.nymtech.net',
},
],
},
}
const nymSandboxAssets = {
chain_name: 'sandbox',
assets: [
{
name: 'Nym',
base: 'unym',
symbol: 'NYM',
display: 'NYM',
denom_units: [],
},
],
}
const CosmosKitProvider = ({ children }: { children: React.ReactNode }) => {
// Only use the nyx chains
const chainsFixedUp = React.useMemo(() => {
const nyx = chains.find((chain) => chain.chain_id === 'nyx')
return nyx ? [nymSandbox, nyx] : [nymSandbox]
}, [chains])
// Only use the nyx assets
const assetsFixedUp = React.useMemo(() => {
const nyx = assets.find((asset) => asset.chain_name === 'nyx')
return nyx ? [nymSandboxAssets, nyx] : [nymSandboxAssets]
}, [assets]) as AssetList[]
return (
<ChainProvider
chains={chainsFixedUp}
assetLists={assetsFixedUp}
wallets={[...keplr]}
endpointOptions={{
endpoints: {
nyx: {
rpc: [VALIDATOR_BASE_URL],
},
},
}}
>
{children}
</ChainProvider>
)
}
export default CosmosKitProvider
+252
View File
@@ -0,0 +1,252 @@
'use client'
import React, {
createContext,
useCallback,
useContext,
useMemo,
useState,
} from 'react'
import {
Delegation,
PendingEpochEvent,
PendingEpochEventKind,
} from '@nymproject/contract-clients/Mixnet.types'
import { ExecuteResult } from '@cosmjs/cosmwasm-stargate'
import { useWalletContext } from './wallet'
import { useMainContext } from './main'
const fee = { gas: '1000000', amount: [{ amount: '1000000', denom: 'unym' }] }
export type PendingEvent = ReturnType<typeof getEventsByAddress>
export type DelegationWithRewards = Delegation & {
rewards: string
identityKey: string
pending: PendingEvent
}
const getEventsByAddress = (kind: PendingEpochEventKind, address: String) => {
if ('delegate' in kind && kind.delegate.owner === address) {
return {
kind: 'delegate' as const,
mixId: kind.delegate.mix_id,
amount: kind.delegate.amount,
}
}
if ('undelegate' in kind && kind.undelegate.owner === address) {
return {
kind: 'undelegate' as const,
mixId: kind.undelegate.mix_id,
}
}
return undefined
}
interface DelegationsState {
delegations?: DelegationWithRewards[]
handleGetDelegations: () => Promise<void>
handleDelegate: (
mixId: number,
amount: string
) => Promise<ExecuteResult | undefined>
handleUndelegate: (mixId: number) => Promise<ExecuteResult | undefined>
}
export const DelegationsContext = createContext<DelegationsState>({
delegations: undefined,
handleGetDelegations: async () => {
throw new Error('Please connect your wallet')
},
handleDelegate: async () => {
throw new Error('Please connect your wallet')
},
handleUndelegate: async () => {
throw new Error('Please connect your wallet')
},
})
export const DelegationsProvider = ({
children,
}: {
children: React.ReactNode
}) => {
const [delegations, setDelegations] = useState<DelegationWithRewards[]>()
const { address, nymQueryClient, nymClient } = useWalletContext()
const { fetchMixnodes } = useMainContext()
const handleGetPendingEvents = async () => {
if (!nymQueryClient) {
return undefined
}
if (!address) {
return undefined
}
const response = await nymQueryClient.getPendingEpochEvents({})
const pendingEvents: PendingEvent[] = []
response.events.forEach((e: PendingEpochEvent) => {
const event = getEventsByAddress(e.event.kind, address)
if (event) {
pendingEvents.push(event)
}
})
return pendingEvents
}
const handleGetDelegationRewards = async (mixId: number) => {
if (!nymQueryClient) {
return undefined
}
if (!address) {
return undefined
}
const response = await nymQueryClient.getPendingDelegatorReward({
address,
mixId,
})
return response
}
const handleGetDelegations = useCallback(async () => {
if (!nymQueryClient) {
setDelegations(undefined)
return undefined
}
if (!address) {
setDelegations(undefined)
return undefined
}
// Get all mixnodes - Required to get the identity key for each delegation
const mixnodes = await fetchMixnodes()
// Get delegations
const delegationsResponse = await nymQueryClient.getDelegatorDelegations({
delegator: address,
})
// Get rewards for each delegation
const rewardsResponse = await Promise.all(
delegationsResponse.delegations.map((d: Delegation) =>
handleGetDelegationRewards(d.mix_id)
)
)
// Get all pending events
const pendingEvents = await handleGetPendingEvents()
const delegationsWithRewards: DelegationWithRewards[] = []
// Merge delegations with rewards and pending events
delegationsResponse.delegations.forEach((d: Delegation, index: number) => {
delegationsWithRewards.push({
...d,
pending: pendingEvents?.find((e: PendingEvent) =>
e?.mixId === d.mix_id ? e.kind : undefined
),
identityKey:
mixnodes?.find((m) => m.mix_id === d.mix_id)?.mix_node.identity_key ||
'',
rewards: rewardsResponse[index]?.amount_earned_detailed || '0',
})
})
// Add pending events that are not in the delegations list
pendingEvents?.forEach((e) => {
if (
e &&
!delegationsWithRewards.find(
(d: DelegationWithRewards) => d.mix_id === e.mixId
)
) {
delegationsWithRewards.push({
mix_id: e.mixId,
height: 0,
cumulative_reward_ratio: '0',
owner: address,
amount: {
amount: '0',
denom: 'unym',
},
rewards: '0',
identityKey:
mixnodes?.find((m) => m.mix_id === e.mixId)?.mix_node
.identity_key || '',
pending: e,
})
}
})
setDelegations(delegationsWithRewards)
return undefined
}, [address, nymQueryClient])
const handleDelegate = async (mixId: number, amount: string) => {
if (!address) {
throw new Error('Please connect your wallet')
}
const amountToDelegate = (Number(amount) * 1000000).toString()
const uNymFunds = [{ amount: amountToDelegate, denom: 'unym' }]
try {
const tx = await nymClient?.delegateToMixnode(
{ mixId },
fee,
'Delegation from Nym Explorer',
uNymFunds
)
return tx as unknown as ExecuteResult
} catch (e) {
console.error('Failed to delegate to mixnode', e)
throw e
}
}
const handleUndelegate = async (mixId: number) => {
const tx = await nymClient?.undelegateFromMixnode(
{ mixId },
fee,
'Undelegation from Nym Explorer'
)
return tx as unknown as ExecuteResult
}
const contextValue: DelegationsState = useMemo(
() => ({
delegations,
handleGetDelegations,
handleDelegate,
handleUndelegate,
}),
[delegations, handleGetDelegations]
)
return (
<DelegationsContext.Provider value={contextValue}>
{children}
</DelegationsContext.Provider>
)
}
export const useDelegationsContext = () => {
const context = useContext(DelegationsContext)
if (!context) {
throw new Error(
'useDelegationsContext must be used within a DelegationsProvider'
)
}
return context
}
+69
View File
@@ -0,0 +1,69 @@
'use client'
import * as React from 'react'
import {
ApiState,
GatewayReportResponse,
UptimeStoryResponse,
} from '@/app/typeDefs/explorer-api'
import { Api } from '@/app/api'
import { useApiState } from './hooks'
/**
* This context provides the state for a single gateway by identity key.
*/
interface GatewayState {
uptimeReport?: ApiState<GatewayReportResponse>
uptimeStory?: ApiState<UptimeStoryResponse>
}
export const GatewayContext = React.createContext<GatewayState>({})
export const useGatewayContext = (): React.ContextType<typeof GatewayContext> =>
React.useContext<GatewayState>(GatewayContext)
/**
* Provides a state context for a gateway by identity
* @param gatewayIdentityKey The identity key of the gateway
*/
export const GatewayContextProvider = ({
gatewayIdentityKey,
children,
}: {
gatewayIdentityKey: string
children: JSX.Element
}) => {
const [uptimeReport, fetchUptimeReportById, clearUptimeReportById] =
useApiState<GatewayReportResponse>(
gatewayIdentityKey,
Api.fetchGatewayReportById,
'Failed to fetch gateway uptime report by id'
)
const [uptimeStory, fetchUptimeHistory, clearUptimeHistory] =
useApiState<UptimeStoryResponse>(
gatewayIdentityKey,
Api.fetchGatewayUptimeStoryById,
'Failed to fetch gateway uptime history'
)
React.useEffect(() => {
// when the identity key changes, remove all previous data
clearUptimeReportById()
clearUptimeHistory()
Promise.all([fetchUptimeReportById(), fetchUptimeHistory()])
}, [gatewayIdentityKey])
const state = React.useMemo<GatewayState>(
() => ({
uptimeReport,
uptimeStory,
}),
[uptimeReport, uptimeStory]
)
return (
<GatewayContext.Provider value={state}>{children}</GatewayContext.Provider>
)
}
+49
View File
@@ -0,0 +1,49 @@
'use client'
import * as React from 'react';
import { ApiState } from '@/app/typeDefs/explorer-api';
/**
* Custom hook to get data from the API by passing an id to a delegate method that fetches the data asynchronously
* @param id The id to fetch
* @param fn Delegate the fetching to this method (must take `(id: string)` as a parameter)
* @param errorMessage A static error message, to use when no dynamic error message is returned
*/
export const useApiState = <T>(
id: string,
fn: (argId: string) => Promise<T>,
errorMessage: string,
): [ApiState<T> | undefined, () => Promise<ApiState<T>>, () => void] => {
// stores the state
const [value, setValue] = React.useState<ApiState<T>>();
// clear the value
const clearValueFn = () => setValue(undefined);
// this provides a method to trigger the delegate to fetch data
const wrappedFetchFn = React.useCallback(async () => {
setValue({ isLoading: true });
try {
// keep previous state and set to loading
setValue((prevState) => ({ ...prevState, isLoading: true }));
// delegate to user function to get data and set if successful
const data = await fn(id);
const newValue: ApiState<T> = {
isLoading: false,
data,
};
setValue(newValue);
return newValue;
} catch (error) {
// return the caught error or create a new error with the static error message
const newValue: ApiState<T> = {
error: error instanceof Error ? error : new Error(errorMessage),
isLoading: false,
};
setValue(newValue);
return newValue;
}
}, [setValue, fn, id, errorMessage]);
return [value, wrappedFetchFn, clearValueFn];
};
+256
View File
@@ -0,0 +1,256 @@
'use client'
import * as React from 'react'
import { PaletteMode } from '@mui/material'
import {
ApiState,
BlockResponse,
CountryDataResponse,
GatewayResponse,
MixNodeResponse,
MixnodeStatus,
SummaryOverviewResponse,
ValidatorsResponse,
Environment,
DirectoryServiceProvider,
} from '@/app/typeDefs/explorer-api'
import { EnumFilterKey } from '@/app/typeDefs/filters'
import { Api, getEnvironment } from '@/app/api'
import { toPercentIntegerString } from '@/app/utils'
import { NavOptionType, originalNavOptions } from './nav'
interface StateData {
summaryOverview?: ApiState<SummaryOverviewResponse>
block?: ApiState<BlockResponse>
countryData?: ApiState<CountryDataResponse>
gateways?: ApiState<GatewayResponse>
globalError?: string | undefined
mixnodes?: ApiState<MixNodeResponse>
mode: PaletteMode
validators?: ApiState<ValidatorsResponse>
environment?: Environment
serviceProviders?: ApiState<DirectoryServiceProvider[]>
}
interface StateApi {
fetchMixnodes: (
status?: MixnodeStatus
) => Promise<MixNodeResponse | undefined>
filterMixnodes: (filters: any, status: any) => void
toggleMode: () => void
}
type State = StateData & StateApi
export const MainContext = React.createContext<State>({
mode: 'dark',
toggleMode: () => undefined,
filterMixnodes: () => null,
fetchMixnodes: () => Promise.resolve(undefined),
})
export const useMainContext = (): React.ContextType<typeof MainContext> =>
React.useContext<State>(MainContext)
export const MainContextProvider: FCWithChildren = ({ children }) => {
// network explorer environment
const [environment, setEnvironment] = React.useState<Environment>()
// light/dark mode
const [mode, setMode] = React.useState<PaletteMode>('dark')
// global / banner error messaging
const [globalError] = React.useState<string>()
// various APIs for Overview page
const [summaryOverview, setSummaryOverview] =
React.useState<ApiState<SummaryOverviewResponse>>()
const [mixnodes, setMixnodes] = React.useState<ApiState<MixNodeResponse>>()
const [gateways, setGateways] = React.useState<ApiState<GatewayResponse>>()
const [validators, setValidators] =
React.useState<ApiState<ValidatorsResponse>>()
const [block, setBlock] = React.useState<ApiState<BlockResponse>>()
const [countryData, setCountryData] =
React.useState<ApiState<CountryDataResponse>>()
const [serviceProviders, setServiceProviders] =
React.useState<ApiState<DirectoryServiceProvider[]>>()
const toggleMode = () => setMode((m) => (m !== 'light' ? 'light' : 'dark'))
const fetchOverviewSummary = async () => {
try {
const data = await Api.fetchOverviewSummary()
setSummaryOverview({ data, isLoading: false })
} catch (error) {
setSummaryOverview({
error:
error instanceof Error
? error
: new Error('Overview summary api fail'),
isLoading: false,
})
}
}
const fetchMixnodes = async (status?: MixnodeStatus) => {
let data
setMixnodes((d) => ({ ...d, isLoading: true }))
try {
data = status
? await Api.fetchMixnodesActiveSetByStatus(status)
: await Api.fetchMixnodes()
setMixnodes({ data, isLoading: false })
} catch (error) {
setMixnodes({
error: error instanceof Error ? error : new Error('Mixnode api fail'),
isLoading: false,
})
}
return data
}
const filterMixnodes = async (
filters: { [key in EnumFilterKey]: number[] },
status?: MixnodeStatus
) => {
setMixnodes((d) => ({ ...d, isLoading: true }))
const mxns = status
? await Api.fetchMixnodesActiveSetByStatus(status)
: await Api.fetchMixnodes()
const filtered = mxns?.filter(
(m) =>
+m.profit_margin_percent >= filters.profitMargin[0] / 100 &&
+m.profit_margin_percent <= filters.profitMargin[1] / 100 &&
m.stake_saturation >= filters.stakeSaturation[0] &&
m.stake_saturation <= filters.stakeSaturation[1] &&
m.avg_uptime >= filters.routingScore[0] &&
m.avg_uptime <= filters.routingScore[1]
)
setMixnodes({ data: filtered, isLoading: false })
}
const fetchGateways = async () => {
setGateways((d) => ({ ...d, isLoading: true }))
try {
const data = await Api.fetchGateways()
setGateways({ data, isLoading: false })
} catch (error) {
setGateways({
error: error instanceof Error ? error : new Error('Gateways api fail'),
isLoading: false,
})
}
}
const fetchValidators = async () => {
try {
const data = await Api.fetchValidators()
setValidators({ data, isLoading: false })
} catch (error) {
setValidators({
error:
error instanceof Error ? error : new Error('Validators api fail'),
isLoading: false,
})
}
}
const fetchBlock = async () => {
try {
const data = await Api.fetchBlock()
setBlock({ data, isLoading: false })
} catch (error) {
setBlock({
error: error instanceof Error ? error : new Error('Block api fail'),
isLoading: false,
})
}
}
const fetchCountryData = async () => {
setCountryData({ data: undefined, isLoading: true })
try {
const res = await Api.fetchCountryData()
setCountryData({ data: res, isLoading: false })
} catch (error) {
setCountryData({
error:
error instanceof Error ? error : new Error('Country Data api fail'),
isLoading: false,
})
}
}
const fetchServiceProviders = async () => {
setServiceProviders({ data: undefined, isLoading: true })
try {
const res = await Api.fetchServiceProviders()
const resWithRoutingScorePercentage = res.map((item) => ({
...item,
routing_score: item.routing_score
? `${toPercentIntegerString(item.routing_score.toString())}%`
: item.routing_score,
}))
setServiceProviders({
data: resWithRoutingScorePercentage,
isLoading: false,
})
} catch (error) {
setServiceProviders({
error:
error instanceof Error
? error
: new Error('Service provider api fail'),
isLoading: false,
})
}
}
React.useEffect(() => {
if (environment === 'mainnet') {
fetchServiceProviders()
}
}, [environment])
React.useEffect(() => {
setEnvironment(getEnvironment())
Promise.all([
fetchOverviewSummary(),
fetchGateways(),
fetchValidators(),
fetchBlock(),
fetchCountryData(),
])
}, [])
const state = React.useMemo<State>(
() => ({
environment,
block,
countryData,
gateways,
globalError,
mixnodes,
mode,
summaryOverview,
validators,
serviceProviders,
toggleMode,
fetchMixnodes,
filterMixnodes,
}),
[
environment,
block,
countryData,
gateways,
globalError,
mixnodes,
mode,
summaryOverview,
validators,
serviceProviders,
]
)
return <MainContext.Provider value={state}>{children}</MainContext.Provider>
}
+173
View File
@@ -0,0 +1,173 @@
'use client'
import * as React from 'react'
import {
ApiState,
DelegationsResponse,
UniqDelegationsResponse,
MixNodeDescriptionResponse,
MixNodeEconomicDynamicsStatsResponse,
MixNodeResponseItem,
StatsResponse,
StatusResponse,
UptimeStoryResponse,
} from '../typeDefs/explorer-api'
import { Api } from '../api'
import { useApiState } from './hooks'
import {
mixNodeResponseItemToMixnodeRowType,
MixnodeRowType,
} from '../components/MixNodes'
/**
* This context provides the state for a single mixnode by identity key.
*/
interface MixnodeState {
delegations?: ApiState<DelegationsResponse>
uniqDelegations?: ApiState<UniqDelegationsResponse>
description?: ApiState<MixNodeDescriptionResponse>
economicDynamicsStats?: ApiState<MixNodeEconomicDynamicsStatsResponse>
mixNode?: ApiState<MixNodeResponseItem | undefined>
mixNodeRow?: MixnodeRowType
stats?: ApiState<StatsResponse>
status?: ApiState<StatusResponse>
uptimeStory?: ApiState<UptimeStoryResponse>
}
export const MixnodeContext = React.createContext<MixnodeState>({})
export const useMixnodeContext = (): React.ContextType<typeof MixnodeContext> =>
React.useContext<MixnodeState>(MixnodeContext)
interface MixnodeContextProviderProps {
mixId: string
children: React.ReactNode
}
/**
* Provides a state context for a mixnode by identity
* @param mixId The mixID of the mixnode
*/
export const MixnodeContextProvider: FCWithChildren<
MixnodeContextProviderProps
> = ({ mixId, children }) => {
const [mixNode, fetchMixnodeById, clearMixnodeById] = useApiState<
MixNodeResponseItem | undefined
>(mixId, Api.fetchMixnodeByID, 'Failed to fetch mixnode by id')
const [mixNodeRow, setMixnodeRow] = React.useState<
MixnodeRowType | undefined
>()
const [delegations, fetchDelegations, clearDelegations] =
useApiState<DelegationsResponse>(
mixId,
Api.fetchDelegationsById,
'Failed to fetch delegations for mixnode'
)
const [uniqDelegations, fetchUniqDelegations, clearUniqDelegations] =
useApiState<UniqDelegationsResponse>(
mixId,
Api.fetchUniqDelegationsById,
'Failed to fetch delegations for mixnode'
)
const [status, fetchStatus, clearStatus] = useApiState<StatusResponse>(
mixId,
Api.fetchStatusById,
'Failed to fetch mixnode status'
)
const [stats, fetchStats, clearStats] = useApiState<StatsResponse>(
mixId,
Api.fetchStatsById,
'Failed to fetch mixnode stats'
)
const [description, fetchDescription, clearDescription] =
useApiState<MixNodeDescriptionResponse>(
mixId,
Api.fetchMixnodeDescriptionById,
'Failed to fetch mixnode description'
)
const [
economicDynamicsStats,
fetchEconomicDynamicsStats,
clearEconomicDynamicsStats,
] = useApiState<MixNodeEconomicDynamicsStatsResponse>(
mixId,
Api.fetchMixnodeEconomicDynamicsStatsById,
'Failed to fetch mixnode dynamics stats by id'
)
const [uptimeStory, fetchUptimeHistory, clearUptimeHistory] =
useApiState<UptimeStoryResponse>(
mixId,
Api.fetchUptimeStoryById,
'Failed to fetch mixnode uptime history'
)
React.useEffect(() => {
// when the identity key changes, remove all previous data
clearMixnodeById()
clearDelegations()
clearUniqDelegations()
clearStatus()
clearStats()
clearDescription()
clearEconomicDynamicsStats()
clearUptimeHistory()
// fetch the mixnode, then get all the other stuff
fetchMixnodeById().then((value) => {
if (!value.data || value.error) {
setMixnodeRow(undefined)
return
}
setMixnodeRow(mixNodeResponseItemToMixnodeRowType(value.data))
Promise.all([
fetchDelegations(),
fetchUniqDelegations(),
fetchStatus(),
fetchStats(),
fetchDescription(),
fetchEconomicDynamicsStats(),
fetchUptimeHistory(),
])
})
}, [mixId])
const state = React.useMemo<MixnodeState>(
() => ({
delegations,
uniqDelegations,
mixNode,
mixNodeRow,
description,
economicDynamicsStats,
stats,
status,
uptimeStory,
}),
[
{
delegations,
uniqDelegations,
mixNode,
mixNodeRow,
description,
economicDynamicsStats,
stats,
status,
uptimeStory,
},
]
)
return (
<MixnodeContext.Provider value={state}>{children}</MixnodeContext.Provider>
)
}
+59
View File
@@ -0,0 +1,59 @@
'use client'
import * as React from 'react'
import { DelegateIcon } from '@/app/icons/DelevateSVG'
import { BIG_DIPPER } from '@/app/api/constants'
import { OverviewSVG } from '@/app/icons/OverviewSVG'
import { NodemapSVG } from '@/app/icons/NodemapSVG'
import { NetworkComponentsSVG } from '@/app/icons/NetworksSVG'
export type NavOptionType = {
url: string
title: string
Icon?: React.ReactNode
nested?: NavOptionType[]
isExpandedChild?: boolean
isExternal?: boolean
}
export const originalNavOptions: NavOptionType[] = [
{
url: '/',
title: 'Overview',
Icon: <OverviewSVG />,
},
{
url: '/network-components',
title: 'Network Components',
Icon: <NetworkComponentsSVG />,
nested: [
{
url: '/network-components/mixnodes',
title: 'Mixnodes',
},
{
url: '/network-components/gateways',
title: 'Gateways',
},
{
url: `${BIG_DIPPER}/validators`,
title: 'Validators',
isExternal: true,
},
{
url: '/network-components/service-providers',
title: 'Service Providers',
},
],
},
{
url: '/nodemap',
title: 'Nodemap',
Icon: <NodemapSVG />,
},
{
url: '/delegations',
title: 'Delegations',
Icon: <DelegateIcon sx={{ color: 'white' }} />,
},
]
+123
View File
@@ -0,0 +1,123 @@
'use client'
import React, {
createContext,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import { useChain } from '@cosmos-kit/react'
import { Wallet } from '@cosmos-kit/core'
import { unymToNym } from '@/app/utils/currency'
import { useNymClient } from '@/app/hooks'
import {
MixnetClient,
MixnetQueryClient,
} from '@nymproject/contract-clients/Mixnet.client'
import { COSMOS_KIT_USE_CHAIN } from '@/app/api/constants'
interface WalletState {
balance: { status: 'loading' | 'success'; data?: string }
address?: string
isWalletConnected: boolean
isWalletConnecting: boolean
wallet?: Wallet
nymClient?: MixnetClient
nymQueryClient?: MixnetQueryClient
connectWallet: () => Promise<void>
disconnectWallet: () => Promise<void>
}
export const WalletContext = createContext<WalletState>({
address: undefined,
balance: { status: 'loading', data: undefined },
isWalletConnected: false,
isWalletConnecting: false,
nymClient: undefined,
nymQueryClient: undefined,
connectWallet: async () => {
throw new Error('Please connect your wallet')
},
disconnectWallet: async () => {
throw new Error('Please connect your wallet')
},
})
export const WalletProvider = ({ children }: { children: React.ReactNode }) => {
const [balance, setBalance] = useState<WalletState['balance']>({
status: 'loading',
data: undefined,
})
const {
connect,
disconnect,
wallet,
address,
isWalletConnected,
isWalletConnecting,
getCosmWasmClient,
} = useChain(COSMOS_KIT_USE_CHAIN)
const { nymClient, nymQueryClient } = useNymClient(address)
const getBalance = async (walletAddress: string) => {
const account = await getCosmWasmClient()
const uNYMBalance = await account.getBalance(walletAddress, 'unym')
const NYMBalance = unymToNym(uNYMBalance.amount)
return NYMBalance
}
const init = async (walletAddress: string) => {
const walletBalance = await getBalance(walletAddress)
setBalance({ status: 'success', data: walletBalance })
}
useEffect(() => {
if (isWalletConnected && address) {
init(address)
}
}, [address, isWalletConnected])
const handleConnectWallet = async () => {
await connect()
}
const handleDisconnectWallet = async () => {
await disconnect()
setBalance({ status: 'loading', data: undefined })
}
const contextValue: WalletState = useMemo(
() => ({
address,
balance,
wallet,
isWalletConnected,
isWalletConnecting,
nymClient,
nymQueryClient,
connectWallet: handleConnectWallet,
disconnectWallet: handleDisconnectWallet,
}),
[
address,
balance,
wallet,
isWalletConnected,
isWalletConnecting,
nymClient,
nymQueryClient,
]
)
return (
<WalletContext.Provider value={contextValue}>
{children}
</WalletContext.Provider>
)
}
export const useWalletContext = () => useContext(WalletContext)
+311
View File
@@ -0,0 +1,311 @@
'use client'
import React, { useCallback, useEffect, useMemo } from 'react'
import {
Alert,
AlertTitle,
Box,
Button,
Card,
Chip,
IconButton,
Tooltip,
Typography,
} from '@mui/material'
import { DelegationModal, DelegationModalProps, Title } from '@/app/components'
import { useWalletContext } from '@/app/context/wallet'
import { unymToNym } from '@/app/utils/currency'
import {
DelegationWithRewards,
DelegationsProvider,
PendingEvent,
useDelegationsContext,
} from '@/app/context/delegations'
import { urls } from '@/app/utils'
import { useClipboard } from 'use-clipboard-copy'
import { Close } from '@mui/icons-material'
import { useRouter } from 'next/navigation'
import {
MRT_ColumnDef,
MaterialReactTable,
useMaterialReactTable,
} from 'material-react-table'
const mapToDelegationsRow = (
delegation: DelegationWithRewards,
index: number
) => ({
identity: delegation.identityKey,
mix_id: delegation.mix_id,
amount: `${unymToNym(delegation.amount.amount)} NYM`,
rewards: `${unymToNym(delegation.rewards)} NYM`,
id: index,
pending: delegation.pending,
})
const Banner = ({ onClose }: { onClose: () => void }) => {
const { copy } = useClipboard()
return (
<Alert
severity="info"
sx={{ mb: 3, fontSize: 'medium', width: '100%' }}
action={
<IconButton size="small" onClick={onClose}>
<Close fontSize="small" />
</IconButton>
}
>
<AlertTitle> Mobile Delegations Beta</AlertTitle>
<Box>
<Typography>
This is a beta release for mobile delegations If you have any feedback
or feature suggestions contact us at support@nymte.ch
<Button
size="small"
onClick={() => copy('support@nymte.ch')}
sx={{ display: 'inline-block' }}
>
Copy
</Button>
</Typography>
</Box>
</Alert>
)
}
const DelegationsPage = () => {
const [confirmationModalProps, setConfirmationModalProps] = React.useState<
DelegationModalProps | undefined
>()
const [isLoading, setIsLoading] = React.useState(false)
const [showBanner, setShowBanner] = React.useState(true)
const { isWalletConnected } = useWalletContext()
const { handleGetDelegations, handleUndelegate, delegations } =
useDelegationsContext()
const router = useRouter()
useEffect(() => {
let timeoutId: NodeJS.Timeout
const fetchDelegations = async () => {
setIsLoading(true)
try {
await handleGetDelegations()
} catch (error) {
setConfirmationModalProps({
status: 'error',
message: "Couldn't fetch delegations. Please try again later.",
})
} finally {
setIsLoading(false)
timeoutId = setTimeout(() => {
fetchDelegations()
}, 60_000)
}
}
fetchDelegations()
return () => {
clearTimeout(timeoutId)
}
}, [handleGetDelegations])
const getTooltipTitle = (pending: PendingEvent) => {
if (pending?.kind === 'undelegate') {
return 'You have an undelegation pending'
}
if (pending?.kind === 'delegate') {
return `You have a delegation pending worth ${unymToNym(
pending.amount.amount
)} NYM`
}
return undefined
}
const onUndelegate = useCallback(
async (mixId: number) => {
setConfirmationModalProps({ status: 'loading' })
try {
const tx = await handleUndelegate(mixId)
if (tx) {
setConfirmationModalProps({
status: 'success',
message: 'Undelegation can take up to one hour to process',
transactions: [
{
url: `${urls('MAINNET').blockExplorer}/transaction/${
tx.transactionHash
}`,
hash: tx.transactionHash,
},
],
})
}
} catch (error) {
if (error instanceof Error) {
setConfirmationModalProps({ status: 'error', message: error.message })
}
}
},
[handleUndelegate]
)
const columns = useMemo<
MRT_ColumnDef<ReturnType<typeof mapToDelegationsRow>>[]
>(() => {
return [
{
id: 'delegations-data',
header: 'Delegations Data',
columns: [
{
id: 'identity',
accessorKey: 'identity',
header: 'Identity Key',
width: 400,
},
{
id: 'mix_id',
accessorKey: 'mix_id',
header: 'Mix ID',
size: 150,
},
{
id: 'amount',
accessorKey: 'amount',
header: 'Amount',
width: 150,
},
{
id: 'rewards',
accessorKey: 'rewards',
header: 'Rewards',
width: 150,
enableColumnFilters: false,
},
{
id: 'undelegate',
accessorKey: 'undelegate',
header: '',
enableSorting: false,
enableColumnActions: false,
Filter: () => null,
Cell: ({ row }) => {
return (
<Box
sx={{ width: '100%', display: 'flex', justifyContent: 'end' }}
>
{row.original.pending ? (
<Tooltip
placement="left"
title={getTooltipTitle(row.original.pending)}
onClick={(e) => e.stopPropagation()}
PopperProps={{}}
>
<Chip size="small" label="Pending events" />
</Tooltip>
) : (
<Button
size="small"
variant="outlined"
onClick={(e) => {
e.stopPropagation()
onUndelegate(row.original.mix_id)
}}
>
Undelegate
</Button>
)}
</Box>
)
},
},
],
},
]
}, [onUndelegate])
const data = useMemo(() => {
return (delegations || []).map(mapToDelegationsRow)
}, [delegations])
const table = useMaterialReactTable({
columns,
data,
enableFullScreenToggle: false,
state: {
isLoading,
},
initialState: {
columnPinning: { right: ['undelegate'] },
},
})
return (
<Box>
{confirmationModalProps && (
<DelegationModal
{...confirmationModalProps}
open={Boolean(confirmationModalProps)}
onClose={async () => {
if (confirmationModalProps.status === 'success') {
await handleGetDelegations()
}
setConfirmationModalProps(undefined)
}}
sx={{
width: {
xs: '90%',
sm: 600,
},
}}
/>
)}
{showBanner && <Banner onClose={() => setShowBanner(false)} />}
<Box display="flex" justifyContent="space-between" alignItems="center">
<Title text="Your Delegations" />
<Button
variant="contained"
color="primary"
onClick={() => router.push('/network-components/mixnodes')}
>
Delegate
</Button>
</Box>
{!isWalletConnected ? (
<Box>
<Typography mb={2} variant="h6">
Connect your wallet to view your delegations.
</Typography>
</Box>
) : null}
<Card
sx={{
mt: 2,
padding: 2,
height: '100%',
}}
>
<MaterialReactTable table={table} />
</Card>
</Box>
)
}
const Delegations = () => (
<DelegationsProvider>
<DelegationsPage />
</DelegationsProvider>
)
export default Delegations
@@ -0,0 +1,26 @@
import * as React from 'react'
import { FallbackProps } from 'react-error-boundary'
import { Alert, AlertTitle, Container } from '@mui/material'
import { NymThemeProvider } from '@nymproject/mui-theme'
import { NymLogo } from '@nymproject/react/logo/NymLogo'
export const ErrorBoundaryContent: FCWithChildren<FallbackProps> = ({
error,
}) => (
<NymThemeProvider mode="dark">
<Container sx={{ py: 4 }}>
<NymLogo height="75px" width="75px" />
<h1>Oh no! Sorry, something went wrong</h1>
<Alert severity="error" data-testid="error-message">
<AlertTitle>{error.name}</AlertTitle>
{error.message}
</Alert>
{process.env.NODE_ENV === 'development' && (
<Alert severity="info" sx={{ mt: 2 }} data-testid="stack-trace">
<AlertTitle>Stack trace</AlertTitle>
{error.stack}
</Alert>
)}
</Container>
</NymThemeProvider>
)
+4
View File
@@ -0,0 +1,4 @@
export * from './useIsMobile';
export * from './useIsMounted';
export * from './useGetMixnodeStatusColor';
export * from './useNymClient';
@@ -0,0 +1,19 @@
'use client'
import { useTheme } from '@mui/material';
import { MixnodeStatus } from '@/app/typeDefs/explorer-api';
export const useGetMixNodeStatusColor = (status: MixnodeStatus) => {
const theme = useTheme();
switch (status) {
case MixnodeStatus.active:
return theme.palette.nym.networkExplorer.mixnodes.status.active;
case MixnodeStatus.standby:
return theme.palette.nym.networkExplorer.mixnodes.status.standby;
default:
return theme.palette.nym.networkExplorer.mixnodes.status.inactive;
}
};
+11
View File
@@ -0,0 +1,11 @@
'use client'
import { Breakpoint, useMediaQuery } from '@mui/material';
import { useTheme } from '@mui/material/styles';
export const useIsMobile = (queryInput: number | Breakpoint = 'md') => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down(queryInput));
return isMobile;
};
+16
View File
@@ -0,0 +1,16 @@
'use client'
import { useRef, useEffect, useCallback } from 'react';
export function useIsMounted(): () => boolean {
const ref = useRef(false);
useEffect(() => {
ref.current = true;
return () => {
ref.current = false;
};
}, []);
return useCallback(() => ref.current, [ref]);
}
@@ -0,0 +1,44 @@
'use client'
import { useEffect, useState } from 'react'
import { useChain } from '@cosmos-kit/react'
import { contracts } from '@nymproject/contract-clients'
import {
MixnetClient,
MixnetQueryClient,
} from '@nymproject/contract-clients/Mixnet.client'
import { COSMOS_KIT_USE_CHAIN, NYM_MIXNET_CONTRACT } from '@/app/api/constants'
export const useNymClient = (address?: string) => {
const [nymClient, setNymClient] = useState<MixnetClient>()
const [nymQueryClient, setNymQueryClient] = useState<MixnetQueryClient>()
const { getCosmWasmClient, getSigningCosmWasmClient } =
useChain(COSMOS_KIT_USE_CHAIN)
useEffect(() => {
if (address) {
const init = async () => {
const cosmWasmSigningClient = await getSigningCosmWasmClient()
const cosmWasmClient = await getCosmWasmClient()
const client = new contracts.Mixnet.MixnetClient(
cosmWasmSigningClient as any,
address,
NYM_MIXNET_CONTRACT
)
const queryClient = new contracts.Mixnet.MixnetQueryClient(
cosmWasmClient as any,
NYM_MIXNET_CONTRACT
)
setNymClient(client)
setNymQueryClient(queryClient)
}
init()
}
}, [address, getCosmWasmClient, getSigningCosmWasmClient])
return { nymClient, nymQueryClient }
}
+12
View File
@@ -0,0 +1,12 @@
import React from 'react';
import { SvgIcon, SvgIconProps } from '@mui/material';
export const DelegateIcon = (props: SvgIconProps) => (
<SvgIcon {...props}>
<path d="M4 12V15H6V12H4ZM16 7L14.59 5.59L13 7.17V2H11V7.19L9.39 5.61L8 7L12 11L16 7ZM4 17H20V15H4V17Z" />
<path d="M20 21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V20H20V21Z" />
<rect x="18" y="12" width="2" height="3" />
<rect x="18" y="17" width="2" height="3" />
<rect x="4" y="17" width="2" height="3" />
</SvgIcon>
);
+20
View File
@@ -0,0 +1,20 @@
import * as React from 'react';
export const ElipsSVG: FCWithChildren = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="25" viewBox="0 0 24 25" fill="none">
<circle cx="12" cy="12.5" r="10" fill="url(#paint0_angular_2549_7570)" />
<defs>
<radialGradient
id="paint0_angular_2549_7570"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(12 12.5) rotate(90) scale(12)"
>
<stop stopColor="#22D27E" />
<stop offset="1" stopColor="#9002FF" />
</radialGradient>
</defs>
</svg>
);
+28
View File
@@ -0,0 +1,28 @@
import * as React from 'react';
import { useTheme } from '@mui/material/styles';
export const GatewaysSVG: FCWithChildren = () => {
const theme = useTheme();
const color = theme.palette.text.primary;
return (
<svg width="24" height="24" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.2 12H22.7" stroke={color} strokeWidth="1.3" strokeMiterlimit="10" strokeLinecap="round" />
<path d="M1.30005 12H12" stroke={color} strokeWidth="1.3" strokeMiterlimit="10" strokeLinecap="round" />
<path
d="M20.1 9.40015L22.7 12.0001L20.1 14.6001"
stroke={color}
strokeWidth="1.3"
strokeMiterlimit="10"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M13.2 22.7001H8.59998C6.89998 22.7001 5.59998 21.4001 5.59998 19.7001V4.30005C5.59998 2.60005 6.89998 1.30005 8.59998 1.30005H13.2C14.9 1.30005 16.2 2.60005 16.2 4.30005V19.6C16.2 21.3001 14.8 22.7001 13.2 22.7001Z"
stroke={color}
strokeWidth="1.3"
strokeMiterlimit="10"
strokeLinecap="round"
/>
</svg>
);
};
@@ -0,0 +1,15 @@
import * as React from 'react';
import { useTheme } from '@mui/material/styles';
export const LightSwitchSVG: FCWithChildren = () => {
const { palette } = useTheme();
return (
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 2C6.5 2 2 6.5 2 12C2 17.5 6.5 22 12 22C17.5 22 22 17.5 22 12C22 6.5 17.5 2 12 2Z"
fill={palette.background.default}
/>
<path d="M12 20C7.6 20 4 16.4 4 12C4 7.6 7.6 4 12 4V20Z" fill={palette.text.primary} />
</svg>
);
};
+92
View File
@@ -0,0 +1,92 @@
import * as React from 'react';
import { useTheme } from '@mui/material/styles';
export const MixnodesSVG: FCWithChildren = () => {
const theme = useTheme();
const color = theme.palette.text.primary;
return (
<svg width="24" height="24" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M23.0437 13.0291H2.97681" stroke={color} strokeMiterlimit="10" />
<path d="M23.0437 2.99512H2.97681" stroke={color} strokeMiterlimit="10" />
<path d="M23.0437 23.0625H2.97681" stroke={color} strokeMiterlimit="10" />
<path d="M2.97681 23.0621L23.0437 2.99512" stroke={color} strokeMiterlimit="10" />
<path d="M23.0437 23.0621L2.97681 2.99512" stroke={color} strokeMiterlimit="10" />
<path d="M13.0103 23.0621L23.0437 2.99512" stroke={color} strokeMiterlimit="10" />
<path d="M2.97681 2.99512L13.0103 23.0621" stroke={color} strokeMiterlimit="10" />
<path
d="M13.0099 13.0289L23.0437 23.0621L13.0099 2.99512L2.97681 23.0621L13.0099 2.99512"
stroke={color}
strokeMiterlimit="10"
/>
<path
d="M23.097 12.9846L13.0892 2.97681L3.08142 12.9846L13.0892 22.9924L23.097 12.9846Z"
stroke={color}
strokeMiterlimit="10"
/>
<path
d="M23.0232 4.9536C24.1149 4.9536 25 4.06856 25 2.9768C25 1.88504 24.1149 1 23.0232 1C21.9314 1 21.0464 1.88504 21.0464 2.9768C21.0464 4.06856 21.9314 4.9536 23.0232 4.9536Z"
fill="#242C3D"
stroke={color}
strokeWidth="1.2"
strokeMiterlimit="10"
/>
<path
d="M12.9731 4.9536C14.0648 4.9536 14.9499 4.06856 14.9499 2.9768C14.9499 1.88504 14.0648 1 12.9731 1C11.8813 1 10.9963 1.88504 10.9963 2.9768C10.9963 4.06856 11.8813 4.9536 12.9731 4.9536Z"
fill="#242C3D"
stroke={color}
strokeWidth="1.2"
strokeMiterlimit="10"
/>
<path
d="M2.9768 4.9536C4.06856 4.9536 4.9536 4.06856 4.9536 2.9768C4.9536 1.88504 4.06856 1 2.9768 1C1.88504 1 1 1.88504 1 2.9768C1 4.06856 1.88504 4.9536 2.9768 4.9536Z"
fill="#242C3D"
stroke={color}
strokeWidth="1.2"
strokeMiterlimit="10"
/>
<path
d="M23.0232 15.0029C24.1149 15.0029 25 14.1179 25 13.0261C25 11.9344 24.1149 11.0493 23.0232 11.0493C21.9314 11.0493 21.0464 11.9344 21.0464 13.0261C21.0464 14.1179 21.9314 15.0029 23.0232 15.0029Z"
fill="#242C3D"
stroke={color}
strokeWidth="1.2"
strokeMiterlimit="10"
/>
<path
d="M12.9731 15.0029C14.0648 15.0029 14.9499 14.1179 14.9499 13.0261C14.9499 11.9344 14.0648 11.0493 12.9731 11.0493C11.8813 11.0493 10.9963 11.9344 10.9963 13.0261C10.9963 14.1179 11.8813 15.0029 12.9731 15.0029Z"
fill="#242C3D"
stroke={color}
strokeWidth="1.2"
strokeMiterlimit="10"
/>
<path
d="M2.9768 15.0029C4.06856 15.0029 4.9536 14.1179 4.9536 13.0261C4.9536 11.9344 4.06856 11.0493 2.9768 11.0493C1.88504 11.0493 1 11.9344 1 13.0261C1 14.1179 1.88504 15.0029 2.9768 15.0029Z"
fill="#242C3D"
stroke={color}
strokeWidth="1.2"
strokeMiterlimit="10"
/>
<path
d="M23.0232 25C24.1149 25 25 24.1149 25 23.0232C25 21.9314 24.1149 21.0464 23.0232 21.0464C21.9314 21.0464 21.0464 21.9314 21.0464 23.0232C21.0464 24.1149 21.9314 25 23.0232 25Z"
fill="#242C3D"
stroke={color}
strokeWidth="1.2"
strokeMiterlimit="10"
/>
<path
d="M12.9731 25C14.0648 25 14.9499 24.1149 14.9499 23.0232C14.9499 21.9314 14.0648 21.0464 12.9731 21.0464C11.8813 21.0464 10.9963 21.9314 10.9963 23.0232C10.9963 24.1149 11.8813 25 12.9731 25Z"
fill="#242C3D"
stroke={color}
strokeWidth="1.2"
strokeMiterlimit="10"
/>
<path
d="M2.9768 25C4.06856 25 4.9536 24.1149 4.9536 23.0232C4.9536 21.9314 4.06856 21.0464 2.9768 21.0464C1.88504 21.0464 1 21.9314 1 23.0232C1 24.1149 1.88504 25 2.9768 25Z"
fill="#242C3D"
stroke={color}
strokeWidth="1.2"
strokeMiterlimit="10"
/>
</svg>
);
};
@@ -0,0 +1,10 @@
import * as React from 'react';
export const MobileDrawerClose: FCWithChildren = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-3 -5 24 24" width="25" height="25" {...props}>
<path
d="M0 12H13V10H0V12ZM0 7H10V5H0V7ZM0 0V2H13V0H0ZM18 9.59L14.42 6L18 2.41L16.59 1L11.59 6L16.59 11L18 9.59Z"
fill="#F2F2F2"
/>
</svg>
);
+18
View File
@@ -0,0 +1,18 @@
import * as React from 'react';
import { useTheme } from '@mui/material/styles';
export const NetworkComponentsSVG: FCWithChildren = () => {
const theme = useTheme();
const color = theme.palette.nym.networkExplorer.nav.text;
return (
<svg width="25" height="25" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M17.2 10.5V4.40002L12 1.40002L6.8 4.40002V10.5L12 13.5L17.2 10.5Z"
stroke={color}
strokeMiterlimit="10"
/>
<path d="M12 19.6V13.5L6.8 10.5L1.5 13.5V19.6L6.8 22.6L12 19.6Z" stroke={color} strokeMiterlimit="10" />
<path d="M22.5 19.6V13.5L17.2 10.5L12 13.5V19.6L17.2 22.6L22.5 19.6Z" stroke={color} strokeMiterlimit="10" />
</svg>
);
};
+22
View File
@@ -0,0 +1,22 @@
import * as React from 'react';
import { useTheme } from '@mui/material/styles';
export const NodemapSVG: FCWithChildren = () => {
const theme = useTheme();
const color = theme.palette.nym.networkExplorer.nav.text;
return (
<svg width="25" height="25" viewBox="0 0 19 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M1 9.6999C1 5.0999 4.7 1.3999 9.3 1.3999C13.9 1.3999 17.6 5.0999 17.6 9.6999C17.6 14.2999 9.3 21.5999 9.3 21.5999C9.3 21.5999 1 14.2999 1 9.6999Z"
stroke={color}
strokeMiterlimit="10"
/>
<path
d="M9.30005 12C11.233 12 12.8 10.433 12.8 8.5C12.8 6.567 11.233 5 9.30005 5C7.36705 5 5.80005 6.567 5.80005 8.5C5.80005 10.433 7.36705 12 9.30005 12Z"
stroke={color}
strokeMiterlimit="10"
/>
<path d="M1.5 22.5999H17.1" stroke={color} strokeMiterlimit="10" strokeLinecap="round" />
</svg>
);
};
+58
View File
@@ -0,0 +1,58 @@
import * as React from 'react'
interface DiscordIconProps {
size?: { width: number; height: number }
}
export const NymVpnIcon: FCWithChildren<DiscordIconProps> = ({ size }) => (
<svg
width={size?.width}
height={size?.height}
viewBox="0 0 170 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.6118 0.128906H19.5405V0.187854V20.7961L10.7849 0.164277L10.773 0.128906H10.7255H5.75959H0.187819H0.128418V0.187854V23.8142V23.8732H0.187819H5.75959H5.81899V23.8142V3.17063L14.6103 23.8378L14.6222 23.8732H14.6697H19.6118H25.1717H25.2311V23.8142V0.187854V0.128906H25.1717H19.6118Z"
fill="white"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M19.4121 0H25.3603V24H14.5297L14.4901 23.8819L5.94824 3.80121V24H0V0H10.8663L10.906 0.118132L19.4121 20.1621V0ZM19.5409 20.7951L10.7853 0.163225L10.7734 0.127854H0.128835V23.8721H5.81941V3.16958L14.6107 23.8368L14.6226 23.8721H25.2315V0.127854H19.5409V20.7951Z"
fill="white"
/>
<path
d="M89.8116 0.128906H79.1908H79.1314L79.1195 0.176068L73.6784 20.8904L68.2255 0.176068L68.2136 0.128906H68.1661H57.5215H57.4502V0.187854V23.8142V23.8732H57.5215H63.0814H63.1408V23.8142V3.33568L68.5225 23.826L68.5343 23.8732H68.5937H78.7394H78.7869L78.7988 23.826L84.1804 3.33568V23.8142V23.8732H84.2398H89.8116H89.871V23.8142V0.187854V0.128906H89.8116Z"
fill="white"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M79.0312 0H90.0003V24H84.052V4.33208L78.9242 23.856L78.9238 23.8572L78.8879 24H68.4342L68.3982 23.8572L68.3979 23.856L63.27 4.33208V24H57.3218V0H68.3146L68.3505 0.142699L68.3509 0.144015L73.6787 20.383L78.9949 0.144015L78.9953 0.142765L79.0312 0ZM73.6788 20.8894L68.2259 0.175015L68.214 0.127854H57.4506V23.8721H63.1412V3.33463L68.5229 23.825L68.5348 23.8721H78.7873L78.7992 23.825L84.1809 3.33463V23.8721H89.8714V0.127854H79.1318L79.1199 0.175015L73.6788 20.8894Z"
fill="white"
/>
<path
d="M48.2909 0.128906H48.2553L48.2434 0.152487L41.4836 11.8124L34.6882 0.152487L34.6763 0.128906H34.6407H28.2135H28.0947L28.1541 0.223225L38.6205 18.2142V23.8142V23.8732H38.6799H44.2517H44.3111V23.8142V18.2142L54.7775 0.223225L54.8369 0.128906H54.7181H48.2909Z"
fill="white"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M48.1757 0H55.0693L54.8879 0.288036L44.4399 18.2474V24H38.4917V18.2474L28.0437 0.288036L27.8623 0H34.756L34.8017 0.0907854L41.4833 11.5555L48.1299 0.0909153L48.1757 0ZM48.2434 0.151434L41.4836 11.8114L34.6882 0.151434L34.6763 0.127854H28.0948L28.1542 0.222173L38.6205 18.2131V23.8721H44.3111V18.2131L54.7775 0.222173L54.8369 0.127854H48.2553L48.2434 0.151434Z"
fill="white"
/>
<path
d="M169.238 0V24H166.422C166.006 24 165.654 23.9341 165.366 23.8023C165.088 23.6596 164.811 23.418 164.534 23.0776L153.542 8.76321C153.584 9.19149 153.611 9.60878 153.622 10.0151C153.643 10.4104 153.654 10.7838 153.654 11.1352V24H148.886V0H151.734C151.968 0 152.166 0.0109813 152.326 0.032944C152.486 0.0549066 152.63 0.0988326 152.758 0.164722C152.886 0.219629 153.008 0.30199 153.126 0.411805C153.243 0.521619 153.376 0.669869 153.526 0.856553L164.614 15.2697C164.56 14.8085 164.523 14.3638 164.502 13.9355C164.48 13.4962 164.47 13.0844 164.47 12.7001V0H169.238Z"
fill="#A8A6A6"
/>
<path
d="M134.206 11.7776C135.614 11.7776 136.627 11.4317 137.246 10.7399C137.865 10.048 138.174 9.08167 138.174 7.84077C138.174 7.29169 138.094 6.79204 137.934 6.3418C137.774 5.89156 137.529 5.50721 137.198 5.18874C136.878 4.8593 136.467 4.60673 135.966 4.43102C135.475 4.25532 134.889 4.16747 134.206 4.16747H131.39V11.7776H134.206ZM134.206 0C135.849 0 137.257 0.203157 138.43 0.609471C139.614 1.0048 140.585 1.55388 141.342 2.25669C142.11 2.95951 142.675 3.78861 143.038 4.74399C143.401 5.69938 143.582 6.73164 143.582 7.84077C143.582 9.03775 143.395 10.1359 143.022 11.1352C142.649 12.1345 142.078 12.9911 141.31 13.7049C140.542 14.4187 139.566 14.9787 138.382 15.385C137.209 15.7804 135.817 15.978 134.206 15.978H131.39V24H125.982V0H134.206Z"
fill="#A8A6A6"
/>
<path
d="M121.584 0L112.24 24H107.344L98 0H102.352C102.821 0 103.2 0.115305 103.488 0.345915C103.776 0.565545 103.995 0.851064 104.144 1.20247L108.656 14.0508C108.869 14.6108 109.077 15.2258 109.28 15.8957C109.483 16.5546 109.675 17.2464 109.856 17.9712C110.005 17.2464 110.171 16.5546 110.352 15.8957C110.544 15.2258 110.747 14.6108 110.96 14.0508L115.44 1.20247C115.557 0.894989 115.765 0.620452 116.064 0.378861C116.373 0.126287 116.752 0 117.2 0H121.584Z"
fill="#A8A6A6"
/>
</svg>
)
+16
View File
@@ -0,0 +1,16 @@
import * as React from 'react';
import { useTheme } from '@mui/material/styles';
export const OverviewSVG: FCWithChildren = () => {
const theme = useTheme();
const color = theme.palette.nym.networkExplorer.nav.text;
return (
<svg width="25" height="25" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.4 21.6H22.6" stroke={color} strokeMiterlimit="10" strokeLinecap="round" />
<path d="M14.1 2.40002H9.9V21.5H14.1V2.40002Z" stroke={color} strokeMiterlimit="10" strokeLinecap="round" />
<path d="M20.8 6.59998H16.6V21.5H20.8V6.59998Z" stroke={color} strokeMiterlimit="10" strokeLinecap="round" />
<path d="M7.4 11.8H3.2V21.6H7.4V11.8Z" stroke={color} strokeMiterlimit="10" strokeLinecap="round" />
</svg>
);
};
+25
View File
@@ -0,0 +1,25 @@
import * as React from 'react';
export const TokenSVG: FCWithChildren = () => {
const color = 'white';
return (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="25" viewBox="0 0 24 25" fill="none">
<g clipPath="url(#clip0_2549_7563)">
<path
d="M20.4841 4.01607C15.8041 -0.67593 8.19607 -0.67593 3.51607 4.01607C-1.17593 8.70807 -1.17593 16.3041 3.51607 20.9841C8.20807 25.6761 15.8041 25.6761 20.4841 20.9841C25.1761 16.3041 25.1761 8.69607 20.4841 4.01607ZM19.4521 19.9521C15.3361 24.0681 8.65207 24.0681 4.53607 19.9521C0.42007 15.8361 0.42007 9.15207 4.53607 5.03607C8.65207 0.92007 15.3361 0.92007 19.4521 5.03607C23.5801 9.16407 23.5801 15.8361 19.4521 19.9521Z"
fill={color}
/>
<path
d="M18.48 19.4965V5.50447C17.868 4.92847 17.184 4.42447 16.452 4.02847V17.4085L7.62002 3.98047C6.85202 4.38847 6.14402 4.89247 5.52002 5.49247V19.4965C6.13202 20.0725 6.81602 20.5765 7.54802 20.9725V7.59247L16.38 21.0205C17.148 20.6125 17.856 20.0965 18.48 19.4965Z"
fill={color}
/>
</g>
<defs>
<clipPath id="clip0_2549_7563">
<rect width="24" height="24" fill="white" transform="translate(0 0.5)" />
</clipPath>
</defs>
</svg>
);
};
@@ -0,0 +1,47 @@
import * as React from 'react';
import { useTheme } from '@mui/material/styles';
export const ValidatorsSVG: FCWithChildren = () => {
const theme = useTheme();
const color = theme.palette.text.primary;
return (
<svg width="24" height="24" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0)">
<path
d="M18.2001 18.4V19.7001C18.2001 21.4001 16.9 22.7001 15.2 22.7001H4.30005C2.60005 22.7001 1.30005 21.4001 1.30005 19.7001V4.30005C1.30005 2.60005 2.60005 1.30005 4.30005 1.30005H15.1C16.8 1.30005 18.1 2.60005 18.1 4.30005V5.60005V18.4H18.2001Z"
stroke={color}
strokeWidth="1.3"
strokeMiterlimit="10"
strokeLinecap="round"
/>
<path
d="M13.4 22.7001H17.4C19.1 22.7001 20.4 21.4001 20.4 19.7001V18.4V5.60005V4.30005C20.4 2.60005 19.1 1.30005 17.4 1.30005H11.5"
stroke={color}
strokeWidth="1.3"
strokeMiterlimit="10"
strokeLinecap="round"
/>
<path
d="M15.2 22.7001H19.7C21.4 22.7001 22.7 21.4001 22.7 19.7001V18.4V5.60005V4.30005C22.7 2.60005 21.4 1.30005 19.7 1.30005H13.8"
stroke={color}
strokeWidth="1.3"
strokeMiterlimit="10"
strokeLinecap="round"
/>
<path
d="M5 12.3L7.9 15.3L14.5 8.69995"
stroke={color}
strokeWidth="2"
strokeMiterlimit="10"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
<defs>
<clipPath id="clip0">
<rect width="24" height="24" fill="white" />
</clipPath>
</defs>
</svg>
);
};
@@ -0,0 +1,38 @@
import * as React from 'react'
import { useTheme } from '@mui/material/styles'
interface DiscordIconProps {
size?: number | string
color?: string
}
export const DiscordIcon: FCWithChildren<DiscordIconProps> = ({
size,
color: colorProp,
}) => {
const theme = useTheme()
const color = colorProp || theme.palette.text.primary
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<g clipPath="url(#clip0_1223_2296)">
<path
d="M12.4 0C5.80002 0 0.400024 5.4 0.400024 12C0.400024 18.6 5.80002 24 12.4 24C19 24 24.4 18.6 24.4 12C24.4 5.4 19 0 12.4 0ZM20.1 15.9C18.8 16.9 17.5 17.5 16.2 17.9C16.2 17.9 16.2 17.9 16.1 17.9C15.8 17.5 15.5 17.1 15.3 16.6V16.5C15.7 16.3 16.1 16.1 16.5 15.9V15.8C16.4 15.7 16.3 15.7 16.3 15.6C16.3 15.6 16.3 15.6 16.2 15.6C13.7 16.8 10.9 16.8 8.40002 15.6C8.40002 15.6 8.40002 15.6 8.30002 15.6C8.20002 15.7 8.10002 15.7 8.10002 15.8V15.9C8.50002 16.1 8.90002 16.3 9.30002 16.5C9.30002 16.5 9.30002 16.5 9.30002 16.6C9.10002 17.1 8.80002 17.5 8.50002 17.9C8.50002 17.9 8.50002 17.9 8.40002 17.9C7.10002 17.5 5.90002 16.9 4.50002 15.9C4.40002 13 5.00002 10.1 7.00002 7.1C8.00002 6.6 9.00002 6.3 10.2 6.1C10.2 6.1 10.2 6.1 10.3 6.1C10.4 6.3 10.6 6.7 10.7 6.9C11.9 6.7 13.1 6.7 14.2 6.9C14.3 6.7 14.5 6.3 14.6 6.1C14.6 6.1 14.6 6.1 14.7 6.1C15.8 6.3 16.9 6.6 17.9 7.1C19.5 9.7 20.4 12.6 20.1 15.9Z"
fill={color}
/>
<path
d="M15 11C14.2 11 13.6 11.7 13.6 12.6C13.6 13.5 14.2 14.2 15 14.2C15.8 14.2 16.4 13.5 16.4 12.6C16.4 11.7 15.8 11 15 11Z"
fill={color}
/>
<path
d="M9.80002 11C9.10002 11 8.40002 11.7 8.40002 12.6C8.40002 13.5 9.00002 14.2 9.80002 14.2C10.6 14.2 11.2 13.5 11.2 12.6C11.2 11.7 10.6 11 9.80002 11Z"
fill={color}
/>
</g>
<defs>
<clipPath id="clip0_1223_2296">
<rect width="24" height="24" transform="translate(0.400024)" />
</clipPath>
</defs>
</svg>
)
}
@@ -0,0 +1,32 @@
import * as React from 'react'
import { useTheme } from '@mui/material/styles'
interface GitHubIconProps {
size?: number | string
color?: string
}
export const GitHubIcon: FCWithChildren<GitHubIconProps> = ({
size,
color: colorProp,
}) => {
const theme = useTheme()
const color = colorProp || theme.palette.text.primary
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<g clipPath="url(#clip0_1223_2302)">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12.7 0C5.90002 0 0.400024 5.5 0.400024 12.3C0.400024 17.7 3.90002 22.3 8.80002 24C9.40002 24.1 9.60002 23.7 9.60002 23.4C9.60002 23.1 9.60002 22.1 9.60002 21.1C6.50002 21.7 5.70002 20.3 5.50002 19.7C5.40002 19.3 4.80002 18.3 4.20002 18C3.80002 17.8 3.20002 17.2 4.20002 17.2C5.20002 17.2 5.90002 18.1 6.10002 18.5C7.20002 20.4 9.00002 19.8 9.70002 19.5C9.80002 18.7 10.1 18.2 10.5 17.9C7.80002 17.6 4.90002 16.5 4.90002 11.8C4.90002 10.5 5.40002 9.4 6.20002 8.5C6.00002 8 5.60002 6.8 6.30002 5.1C6.30002 5.1 7.30002 4.8 9.70002 6.4C10.7 6.1 11.7 6 12.8 6C13.8 6 14.9 6.1 15.9 6.4C18.3 4.8 19.3 5.1 19.3 5.1C20 6.8 19.5 8.1 19.4 8.4C20.2 9.3 20.7 10.4 20.7 11.7C20.7 16.4 17.8 17.5 15.1 17.8C15.5 18.2 15.9 18.9 15.9 20.1C15.9 21.7 15.9 23.1 15.9 23.5C15.9 23.8 16.1 24.2 16.7 24.1C21.6 22.5 25.1 17.9 25.1 12.4C25 5.5 19.5 0 12.7 0Z"
fill={color}
/>
</g>
<defs>
<clipPath id="clip0_1223_2302">
<rect width="24" height="24" transform="translate(0.400024)" />
</clipPath>
</defs>
</svg>
)
}
@@ -0,0 +1,23 @@
import * as React from 'react'
import { useTheme } from '@mui/material/styles'
interface TelegramIconProps {
size?: number | string
color?: string
}
export const TelegramIcon: FCWithChildren<TelegramIconProps> = ({
size,
color: colorProp,
}) => {
const theme = useTheme()
const color = colorProp || theme.palette.text.primary
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<path
d="M12.4 24C19.029 24 24.4 18.629 24.4 12C24.4 5.371 19.029 0 12.4 0C5.77102 0 0.400024 5.371 0.400024 12C0.400024 18.629 5.77102 24 12.4 24ZM5.89102 11.74L17.461 7.279C17.998 7.085 18.467 7.41 18.293 8.222L18.294 8.221L16.324 17.502C16.178 18.16 15.787 18.32 15.24 18.01L12.24 15.799L10.793 17.193C10.633 17.353 10.498 17.488 10.188 17.488L10.401 14.435L15.961 9.412C16.203 9.199 15.907 9.079 15.588 9.291L8.71702 13.617L5.75502 12.693C5.11202 12.489 5.09802 12.05 5.89102 11.74Z"
fill={color}
/>
</svg>
)
}
@@ -0,0 +1,30 @@
import * as React from 'react'
import { useTheme } from '@mui/material/styles'
interface TwitterIconProps {
size?: number | string
color?: string
}
export const TwitterIcon: FCWithChildren<TwitterIconProps> = ({
size,
color: colorProp,
}) => {
const theme = useTheme()
const color = colorProp || theme.palette.text.primary
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<g clipPath="url(#clip0_1223_2294)">
<path
d="M12.4 0C5.77362 0 0.400024 5.3736 0.400024 12C0.400024 18.6264 5.77362 24 12.4 24C19.0264 24 24.4 18.6264 24.4 12C24.4 5.3736 19.0264 0 12.4 0ZM17.8791 9.35632C17.8844 9.47443 17.887 9.59308 17.887 9.71228C17.887 13.3519 15.1166 17.5488 10.0502 17.549H10.0504H10.0502C8.49475 17.549 7.0473 17.0931 5.82837 16.3118C6.04388 16.3372 6.26324 16.3499 6.48535 16.3499C7.77588 16.3499 8.9635 15.9097 9.90631 15.1708C8.70056 15.1485 7.68396 14.3522 7.33313 13.2578C7.50104 13.29 7.67371 13.3076 7.85077 13.3076C8.10217 13.3076 8.3457 13.2737 8.57715 13.2105C7.31683 12.9582 6.36743 11.8444 6.36743 10.5106C6.36743 10.4982 6.36743 10.487 6.3678 10.4755C6.73895 10.6818 7.16339 10.806 7.6153 10.8199C6.87573 10.3264 6.38959 9.48285 6.38959 8.52722C6.38959 8.02258 6.526 7.5498 6.76257 7.14276C8.12085 8.80939 10.1508 9.90546 12.4399 10.0206C12.3927 9.81885 12.3683 9.60864 12.3683 9.39258C12.3683 7.87207 13.6019 6.63849 15.123 6.63849C15.9153 6.63849 16.6309 6.97339 17.1335 7.50879C17.761 7.38501 18.3502 7.15576 18.8825 6.84027C18.6765 7.48315 18.24 8.02258 17.6713 8.36371C18.2285 8.29706 18.7595 8.14929 19.2529 7.92993C18.8843 8.48236 18.4169 8.96759 17.8791 9.35632V9.35632Z"
fill={color}
/>
</g>
<defs>
<clipPath id="clip0_1223_2294">
<rect width="24" height="24" transform="translate(0.400024)" />
</clipPath>
</defs>
</svg>
)
}
+21
View File
@@ -0,0 +1,21 @@
import type { Metadata } from 'next'
import '@interchain-ui/react/styles'
import { App } from './App'
export const metadata: Metadata = {
title: 'Nym Network Explorer',
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en">
<body>
<App>{children}</App>
</body>
</html>
)
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import { LinearProgress, Box } from '@mui/material'
export default function Loading() {
return (
<Box sx={{ py: 16 }}>
<LinearProgress />
</Box>
)
}
@@ -0,0 +1,207 @@
'use client'
import * as React from 'react'
import { Alert, AlertTitle, Box, CircularProgress, Grid } from '@mui/material'
import { useParams } from 'next/navigation'
import { GatewayBond } from '@/app/typeDefs/explorer-api'
import { ColumnsType, DetailTable } from '@/app/components/DetailTable'
import {
gatewayEnrichedToGridRow,
GatewayEnrichedRowType,
} from '@/app/components/Gateways/Gateways'
import { ComponentError } from '@/app/components/ComponentError'
import { ContentCard } from '@/app/components/ContentCard'
import { TwoColSmallTable } from '@/app/components/TwoColSmallTable'
import { UptimeChart } from '@/app/components/UptimeChart'
import {
GatewayContextProvider,
useGatewayContext,
} from '@/app/context/gateway'
import { useMainContext } from '@/app/context/main'
import { Title } from '@/app/components/Title'
const columns: ColumnsType[] = [
{
field: 'identity_key',
title: 'Identity Key',
headerAlign: 'left',
width: 230,
},
{
field: 'bond',
title: 'Bond',
headerAlign: 'left',
},
{
field: 'node_performance',
title: 'Routing Score',
headerAlign: 'left',
tooltipInfo:
"Gateway's most recent score (measured in the last 15 minutes). Routing score is relative to that of the network. Each time a gateway is tested, the test packets have to go through the full path of the network (gateway + 3 nodes). If a node in the path drop packets it will affect the score of the gateway and other nodes in the test",
},
{
field: 'avgUptime',
title: 'Avg. Score',
headerAlign: 'left',
tooltipInfo: "Gateway's average routing score in the last 24 hours",
},
{
field: 'host',
title: 'IP',
headerAlign: 'left',
width: 99,
},
{
field: 'location',
title: 'Location',
headerAlign: 'left',
},
{
field: 'owner',
title: 'Owner',
headerAlign: 'left',
},
{
field: 'version',
title: 'Version',
headerAlign: 'left',
},
]
/**
* Shows gateway details
*/
const PageGatewayDetailsWithState = ({
selectedGateway,
}: {
selectedGateway: GatewayBond | undefined
}) => {
const [enrichGateway, setEnrichGateway] =
React.useState<GatewayEnrichedRowType>()
const [status, setStatus] = React.useState<number[] | undefined>()
const { uptimeReport, uptimeStory } = useGatewayContext()
React.useEffect(() => {
if (uptimeReport?.data && selectedGateway) {
setEnrichGateway(
gatewayEnrichedToGridRow(selectedGateway, uptimeReport.data)
)
}
}, [uptimeReport, selectedGateway])
React.useEffect(() => {
if (enrichGateway) {
setStatus([enrichGateway.mixPort, enrichGateway.clientsPort])
}
}, [enrichGateway])
return (
<Box component="main">
<Title text="Gateway Detail" />
<Grid container>
<Grid item xs={12}>
<DetailTable
columnsData={columns}
tableName="Gateway detail table"
rows={enrichGateway ? [enrichGateway] : []}
/>
</Grid>
</Grid>
<Grid container spacing={2} mt={0}>
<Grid item xs={12} md={4}>
{status && (
<ContentCard title="Gateway Status">
<TwoColSmallTable
loading={false}
keys={['Mix port', 'Client WS API Port']}
values={status.map((each) => each)}
icons={status.map((elem) => !!elem)}
/>
</ContentCard>
)}
</Grid>
<Grid item xs={12} md={8}>
{uptimeStory && (
<ContentCard title="Routing Score">
{uptimeStory.error && (
<ComponentError text="There was a problem retrieving routing score." />
)}
<UptimeChart
loading={uptimeStory.isLoading}
xLabel="Date"
yLabel="Daily average"
uptimeStory={uptimeStory}
/>
</ContentCard>
)}
</Grid>
</Grid>
</Box>
)
}
/**
* Guard component to handle loadingW and not found states
*/
const PageGatewayDetailGuard = () => {
const [selectedGateway, setSelectedGateway] = React.useState<GatewayBond>()
const { gateways } = useMainContext()
const { id } = useParams()
React.useEffect(() => {
if (gateways?.data) {
setSelectedGateway(
gateways.data.find((g) => g.gateway.identity_key === id)
)
}
}, [gateways, id])
if (gateways?.isLoading) {
return <CircularProgress />
}
if (gateways?.error) {
// eslint-disable-next-line no-console
console.error(gateways?.error)
return (
<Alert severity="error">
Oh no! Could not load mixnode <code>{id || ''}</code>
</Alert>
)
}
// loaded, but not found
if (gateways && !gateways.isLoading && !gateways.data) {
return (
<Alert severity="warning">
<AlertTitle>Gateway not found</AlertTitle>
Sorry, we could not find a mixnode with id <code>{id || ''}</code>
</Alert>
)
}
return <PageGatewayDetailsWithState selectedGateway={selectedGateway} />
}
/**
* Wrapper component that adds the mixnode content based on the `id` in the address URL
*/
const PageGatewayDetail = () => {
const { id } = useParams()
if (!id || typeof id !== 'string') {
return (
<Alert severity="error">Oh no! No mixnode identity key specified</Alert>
)
}
return (
<GatewayContextProvider gatewayIdentityKey={id}>
<PageGatewayDetailGuard />
</GatewayContextProvider>
)
}
export default PageGatewayDetail
@@ -0,0 +1,405 @@
'use client'
import React, { useMemo } from 'react'
import { Box, Card, Grid, Stack } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import {
MRT_ColumnDef,
MaterialReactTable,
useMaterialReactTable,
} from 'material-react-table'
import { GridColDef, GridRenderCellParams } from '@mui/x-data-grid'
import { CopyToClipboard } from '@nymproject/react/clipboard/CopyToClipboard'
import { Tooltip as InfoTooltip } from '@nymproject/react/tooltip/Tooltip'
import { diff, gte, rcompare } from 'semver'
import { useMainContext } from '@/app/context/main'
import { TableToolbar } from '@/app/components/TableToolbar'
import { CustomColumnHeading } from '@/app/components/CustomColumnHeading'
import { Title } from '@/app/components/Title'
import { unymToNym } from '@/app/utils/currency'
import { Tooltip } from '@/app/components/Tooltip'
import { NYM_BIG_DIPPER } from '@/app/api/constants'
import { splice } from '@/app/utils'
import {
VersionDisplaySelector,
VersionSelectOptions,
} from '@/app/components/Gateways/VersionDisplaySelector'
import StyledLink from '@/app/components/StyledLink'
import {
GatewayRowType,
gatewayToGridRow,
} from '@/app/components/Gateways/Gateways'
const PageGateways = () => {
const { gateways } = useMainContext()
const [versionFilter, setVersionFilter] =
React.useState<VersionSelectOptions>(VersionSelectOptions.all)
const theme = useTheme()
const highestVersion = React.useMemo(() => {
if (gateways?.data) {
const versions = gateways.data.reduce(
(a: string[], b) => [...a, b.gateway.version],
[]
)
const [lastestVersion] = versions.sort(rcompare)
return lastestVersion
}
// fallback value
return '2.0.0'
}, [gateways])
const filterByLatestVersions = React.useMemo(() => {
const filtered = gateways?.data?.filter((gw) => {
const versionDiff = diff(highestVersion, gw.gateway.version)
return versionDiff === 'patch' || versionDiff === null
})
if (filtered) return filtered
return []
}, [gateways])
const filterByOlderVersions = React.useMemo(() => {
const filtered = gateways?.data?.filter((gw) => {
const versionDiff = diff(highestVersion, gw.gateway.version)
return versionDiff === 'major' || versionDiff === 'minor'
})
if (filtered) return filtered
return []
}, [gateways])
const filteredByVersion = React.useMemo(() => {
switch (versionFilter) {
case VersionSelectOptions.latestVersion:
return filterByLatestVersions
case VersionSelectOptions.olderVersions:
return filterByOlderVersions
case VersionSelectOptions.all:
return gateways?.data || []
default:
return []
}
}, [versionFilter, gateways])
const data = useMemo(() => {
return gatewayToGridRow(filteredByVersion || [])
}, [filteredByVersion])
const columns = useMemo<MRT_ColumnDef<GatewayRowType>[]>(() => {
return [
{
id: 'gateway-data',
header: 'Gatewsay Data',
columns: [
{
id: 'identity_key',
header: 'Identity Key',
accessorKey: 'identity_key',
size: 250,
Cell: ({ row }) => {
return (
<Stack direction="row" alignItems="center" gap={1}>
<CopyToClipboard
sx={{ mr: 0.5, color: 'grey.400' }}
smallIcons
value={row.original.identity_key}
tooltip={`Copy identity key ${row.original.identity_key} to clipboard`}
/>
<StyledLink
to={`/network-components/gateways/${row.original.identity_key}`}
dataTestId="identity-link"
color="text.primary"
>
{splice(7, 29, row.original.identity_key)}
</StyledLink>
</Stack>
)
},
},
{
id: 'node_performance',
header: 'Node Performance',
accessorKey: 'node_performance',
size: 200,
Header: () => {
return (
<Box display="flex">
<InfoTooltip
id="gateways-list-routing-score"
title="Gateway's most recent score (measured in the last 15 minutes). Routing score is relative to that of the network. Each time a gateway is tested, the test packets have to go through the full path of the network (gateway + 3 nodes). If a node in the path drop packets it will affect the score of the gateway and other nodes in the test"
placement="top-start"
textColor={theme.palette.nym.networkExplorer.tooltip.color}
bgColor={
theme.palette.nym.networkExplorer.tooltip.background
}
maxWidth={230}
arrow
/>
<CustomColumnHeading headingTitle="Routing Score" />
</Box>
)
},
Cell: ({ row }) => {
return (
<StyledLink
to={`/network-components/gateways/${row.original.identity_key}`}
data-testid="node-performance"
color="text.primary"
>
{`${row.original.node_performance}%`}
</StyledLink>
)
},
},
{
id: 'version',
header: 'Version',
accessorKey: 'version',
size: 150,
Cell: ({ row }) => {
return (
<StyledLink
to={`/network-components/gateways/${row.original.identity_key}`}
data-testid="version"
color="text.primary"
>
{row.original.version}
</StyledLink>
)
},
},
{
id: 'location',
header: 'Location',
accessorKey: 'location',
size: 150,
Cell: ({ row }) => {
return (
<Box
sx={{ justifyContent: 'flex-start', cursor: 'pointer' }}
data-testid="location-button"
>
<Tooltip
text={row.original.location}
id="gateway-location-text"
>
<Box
sx={{
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
}}
>
{row.original.location}
</Box>
</Tooltip>
</Box>
)
},
},
{
id: 'host',
header: 'IP:Port',
accessorKey: 'host',
size: 150,
Cell: ({ row }) => {
return (
<StyledLink
to={`/network-components/gateways/${row.original.identity_key}`}
data-testid="host"
color="text.primary"
>
{row.original.host}
</StyledLink>
)
},
},
{
id: 'owner',
header: 'Owner',
accessorKey: 'owner',
size: 150,
Cell: ({ row }) => {
return (
<StyledLink
to={`${NYM_BIG_DIPPER}/account/${row.original.owner}`}
target="_blank"
data-testid="owner"
color="text.primary"
>
{splice(7, 29, row.original.owner)}
</StyledLink>
)
},
},
],
},
]
}, [])
const _columns: GridColDef[] = [
{
field: 'node_performance',
align: 'center',
renderHeader: () => (
<>
<InfoTooltip
id="gateways-list-routing-score"
title="Gateway's most recent score (measured in the last 15 minutes). Routing score is relative to that of the network. Each time a gateway is tested, the test packets have to go through the full path of the network (gateway + 3 nodes). If a node in the path drop packets it will affect the score of the gateway and other nodes in the test"
placement="top-start"
textColor={theme.palette.nym.networkExplorer.tooltip.color}
bgColor={theme.palette.nym.networkExplorer.tooltip.background}
maxWidth={230}
arrow
/>
<CustomColumnHeading headingTitle="Routing Score" />
</>
),
width: 120,
disableColumnMenu: true,
headerAlign: 'center',
headerClassName: 'MuiDataGrid-header-override',
renderCell: (params: GridRenderCellParams) => (
<StyledLink
to={`/network-components/gateways/${params.row.identity_key}`}
data-testid="pledge-amount"
>
{`${params.value}%`}
</StyledLink>
),
},
{
field: 'version',
align: 'center',
renderHeader: () => <CustomColumnHeading headingTitle="Version" />,
width: 150,
disableColumnMenu: true,
headerAlign: 'center',
headerClassName: 'MuiDataGrid-header-override',
renderCell: (params: GridRenderCellParams) => (
<StyledLink
to={`/network-components/gateways/${params.row.identity_key}`}
data-testid="version"
>
{params.value}
</StyledLink>
),
sortComparator: (a, b) => {
if (gte(a, b)) return 1
return -1
},
},
{
field: 'location',
renderHeader: () => <CustomColumnHeading headingTitle="Location" />,
width: 180,
disableColumnMenu: true,
headerAlign: 'left',
headerClassName: 'MuiDataGrid-header-override',
renderCell: (params: GridRenderCellParams) => (
<Box
sx={{ justifyContent: 'flex-start', cursor: 'pointer' }}
data-testid="location-button"
>
<Tooltip text={params.value} id="gateway-location-text">
<Box
sx={{
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
}}
>
{params.value}
</Box>
</Tooltip>
</Box>
),
},
{
field: 'host',
renderHeader: () => <CustomColumnHeading headingTitle="IP:Port" />,
width: 180,
disableColumnMenu: true,
headerAlign: 'left',
headerClassName: 'MuiDataGrid-header-override',
renderCell: (params: GridRenderCellParams) => (
<StyledLink
to={`/network-components/gateways/${params.row.identity_key}`}
data-testid="host"
>
{params.value}
</StyledLink>
),
},
{
field: 'owner',
headerName: 'Owner',
renderHeader: () => <CustomColumnHeading headingTitle="Owner" />,
width: 180,
disableColumnMenu: true,
headerAlign: 'left',
headerClassName: 'MuiDataGrid-header-override',
renderCell: (params: GridRenderCellParams) => (
<StyledLink
to={`${NYM_BIG_DIPPER}/account/${params.value}`}
target="_blank"
data-testid="owner"
>
{splice(7, 29, params.value)}
</StyledLink>
),
},
{
field: 'bond',
width: 150,
disableColumnMenu: true,
type: 'number',
renderHeader: () => <CustomColumnHeading headingTitle="Bond" />,
headerClassName: 'MuiDataGrid-header-override',
headerAlign: 'left',
renderCell: (params: GridRenderCellParams) => (
<StyledLink
to={`/network-components/gateways/${params.row.identity_key}`}
data-testid="pledge-amount"
>
{`${unymToNym(params.value, 6)}`}
</StyledLink>
),
},
]
const table = useMaterialReactTable({
columns,
data,
})
return (
<>
<Box mb={2}>
<Title text="Gateways" />
</Box>
<Grid container>
<Grid item xs={12}>
<Card
sx={{
padding: 2,
height: '100%',
}}
>
<TableToolbar
childrenBefore={
<VersionDisplaySelector
handleChange={(option) => setVersionFilter(option)}
selected={versionFilter}
/>
}
/>
<MaterialReactTable table={table} />
</Card>
</Grid>
</Grid>
</>
)
}
export default PageGateways
@@ -0,0 +1,297 @@
'use client'
import * as React from 'react'
import {
Alert,
AlertTitle,
Box,
CircularProgress,
Grid,
Typography,
} from '@mui/material'
import { ColumnsType, DetailTable } from '@/app/components/DetailTable'
import { BondBreakdownTable } from '@/app/components/MixNodes/BondBreakdown'
import {
DelegatorsInfoTable,
EconomicsInfoColumns,
EconomicsInfoRows,
} from '@/app/components/MixNodes/Economics'
import { ComponentError } from '@/app/components/ComponentError'
import { ContentCard } from '@/app/components/ContentCard'
import { TwoColSmallTable } from '@/app/components/TwoColSmallTable'
import { UptimeChart } from '@/app/components/UptimeChart'
import { WorldMap } from '@/app/components/WorldMap'
import { MixNodeDetailSection } from '@/app/components/MixNodes/DetailSection'
import {
MixnodeContextProvider,
useMixnodeContext,
} from '@/app/context/mixnode'
import { Title } from '@/app/components/Title'
import { useIsMobile } from '@/app/hooks/useIsMobile'
import { useParams } from 'next/navigation'
const columns: ColumnsType[] = [
{
field: 'owner',
title: 'Owner',
width: '15%',
},
{
field: 'identity_key',
title: 'Identity Key',
width: '15%',
},
{
field: 'bond',
title: 'Stake',
width: '12.5%',
},
{
field: 'stake_saturation',
title: 'Stake Saturation',
width: '12.5%',
tooltipInfo:
'Level of stake saturation for this node. Nodes receive more rewards the higher their saturation level, up to 100%. Beyond 100% no additional rewards are granted. The current stake saturation level is 940k NYMs, computed as S/K where S is target amount of tokens staked in the network and K is the number of nodes in the reward set.',
},
{
field: 'self_percentage',
width: '10%',
title: 'Bond %',
tooltipInfo:
"Percentage of the operator's bond to the total stake on the node",
},
{
field: 'host',
width: '10%',
title: 'Host',
},
{
field: 'location',
title: 'Location',
},
{
field: 'layer',
title: 'Layer',
},
]
/**
* Shows mix node details
*/
const PageMixnodeDetailWithState = () => {
const {
mixNode,
mixNodeRow,
description,
stats,
status,
uptimeStory,
uniqDelegations,
} = useMixnodeContext()
const isMobile = useIsMobile()
return (
<Box component="main">
<Title text="Mixnode Detail" />
<Grid container spacing={2} mt={1} mb={6}>
<Grid item xs={12}>
{mixNodeRow && description?.data && (
<MixNodeDetailSection
mixNodeRow={mixNodeRow}
mixnodeDescription={description.data}
/>
)}
{mixNodeRow?.blacklisted && (
<Typography
textAlign={isMobile ? 'left' : 'right'}
fontSize="smaller"
sx={{ color: 'error.main' }}
>
This node is having a poor performance
</Typography>
)}
</Grid>
</Grid>
<Grid container>
<Grid item xs={12}>
<DetailTable
columnsData={columns}
tableName="Mixnode detail table"
rows={mixNodeRow ? [mixNodeRow] : []}
/>
</Grid>
</Grid>
<Grid container spacing={2} mt={0}>
<Grid item xs={12}>
<DelegatorsInfoTable
columnsData={EconomicsInfoColumns}
tableName="Delegators info table"
rows={[EconomicsInfoRows()]}
/>
</Grid>
</Grid>
<Grid container spacing={2} mt={0}>
<Grid item xs={12}>
<ContentCard
title={`Stake Breakdown (${uniqDelegations?.data?.length} delegators)`}
>
<BondBreakdownTable />
</ContentCard>
</Grid>
</Grid>
<Grid container spacing={2} mt={0}>
<Grid item xs={12} md={4}>
<ContentCard title="Mixnode Stats">
{stats && (
<>
{stats.error && (
<ComponentError text="There was a problem retrieving this nodes stats." />
)}
<TwoColSmallTable
loading={stats.isLoading}
error={stats?.error?.message}
title="Since startup"
keys={['Received', 'Sent', 'Explicitly dropped']}
values={[
stats?.data?.packets_received_since_startup || 0,
stats?.data?.packets_sent_since_startup || 0,
stats?.data?.packets_explicitly_dropped_since_startup || 0,
]}
/>
<TwoColSmallTable
loading={stats.isLoading}
error={stats?.error?.message}
title="Since last update"
keys={['Received', 'Sent', 'Explicitly dropped']}
values={[
stats?.data?.packets_received_since_last_update || 0,
stats?.data?.packets_sent_since_last_update || 0,
stats?.data?.packets_explicitly_dropped_since_last_update ||
0,
]}
marginBottom
/>
</>
)}
{!stats && <Typography>No stats information</Typography>}
</ContentCard>
</Grid>
<Grid item xs={12} md={8}>
{uptimeStory && (
<ContentCard title="Routing Score">
{uptimeStory.error && (
<ComponentError text="There was a problem retrieving routing score." />
)}
<UptimeChart
loading={uptimeStory.isLoading}
xLabel="Date"
yLabel="Daily average"
uptimeStory={uptimeStory}
/>
</ContentCard>
)}
</Grid>
</Grid>
<Grid container spacing={2} mt={0}>
<Grid item xs={12} md={4}>
{status && (
<ContentCard title="Mixnode Status">
{status.error && (
<ComponentError text="There was a problem retrieving port information" />
)}
<TwoColSmallTable
loading={status.isLoading}
error={status?.error?.message}
keys={['Mix port', 'Verloc port', 'HTTP port']}
values={[1789, 1790, 8000].map((each) => each)}
icons={
(status?.data?.ports && Object.values(status.data.ports)) || [
false,
false,
false,
]
}
/>
</ContentCard>
)}
</Grid>
<Grid item xs={12} md={8}>
{mixNode && (
<ContentCard title="Location">
{mixNode?.error && (
<ComponentError text="There was a problem retrieving this mixnode location" />
)}
{mixNode?.data?.location?.latitude &&
mixNode?.data?.location?.longitude && (
<WorldMap
loading={mixNode.isLoading}
userLocation={[
mixNode.data.location.longitude,
mixNode.data.location.latitude,
]}
/>
)}
</ContentCard>
)}
</Grid>
</Grid>
</Box>
)
}
/**
* Guard component to handle loading and not found states
*/
const PageMixnodeDetailGuard = () => {
const { mixNode } = useMixnodeContext()
const { id } = useParams()
if (mixNode?.isLoading) {
return <CircularProgress />
}
if (mixNode?.error) {
// eslint-disable-next-line no-console
console.error(mixNode?.error)
return (
<Alert severity="error">
Oh no! Could not load mixnode <code>{id || ''}</code>
</Alert>
)
}
// loaded, but not found
if (mixNode && !mixNode.isLoading && !mixNode.data) {
return (
<Alert severity="warning">
<AlertTitle>Mixnode not found</AlertTitle>
Sorry, we could not find a mixnode with id <code>{id || ''}</code>
</Alert>
)
}
return <PageMixnodeDetailWithState />
}
/**
* Wrapper component that adds the mixnode content based on the `id` in the address URL
*/
const PageMixnodeDetail = () => {
const { id } = useParams()
if (!id || typeof id !== 'string') {
return (
<Alert severity="error">Oh no! No mixnode identity key specified</Alert>
)
}
return (
<MixnodeContextProvider mixId={id}>
<PageMixnodeDetailGuard />
</MixnodeContextProvider>
)
}
export default PageMixnodeDetail

Some files were not shown because too many files have changed in this diff Show More