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:
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 }
|
||||
@@ -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 || '';
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
</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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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];
|
||||
};
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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' }} />,
|
||||
},
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user