Yana/node page (#5276)

* Add nym-node page api WIP

* nym-node page api WIP

* Add rewards card

* Add account balances

* fix build

* Add USD price to tokenomics card

* fix build

* fix build

* fix build

* Refactor ProgressBar

* Add profile card

* fix build

* replace hardcoded id

* Add build version and node roles

* fix build

* rename id param to address

* add node table to explorer page

* get node details from unstable endpoint + layout updates

* allow node select from table

* stop propogation on favorite/unfavorite

* update self bond data

* card refactors

* revert node engine requirement

* use node v20

---------

Co-authored-by: Yana <yanok87@users.noreply.github.com>
Co-authored-by: fmtabbara <fmtabbara@hotmail.co.uk>
This commit is contained in:
Yana Matrosova
2024-12-20 21:58:04 +07:00
committed by Yana
parent 09b9866bf0
commit ee4f5da834
35 changed files with 1242 additions and 7790 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
- uses: rlespinasse/github-slug-action@v3.x
- uses: actions/setup-node@v4
with:
node-version: 18
node-version: 20
- name: Setup yarn
run: npm install -g yarn
- name: Build
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 18
node-version: 20
- name: Setup yarn
run: npm install -g yarn
+4
View File
@@ -12,6 +12,8 @@
"node": ">=20.0.0"
},
"dependencies": {
"@cosmjs/cosmwasm-stargate": "^0.32.4",
"@cosmjs/proto-signing": "^0.32.4",
"@emotion/cache": "^11.13.5",
"@emotion/react": "^11.13.5",
"@emotion/styled": "^11.13.5",
@@ -20,6 +22,7 @@
"@mui/material-nextjs": "^6.1.9",
"@mui/x-date-pickers": "^7.23.2",
"@nivo/line": "^0.88.0",
"@nymproject/contract-clients": "^1.4.1",
"@tanstack/react-table": "^8.20.6",
"@uidotdev/usehooks": "^2.4.1",
"cldr-compact-number": "^0.4.0",
@@ -36,6 +39,7 @@
"@mui/x-date-pickers": "7.1.1",
"@mui/x-data-grid": "7.1.1",
"@mui/x-charts": "^7.22.3",
"react-random-avatars": "^1.3.1",
"react-world-flags": "^1.6.0"
},
"devDependencies": {
@@ -0,0 +1,103 @@
import type { CurrencyRates, IAccountBalancesInfo } from "@/app/api/types";
import type NodeData from "@/app/api/types";
import { NYM_ACCOUNT_ADDRESS, NYM_NODES, NYM_PRICES_API } from "@/app/api/urls";
import { AccountBalancesCard } from "@/components/accountPageComponents/AccountBalancesCard";
import { AccountInfoCard } from "@/components/accountPageComponents/AccountInfoCard";
import { ContentLayout } from "@/components/contentLayout/ContentLayout";
import SectionHeading from "@/components/headings/SectionHeading";
import ExplorerButtonGroup from "@/components/toggleButton/ToggleButton";
import { Box, Grid2, Typography } from "@mui/material";
export default async function Account({
params,
}: {
params: Promise<{ address: string }>;
}) {
try {
const { address } = await params;
const accountData = await fetch(`${NYM_ACCOUNT_ADDRESS}${address}`, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
next: { revalidate: 60 },
// refresh event list cache at given interval
});
const response = await fetch(NYM_NODES, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
next: { revalidate: 60 },
// refresh event list cache at given interval
});
const nymNodes: NodeData[] = await response.json();
const nymNode = nymNodes.find(
(node) => node.bond_information.owner === address,
);
const nymAccountBalancesData: IAccountBalancesInfo =
await accountData.json();
if (!nymAccountBalancesData) {
return <Typography>Account not found</Typography>;
}
const nymPrice = await fetch(NYM_PRICES_API, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
next: { revalidate: 60 },
// refresh event list cache at given interval
});
const nymPriceData: CurrencyRates = await nymPrice.json();
return (
<ContentLayout>
<Grid2 container columnSpacing={5} rowSpacing={5}>
<Grid2 size={6}>
<SectionHeading title="Account Details" />
</Grid2>
<Grid2 size={6} justifyContent="flex-end">
<Box sx={{ display: "flex", justifyContent: "end" }}>
<ExplorerButtonGroup
options={[
{
label: "Nym Node",
isSelected: false,
link: nymNode
? `/nym-node/${nymNode.node_id}`
: "/nym-node/not-found",
},
{
label: "Account",
isSelected: true,
link: "/account/1",
},
]}
/>
</Box>
</Grid2>
<Grid2 size={4}>
<AccountInfoCard accountInfo={nymAccountBalancesData} />
</Grid2>
<Grid2 size={8}>
<AccountBalancesCard
accountInfo={nymAccountBalancesData}
nymPrice={nymPriceData.usd}
/>
</Grid2>
</Grid2>
</ContentLayout>
);
} catch (error) {
console.error("error :>> ", error);
return <Typography>Error loading account data</Typography>;
}
}
@@ -1,61 +0,0 @@
import ExplorerCard from "@/components/cards/ExplorerCard";
import { ContentLayout } from "@/components/contentLayout/ContentLayout";
import SectionHeading from "@/components/headings/SectionHeading";
import ExplorerListItem from "@/components/list/ListItem";
import ExplorerButtonGroup from "@/components/toggleButton/ToggleButton";
import { Box, Grid2, Stack } from "@mui/material";
export default function Account() {
return (
<ContentLayout>
<Grid2 container columnSpacing={5} rowSpacing={5}>
<Grid2 size={6}>
<SectionHeading title="Account Details" />
</Grid2>
<Grid2 size={6} justifyContent="flex-end">
<Box sx={{ display: "flex", justifyContent: "end" }}>
<ExplorerButtonGroup
options={[
{ label: "Nym Node", isSelected: false, link: "/nym-node/1" },
{
label: "Account",
isSelected: true,
link: "/account/1",
},
]}
/>
</Box>
</Grid2>
<Grid2 size={4}>
<ExplorerCard label="Action" sx={{ height: "100%" }}>
<div />
</ExplorerCard>
</Grid2>
<Grid2 size={8}>
<ExplorerCard label="Basic info">
<Stack gap={1}>
<ExplorerListItem
divider
label="NYM Address"
value="0x1234567890"
/>
<ExplorerListItem
divider
label="Identity Key"
value="0x1234567890"
/>
<ExplorerListItem
row
divider
label="Node bonded"
value="24/11/2024"
/>
<ExplorerListItem row divider label="Nr. of stakes" value="56" />
<ExplorerListItem row label="Self bonded" value="10,000 NYM" />
</Stack>
</ExplorerCard>
</Grid2>
</Grid2>
</ContentLayout>
);
}
+128 -13
View File
@@ -3,6 +3,7 @@ import {
CIRCULATING_NYM_SUPPLY,
HARBOURMASTER_API_MIXNODES_STATS,
NYM_NODES_DESCRIBED,
NYM_NODE_DESCRIPTION,
} from "./urls";
type Denom = "unym" | "nym";
@@ -83,6 +84,96 @@ export interface ExplorerCache {
};
}
export interface IBondInfo {
bond_information: {
bonding_height: number;
is_unbonding: boolean;
node: {
custom_http_port: number;
host: string;
identity_key: string;
};
node_id: number;
original_pledge: {
amount: string;
denom: string;
};
owner: string;
};
rewarding_details: {
cost_params: {
profit_margin_percent: string;
interval_operating_cost: {
denom: string;
amount: string;
};
};
delegates: string;
last_rewarded_epoch: number;
operator: string;
total_unit_reward: string;
unique_delegations: number;
unit_delegation: string;
};
}
export interface INodeDescription {
contract_node_type: string;
description: {
authenticator: object;
address: string;
auxiliary_details: {
location: string;
accepted_operator_terms_and_conditions: boolean;
announce_ports: {
verloc_port: number | null;
mix_port: number | null;
};
};
build_information: {
binary_name: string;
build_timestamp: string;
build_version: string;
cargo_profile: string;
cargo_triple: string;
commit_branch: string;
commit_sha: string;
commit_timestamp: string;
rustc_channel: string;
rustc_version: string;
};
declared_role: {
entry: boolean;
exit_ipr: boolean;
exit_nr: boolean;
mixnode: boolean;
};
host_information: {
hostname: string | null;
ip_address: string[];
keys: {
ed25519: string;
x25519: string;
x25519_noise: string | null;
};
};
ip_packet_router: {
address: string;
};
last_polled: string;
mixnet_websockets: {
ws_port: number;
wss_port: number | null;
};
network_requester: {
address: string;
uses_exit_policy: boolean;
};
wireguard: null | object;
};
node_id: number;
}
const getExplorerData = async () => {
// FETCH NYMNODES
const fetchNymNodes = await fetch(NYM_NODES_DESCRIBED, {
@@ -114,21 +205,45 @@ const getExplorerData = async () => {
next: { revalidate: Number(process.env.NEXT_PUBLIC_REVALIDATE_CACHE) },
});
const [circulatingNymSupplyRes, nymNodesRes, packetsAndStakingRes] =
await Promise.all([
fetchCirculatingNymSupply,
fetchNymNodes,
fetchPacketsAndStaking,
]);
const fetchNymNodeDescription = await fetch(NYM_NODE_DESCRIPTION, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
// refresh event list cache at given interval
next: { revalidate: Number(process.env.NEXT_PUBLIC_REVALIDATE_CACHE) },
});
const [circulatingNymSupplyData, nymNodesData, packetsAndStakingData] =
await Promise.all([
circulatingNymSupplyRes.json(),
nymNodesRes.json(),
packetsAndStakingRes.json(),
]);
const [
circulatingNymSupplyRes,
nymNodesRes,
packetsAndStakingRes,
nymNodeDescriptionRes,
] = await Promise.all([
fetchCirculatingNymSupply,
fetchNymNodes,
fetchPacketsAndStaking,
fetchNymNodeDescription,
]);
return [circulatingNymSupplyData, nymNodesData, packetsAndStakingData];
const [
circulatingNymSupplyData,
nymNodesData,
packetsAndStakingData,
nymNodeDescriptionData,
] = await Promise.all([
circulatingNymSupplyRes.json(),
nymNodesRes.json(),
packetsAndStakingRes.json(),
nymNodeDescriptionRes.json(),
]);
return [
circulatingNymSupplyData,
nymNodesData,
packetsAndStakingData,
nymNodeDescriptionData,
];
};
export async function ensureCacheExists() {
+41
View File
@@ -106,3 +106,44 @@ type NodeData = {
};
export default NodeData;
export interface CurrencyRates {
btc: number;
chf: number;
eur: number;
timestamp: number;
usd: number;
}
// ACCOUNT BALANCES
export interface IRewardDetails {
amount_staked: IAmountDetails;
node_id: number;
node_still_fully_bonded: boolean;
rewards: IAmountDetails;
}
export interface IAmountDetails {
denom: string;
amount: string;
}
export interface IDelegationDetails {
node_id: number;
delegated: IAmountDetails;
height: number;
proxy: null | string;
}
export interface IAccountBalancesInfo {
accumulated_rewards: IRewardDetails[];
address: string;
balances: IAmountDetails[];
claimable_rewards: IAmountDetails;
delegations: IDelegationDetails[];
operator_rewards?: null | IAmountDetails;
total_delegations: IAmountDetails;
total_value: IAmountDetails;
vesting_account?: null | string;
}
+8
View File
@@ -22,3 +22,11 @@ export const CURRENT_EPOCH_REWARDS =
"https://validator.nymtech.net/api/v1/epoch/reward_params";
export const CIRCULATING_NYM_SUPPLY =
"https://validator.nymtech.net/api/v1/circulating-supply";
export const NYM_NODE_DESCRIPTION =
"https://nym-api.swiss-staking.ch/v1/nym-nodes/described";
export const NYM_NODE_BONDED =
"https://nym-api.swiss-staking.ch/v1/nym-nodes/bonded";
export const NYM_ACCOUNT_ADDRESS =
"https://explorer.nymtech.net/api/v1/tmp/unstable/account/";
export const NYM_PRICES_API =
"https://canary-nym-vpn-chain-payment-watcher.nymte.ch/v1/price/average";
+13 -25
View File
@@ -1,31 +1,19 @@
import ExplorerButtonGroup from "@/components/toggleButton/ToggleButton";
import CardSkeleton from "@/components/cards/Skeleton";
import { ContentLayout } from "@/components/contentLayout/ContentLayout";
import SectionHeading from "@/components/headings/SectionHeading";
import NodeTableWithAction from "@/components/nodeTable/NodeTableWithAction";
import { Wrapper } from "@/components/wrapper";
import { Box, Typography } from "@mui/material";
import { Suspense } from "react";
export default function ExplorerPage() {
return (
<div>
<main>
<Box sx={{ p: 5 }}>
<Wrapper>
<Typography fontWeight="light">Explorer page</Typography>
<ExplorerButtonGroup
options={[
{
label: "Node",
link: "/explorer",
isSelected: true,
},
{
label: "Account",
link: "/stake",
isSelected: false,
},
]}
/>
</Wrapper>
</Box>
</main>
</div>
<ContentLayout>
<Wrapper>
<SectionHeading title="Explorer" />
<Suspense fallback={<CardSkeleton sx={{ mt: 5 }} />}>
<NodeTableWithAction />
</Suspense>
</Wrapper>
</ContentLayout>
);
}
+4 -4
View File
@@ -1,4 +1,4 @@
import type { IAccountStatsCardProps } from "@/components/cards/AccountStatsCard";
import type { IAccountBalancesTableProps } from "@/components/cards/AccountBalancesTable";
import type { ContentCardProps } from "@/components/cards/MonoCard";
const explorerCard: ContentCardProps = {
@@ -78,9 +78,9 @@ const explorerCard: ContentCardProps = {
},
};
const accountStatsCard: IAccountStatsCardProps = {
overTitle: "Total value",
priceTitle: 1990.0174,
const accountStatsCard: IAccountBalancesTableProps = {
// overTitle: "Total value",
// priceTitle: 1990.0174,
rows: [
{ type: "Spendable", allocation: 15.53, amount: 12800, value: 1200 },
{
+85 -114
View File
@@ -1,139 +1,110 @@
import ExplorerCard from "@/components/cards/ExplorerCard";
import type NodeData from "@/app/api/types";
import { NYM_NODES } from "@/app/api/urls";
import { ContentLayout } from "@/components/contentLayout/ContentLayout";
import SectionHeading from "@/components/headings/SectionHeading";
import ExplorerListItem from "@/components/list/ListItem";
import { StarRating } from "@/components/starRating";
import { BasicInfoCard } from "@/components/nymNodePageComponents/BasicInfoCard";
import { NodeMetricsCard } from "@/components/nymNodePageComponents/NodeMetricsCard";
import { NodeProfileCard } from "@/components/nymNodePageComponents/NodeProfileCard";
import { NodeRewardsCard } from "@/components/nymNodePageComponents/NodeRewardsCard";
import { QualityIndicatorsCard } from "@/components/nymNodePageComponents/QualityIndicatorsCard";
import ExplorerButtonGroup from "@/components/toggleButton/ToggleButton";
import { Box, Grid2, Stack } from "@mui/material";
import { Box, Grid2 } from "@mui/material";
export default async function NymNode({
params,
}: {
params: Promise<{ id: string; account?: string }>;
}) {
const id = Number((await params).id);
const response = await fetch(NYM_NODES, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
next: { revalidate: 60 },
// refresh event list cache at given interval
});
const nymNodes: NodeData[] = await response.json();
if (!nymNodes) {
return null;
}
const nymNode = nymNodes.find((node) => node.node_id === id);
if (!nymNode) {
return null;
}
export default function NymNode() {
return (
<ContentLayout>
<Grid2 container columnSpacing={5} rowSpacing={5}>
<Grid2 size={6}>
<SectionHeading title="Nym Node Details" />
</Grid2>
<Grid2 size={6} justifyContent="flex-end">
<Box sx={{ display: "flex", justifyContent: "end" }}>
<Grid2 size={12}>
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
<SectionHeading title="Nym Node Details" />
<ExplorerButtonGroup
options={[
{ label: "Nym Node", isSelected: true, link: "/nym-node/1" },
{
label: "Account",
isSelected: false,
link: "/account/1",
link: `/account/${nymNode.bond_information.owner}`,
},
]}
/>
</Box>
</Grid2>
<Grid2 size={4}>
<ExplorerCard label="Action" sx={{ height: "100%" }}>
<div />
</ExplorerCard>
<Grid2
size={{
xs: 12,
md: 4,
}}
>
<NodeProfileCard
bondInfo={nymNode.bond_information}
nodeDescription={nymNode.description}
/>
</Grid2>
<Grid2 size={4}>
<ExplorerCard label="Basic info">
<Stack gap={1}>
<ExplorerListItem
divider
label="NYM Address"
value="0x1234567890"
/>
<ExplorerListItem
divider
label="Identity Key"
value="0x1234567890"
/>
<ExplorerListItem
row
divider
label="Node bonded"
value="24/11/2024"
/>
<ExplorerListItem row divider label="Nr. of stakes" value="56" />
<ExplorerListItem row label="Self bonded" value="10,000 NYM" />
</Stack>
</ExplorerCard>
<Grid2
size={{
xs: 12,
md: 4,
}}
>
<BasicInfoCard
bondInfo={nymNode.bond_information}
nodeDescription={nymNode.description}
rewardDetails={nymNode.rewarding_details}
/>
</Grid2>
<Grid2 size={4}>
<ExplorerCard
label="Node Rewards (Last Epoch/Hour)"
sx={{ height: "100%" }}
>
<ExplorerListItem row divider label="Role" value="Gateway" />
<ExplorerListItem
row
divider
label="Quality of service"
value={<StarRating value={5} />}
/>
<ExplorerListItem
row
divider
label="Config score"
value={<StarRating value={4} />}
/>
<ExplorerListItem
row
divider
label="Probe score"
value={<StarRating value={5} />}
/>
</ExplorerCard>
<Grid2
size={{
xs: 12,
md: 4,
}}
>
<QualityIndicatorsCard nodeDescription={nymNode.description} />
</Grid2>
<Grid2 size={6}>
<ExplorerCard label="Nym node metrics" sx={{ height: "100%" }}>
<ExplorerListItem
row
divider
label="Total rew."
value="10,000 NYM"
/>
<ExplorerListItem
row
divider
label="Operator rew."
value="10,000 NYM"
/>
<ExplorerListItem
row
divider
label="Staker rew."
value="10,000 NYM"
/>
<ExplorerListItem
row
divider
label="Profit margin rew."
value="40 NYM"
/>
<ExplorerListItem
row
divider
label="Operating cost."
value="40 NYM"
/>
</ExplorerCard>
<Grid2
size={{
xs: 12,
md: 6,
}}
>
<NodeRewardsCard rewardDetails={nymNode.rewarding_details} />
</Grid2>
<Grid2 size={6}>
<ExplorerCard label="Nym node metrics" sx={{ height: "100%" }}>
<ExplorerListItem row divider label="Node ID." value="209" />
<ExplorerListItem row divider label="Host" value="45.10.145.123" />
<ExplorerListItem
row
divider
label="Staker rew."
value="10,000 NYM"
/>
<ExplorerListItem row divider label="Version" value="1.1.1.1" />
<ExplorerListItem
row
divider
label="Active set Prob."
value="High"
/>
</ExplorerCard>
<Grid2
size={{
xs: 12,
md: 6,
}}
>
<NodeMetricsCard
nodeDescription={nymNode.description}
nodeId={nymNode.bond_information.node_id}
/>
</Grid2>
</Grid2>
</ContentLayout>
@@ -0,0 +1,14 @@
import { ContentLayout } from "@/components/contentLayout/ContentLayout";
import SectionHeading from "@/components/headings/SectionHeading";
import { Typography } from "@mui/material";
const NotFound = () => {
return (
<ContentLayout>
<SectionHeading title="Nym Node" />
<Typography variant="body3">This Nym Node could not be found</Typography>
</ContentLayout>
);
};
export default NotFound;
@@ -0,0 +1,143 @@
"use client";
import type { IAccountBalancesInfo, IRewardDetails } from "@/app/api/types";
import { AccountBalancesTable } from "../cards/AccountBalancesTable";
import ExplorerCard from "../cards/ExplorerCard";
export interface IAccontStatsRowProps {
type: string;
allocation: number;
amount: number;
value: number;
history?: { type: string; amount: number }[];
isLastRow?: boolean;
progressBarColor?: string;
}
interface IAccountBalancesCardProps {
accountInfo: IAccountBalancesInfo;
nymPrice: number;
}
const getNymsFormated = (unyms: number) => {
const balance = unyms / 1000000;
return balance;
};
const getPriceInUSD = (unyms: number, usdPrice: number) => {
const balanceInUSD = (unyms / 1000000) * usdPrice;
const balanceFormated = Number(balanceInUSD.toFixed(2));
return balanceFormated;
};
const getAllocation = (unyms: number, totalUnyms: number): number => {
const allocationPercentage = (unyms * 100) / totalUnyms;
return Number(allocationPercentage.toFixed(2));
};
const calculateStakingRewards = (
accumulatedRewards: IRewardDetails[],
): number => {
const totalRewards = accumulatedRewards.reduce((total, rewardDetail) => {
return total + Number.parseFloat(rewardDetail.rewards.amount);
}, 0);
const result = getNymsFormated(totalRewards);
return result;
};
export const AccountBalancesCard = (props: IAccountBalancesCardProps) => {
const { accountInfo, nymPrice } = props;
const totalBalanceUSD = getPriceInUSD(
Number(accountInfo.total_value.amount),
nymPrice,
);
const spendableNYM = getNymsFormated(Number(accountInfo.balances[0].amount));
const spendableUSD = getPriceInUSD(
Number(accountInfo.balances[0].amount),
nymPrice,
);
const spendableAllocation = getAllocation(
Number(accountInfo.balances[0].amount),
Number(accountInfo.total_value.amount),
);
const delegationsNYM = getNymsFormated(
Number(accountInfo.total_delegations.amount),
);
const delegationsUSD = getPriceInUSD(
Number(accountInfo.total_delegations.amount),
nymPrice,
);
const delegationsAllocation = getAllocation(
Number(accountInfo.total_delegations.amount),
Number(accountInfo.total_value.amount),
);
const claimableNYM = getNymsFormated(
Number(accountInfo.claimable_rewards.amount),
);
const claimableUSD = getPriceInUSD(
Number(accountInfo.claimable_rewards.amount),
nymPrice,
);
const claimableAllocation = getAllocation(
Number(accountInfo.claimable_rewards.amount),
Number(accountInfo.total_value.amount),
);
const stakingRewards =
accountInfo.accumulated_rewards.length > 0
? calculateStakingRewards(accountInfo.accumulated_rewards)
: 0;
const tableRows = [
{
type: "Spendable",
allocation: spendableAllocation,
amount: spendableNYM,
value: spendableUSD,
},
{
type: "Delegated",
allocation: delegationsAllocation,
amount: delegationsNYM,
value: delegationsUSD,
// history: [
// { type: "Liquid", amount: 6900 },
// { type: "Locked", amount: 6900 },
// ],
},
{
type: "Claimable",
allocation: claimableAllocation,
amount: claimableNYM,
value: claimableUSD,
history: [
// { type: "Unlocked", amount: 6900 },
{
type: "Staking rewards",
amount: stakingRewards,
},
{ type: "Operator comission", amount: 0 },
],
},
{
type: "Self bonded",
allocation: 0,
amount: 0,
value: 0,
},
];
return (
<ExplorerCard
label="Total value"
title={`$ ${totalBalanceUSD}`}
sx={{ height: "100%" }}
>
<AccountBalancesTable rows={tableRows} />
</ExplorerCard>
);
};
@@ -0,0 +1,48 @@
"use client";
import type { IAccountBalancesInfo } from "@/app/api/types";
import { Box, Stack, Typography } from "@mui/material";
import ExplorerCard from "../cards/ExplorerCard";
import CopyToClipboard from "../copyToClipboard/CopyToClipboard";
import ExplorerListItem from "../list/ListItem";
import { CardQRCode } from "../qrCode/QrCode";
interface IAccountInfoCardProps {
accountInfo: IAccountBalancesInfo;
}
export const AccountInfoCard = (props: IAccountInfoCardProps) => {
const { accountInfo } = props;
const balance = Number(accountInfo.balances[0].amount) / 1000000;
const balanceFormated = `${balance} NYM`;
return (
<ExplorerCard
label="Address"
title={balanceFormated}
sx={{ height: "100%" }}
>
<Stack gap={5}>
<Box display={"flex"} justifyContent={"flex-start"}>
<CardQRCode url={accountInfo.address} />
</Box>
<ExplorerListItem
label="Address"
value={
<Stack
direction="row"
gap={0.1}
alignItems="center"
justifyContent="space-between"
width="100%"
>
<Typography variant="body4">{accountInfo.address}</Typography>
<CopyToClipboard text={accountInfo.address} />
</Stack>
}
/>
</Stack>
</ExplorerCard>
);
};
@@ -4,7 +4,6 @@ import { TABLET_WIDTH } from "@/app/constants";
import CircleIcon from "@mui/icons-material/Circle";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
import { Card, CardContent } from "@mui/material";
import Box from "@mui/material/Box";
import IconButton from "@mui/material/IconButton";
import Table from "@mui/material/Table";
@@ -148,7 +147,7 @@ const Row = (props: IAccontStatsRowProps) => {
}}
>
<Box display={"flex"} gap={1} alignItems={"center"}>
<CircleIcon sx={{ color: progressBarColor }} fontSize="small" />
<CircleIcon sx={{ color: progressBarColor, fontSize: 12 }} />
{type}
</Box>
</TableCell>
@@ -235,14 +234,12 @@ const Row = (props: IAccontStatsRowProps) => {
);
};
export interface IAccountStatsCardProps {
export interface IAccountBalancesTableProps {
rows: Array<IAccontStatsRowProps>;
overTitle?: string;
priceTitle?: number;
}
export const AccountStatsCard = (props: IAccountStatsCardProps) => {
const { rows, overTitle, priceTitle } = props;
export const AccountBalancesTable = (props: IAccountBalancesTableProps) => {
const { rows } = props;
const tablet = useMediaQuery(TABLET_WIDTH);
const progressBarPercentages = () => {
return rows.map((row) => row.allocation);
@@ -262,80 +259,63 @@ export const AccountStatsCard = (props: IAccountStatsCardProps) => {
const progressValues = getProgressValues();
return (
<Card sx={{ height: "100%", borderRadius: "unset", padding: 1 }}>
<CardContent>
{overTitle && (
<Typography
mb={3}
textTransform={"uppercase"}
variant="h5"
sx={{ color: "pine.600", letterSpacing: 0.7 }}
>
{overTitle}
</Typography>
)}
{priceTitle && (
<Typography variant="h3" sx={{ color: "pine.950" }} mb={3}>
${priceTitle}
</Typography>
)}
{!tablet && <MultiSegmentProgressBar values={progressValues} />}
<TableContainer>
<Table aria-label="collapsible table" sx={{ marginBottom: 3 }}>
<TableHead>
{tablet ? (
<TableRow>
<TableCell>
<Typography variant="subtitle2" sx={{ color: "pine.600" }}>
Type
</Typography>
</TableCell>
<TableCell align="right">
<Typography variant="subtitle2" sx={{ color: "pine.600" }}>
Allocation
</Typography>
</TableCell>
<TableCell align="right">
<Typography variant="subtitle2" sx={{ color: "pine.600" }}>
Amount
</Typography>
</TableCell>
<TableCell align="right">
<Typography variant="subtitle2" sx={{ color: "pine.600" }}>
Value
</Typography>
</TableCell>
<TableCell />
</TableRow>
) : (
<TableRow>
<TableCell>
<Typography variant="subtitle2" sx={{ color: "pine.600" }}>
Type
</Typography>
</TableCell>
<TableCell align="right">
<Typography variant="subtitle2" sx={{ color: "pine.600" }}>
Amount / Value
</Typography>
</TableCell>
<TableCell />
</TableRow>
)}
</TableHead>
<TableBody>
{rows.map((row, i) => (
<Row
key={row.type}
{...row}
isLastRow={i === rows.length - 1}
progressBarColor={progressBarColours[i]}
/>
))}
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
<Box>
{!tablet && <MultiSegmentProgressBar values={progressValues} />}
<TableContainer>
<Table aria-label="collapsible table" sx={{ marginBottom: 3 }}>
<TableHead>
{tablet ? (
<TableRow>
<TableCell>
<Typography variant="subtitle2" sx={{ color: "pine.600" }}>
Type
</Typography>
</TableCell>
<TableCell align="right">
<Typography variant="subtitle2" sx={{ color: "pine.600" }}>
Allocation
</Typography>
</TableCell>
<TableCell align="right">
<Typography variant="subtitle2" sx={{ color: "pine.600" }}>
Amount
</Typography>
</TableCell>
<TableCell align="right">
<Typography variant="subtitle2" sx={{ color: "pine.600" }}>
Value
</Typography>
</TableCell>
<TableCell />
</TableRow>
) : (
<TableRow>
<TableCell>
<Typography variant="subtitle2" sx={{ color: "pine.600" }}>
Type
</Typography>
</TableCell>
<TableCell align="right">
<Typography variant="subtitle2" sx={{ color: "pine.600" }}>
Amount / Value
</Typography>
</TableCell>
<TableCell />
</TableRow>
)}
</TableHead>
<TableBody>
{rows.map((row, i) => (
<Row
key={row.type}
{...row}
isLastRow={i === rows.length - 1}
progressBarColor={progressBarColours[i]}
/>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
);
};
@@ -2,7 +2,6 @@
import CopyToClipboard from "@/components/copyToClipboard/CopyToClipboard";
import { Box, Button, Card, CardContent, Typography } from "@mui/material";
import Image from "next/image";
import { QRCodeCanvas } from "qrcode.react";
import type React from "react";
import type { FC } from "react";
import profileImagePlaceholder from "../../../public/profileImagePlaceholder.png";
@@ -16,6 +15,7 @@ import {
UpDownPriceIndicator,
} from "../price/UpDownPriceIndicator";
import type { IDynamicProgressBarProps } from "../progressBars/EpochProgressBar";
import { CardQRCode, type ICardQRCodeProps } from "../qrCode/QrCode";
import { StarRating } from "../starRating";
interface ICardTitlePriceProps {
@@ -126,26 +126,6 @@ const CardCopyAddress = (props: ICardCopyAddressProps) => {
);
};
interface ICardQRCodeProps {
url: string;
}
const CardQRCode = (props: ICardQRCodeProps) => {
const { url } = props;
return (
<Box display={"flex"} justifyContent={"flex-start"}>
<Box
padding={1}
border={"1px solid #C3D7D7"}
display={"block"}
width={"unset"}
>
<QRCodeCanvas value={url} />
</Box>
</Box>
);
};
interface ICardRatingsProps {
ratings: Array<{ title: string; numberOfStars: number }>;
}
@@ -6,7 +6,12 @@ import { IconButton } from "@mui/material";
const Favorite = ({ onFavorite }: { onFavorite: () => void }) => {
return (
<IconButton onClick={onFavorite}>
<IconButton
onClick={(e) => {
e.stopPropagation();
onFavorite();
}}
>
<FavoriteBorderIcon sx={{ color: "accent.main" }} />
</IconButton>
);
@@ -14,7 +19,12 @@ const Favorite = ({ onFavorite }: { onFavorite: () => void }) => {
const UnFavorite = ({ onUnfavorite }: { onUnfavorite: () => void }) => {
return (
<IconButton onClick={onUnfavorite}>
<IconButton
onClick={(e) => {
e.stopPropagation();
onUnfavorite();
}}
>
<FavoriteIcon sx={{ color: "accent.main" }} />
</IconButton>
);
@@ -3,7 +3,7 @@ import {
CURRENT_EPOCH_REWARDS,
HARBOURMASTER_API_MIXNODES_STATS,
} from "@/app/api/urls";
import { formatBigNum } from "@/app/utils/formatBigNumbers";
import { formatBigNum } from "@/utils/formatBigNumbers";
import { Box, Stack, Typography } from "@mui/material";
import ExplorerCard from "../cards/ExplorerCard";
import { LineChart } from "../lineChart";
@@ -1,6 +1,6 @@
import type { IPacketsAndStakingData } from "@/app/api";
import { HARBOURMASTER_API_MIXNODES_STATS } from "@/app/api/urls";
import { formatBigNum } from "@/app/utils/formatBigNumbers";
import { formatBigNum } from "@/utils/formatBigNumbers";
import { Box, Stack, Typography } from "@mui/material";
import ExplorerCard from "../cards/ExplorerCard";
import { LineChart } from "../lineChart";
@@ -1,4 +1,4 @@
import { formatBigNum } from "@/app/utils/formatBigNumbers";
import { formatBigNum } from "@/utils/formatBigNumbers";
import { Stack, Typography } from "@mui/material";
import ExplorerCard from "../cards/ExplorerCard";
@@ -1,15 +1,29 @@
import type { CurrencyRates } from "@/app/api/types";
import { NYM_PRICES_API } from "@/app/api/urls";
import { Box, Stack } from "@mui/material";
import ExplorerCard from "../cards/ExplorerCard";
import ExplorerListItem from "../list/ListItem";
import { TitlePrice } from "../price/TitlePrice";
export const TokenomicsCard = async () => {
const titlePrice = {
price: 1.15,
upDownLine: {
percentage: 10,
numberWentUp: true,
const nymPrice = await fetch(NYM_PRICES_API, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
next: { revalidate: 60 },
// refresh event list cache at given interval
});
const nymPriceData: CurrencyRates = await nymPrice.json();
const nymPriceDataFormated = Number(nymPriceData.usd.toFixed(2));
const titlePrice = {
price: nymPriceDataFormated,
// upDownLine: {
// percentage: 10,
// numberWentUp: true,
// },
};
const dataRows = [
{ key: "Market cap", value: "$ 1000000" },
@@ -7,6 +7,7 @@ import {
MaterialReactTable,
useMaterialReactTable,
} from "material-react-table";
import { useRouter } from "next/navigation";
import { useCallback, useMemo } from "react";
import CountryFlag from "../countryFlag/CountryFlag";
import { Favorite, UnFavorite } from "../favorite/Favorite";
@@ -25,6 +26,8 @@ const ColumnHeading = ({
};
const NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
const router = useRouter();
const [favorites, saveFavorites] = useLocalStorage<string[]>(
"nym-node-favorites",
[],
@@ -195,8 +198,11 @@ const NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
border: "none",
},
},
muiTableBodyRowProps: {
hover: false,
muiTableBodyRowProps: ({ row }) => ({
onClick: () => {
router.push(`/nym-node/${row.original.nodeId}`);
},
hover: true,
sx: {
":nth-child(odd)": {
bgcolor: "#F3F7FB !important",
@@ -204,8 +210,9 @@ const NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
":nth-child(even)": {
bgcolor: "white !important",
},
cursor: "pointer",
},
},
}),
});
return <MaterialReactTable table={table} />;
};
@@ -0,0 +1,77 @@
import type {
BondInformation,
NodeDescription,
RewardingDetails,
} from "@/app/api/types";
import { formatBigNum } from "@/utils/formatBigNumbers";
import { Stack, Typography } from "@mui/material";
import { format } from "date-fns";
import ExplorerCard from "../cards/ExplorerCard";
import CopyToClipboard from "../copyToClipboard/CopyToClipboard";
import ExplorerListItem from "../list/ListItem";
interface IBasicInfoCardProps {
bondInfo: BondInformation;
nodeDescription: NodeDescription;
rewardDetails: RewardingDetails;
}
export const BasicInfoCard = (props: IBasicInfoCardProps) => {
const { bondInfo, nodeDescription, rewardDetails } = props;
const timeBonded = format(
new Date(nodeDescription.build_information.build_timestamp),
"dd/MM/yyyy",
);
const selfBond = formatBigNum(Number(rewardDetails.operator) / 1_000_000);
const selfBondFormated = `${selfBond} NYM`;
return (
<ExplorerCard label="Basic info">
<Stack gap={1}>
<ExplorerListItem
divider
label="NYM Address"
value={
<Stack
direction="row"
gap={0.1}
alignItems="center"
justifyContent="space-between"
width="100%"
>
<Typography variant="body4">{bondInfo.owner}</Typography>
<CopyToClipboard text={bondInfo.owner} />
</Stack>
}
/>
<ExplorerListItem
divider
label="Identity Key"
value={
<Stack
direction="row"
gap={0.1}
alignItems="center"
justifyContent="space-between"
width="100%"
>
<Typography variant="body4">
{bondInfo.node.identity_key}
</Typography>
<CopyToClipboard text={bondInfo.node.identity_key} />
</Stack>
}
/>
<ExplorerListItem row divider label="Node bonded" value={timeBonded} />
<ExplorerListItem
row
divider
label="Nr. of stakes"
value={rewardDetails.unique_delegations.toString()}
/>
<ExplorerListItem row label="Self bonded" value={selfBondFormated} />
</Stack>
</ExplorerCard>
);
};
@@ -0,0 +1,35 @@
import type { NodeDescription } from "@/app/api/types";
import ExplorerCard from "../cards/ExplorerCard";
import ExplorerListItem from "../list/ListItem";
interface INodeMetricsCardProps {
nodeDescription: NodeDescription;
nodeId: number;
}
export const NodeMetricsCard = (props: INodeMetricsCardProps) => {
const { nodeDescription, nodeId } = props;
return (
<ExplorerCard label="Nym node metrics" sx={{ height: "100%" }}>
<ExplorerListItem
row
divider
label="Node ID."
value={nodeId.toString()}
/>
<ExplorerListItem
row
divider
label="Host"
value={nodeDescription.host_information.ip_address.toString()}
/>
<ExplorerListItem
row
divider
label="Version"
value={nodeDescription.build_information.build_version}
/>
<ExplorerListItem row label="Active set Prob." value="High" />
</ExplorerCard>
);
};
@@ -0,0 +1,44 @@
"use client";
import type { BondInformation, NodeDescription } from "@/app/api/types";
import { Box, Button, Stack, Typography } from "@mui/material";
import { RandomAvatar } from "react-random-avatars";
import ExplorerCard from "../cards/ExplorerCard";
import CountryFlag from "../countryFlag/CountryFlag";
interface INodeProfileCardProps {
bondInfo: BondInformation;
nodeDescription: NodeDescription;
}
export const NodeProfileCard = (props: INodeProfileCardProps) => {
const { bondInfo, nodeDescription } = props;
return (
<ExplorerCard label="Nym Node" sx={{ height: "100%" }}>
<Stack gap={1}>
<Box display={"flex"} justifyContent={"flex-start"}>
<RandomAvatar name={bondInfo.node.identity_key} size={80} square />
</Box>
<Typography
variant="h3"
mt={3}
sx={{ color: "pine.950", wordWrap: "break-word", maxWidth: "95%" }}
>
{"Moniker"}
</Typography>
<CountryFlag
countryCode={nodeDescription.auxiliary_details.location || ""}
/>
<Typography variant="body4" sx={{ color: "pine.950" }}>
Team of professional validators with best digital solutions. Please
visit our Telegram🔹https://t.me/CryptoSailorsAnn🔹
</Typography>
<Box mt={3}>
<Button variant="contained" size="small">
Stake on node
</Button>
</Box>
</Stack>
</ExplorerCard>
);
};
@@ -0,0 +1,63 @@
import type { RewardingDetails } from "@/app/api/types";
import ExplorerCard from "../cards/ExplorerCard";
import ExplorerListItem from "../list/ListItem";
interface INodeRewardsCardProps {
rewardDetails: RewardingDetails;
}
export const NodeRewardsCard = (props: INodeRewardsCardProps) => {
const { rewardDetails } = props;
const totalRewards = Number(rewardDetails.total_unit_reward) / 1000000;
const totalRewardsFormated = `${totalRewards} NYM`;
const operatorRewards = Number(rewardDetails.operator) / 1000000;
const operatorRewardsFormated = `${operatorRewards} NYM`;
const stakerRewards = Number(rewardDetails.delegates) / 1000000;
const stakerRewardsFormated = `${stakerRewards} NYM`;
const profitMarginPercent =
Number(rewardDetails.cost_params.profit_margin_percent) * 100;
const profitMarginPercentFormated = `${profitMarginPercent}%`;
const operatingCosts =
Number(rewardDetails.cost_params.interval_operating_cost.amount) / 1000000;
const operatingCostsFormated = `${operatingCosts.toString()} NYM`;
return (
<ExplorerCard label="Node rewards(last epoch/hour)" sx={{ height: "100%" }}>
<ExplorerListItem
row
divider
label="Total rew."
value={totalRewardsFormated}
/>
<ExplorerListItem
row
divider
label="Operator rew."
value={operatorRewardsFormated}
/>
<ExplorerListItem
row
divider
label="Staker rew."
value={stakerRewardsFormated}
/>
<ExplorerListItem
row
divider
label="Profit margin rew."
value={profitMarginPercentFormated}
/>
<ExplorerListItem
row
label="Operating cost."
value={operatingCostsFormated}
/>
</ExplorerCard>
);
};
@@ -0,0 +1,73 @@
import type { NodeDescription } from "@/app/api/types";
import { Chip, Stack } from "@mui/material";
import ExplorerCard from "../cards/ExplorerCard";
import ExplorerListItem from "../list/ListItem";
import StarRating from "../starRating/StarRating";
interface IQualityIndicatorsCardProps {
nodeDescription: NodeDescription;
}
type DelcaredRoleKey = keyof NodeDescription["declared_role"];
type RoleString = "Entry Node" | "Exit IPR Node" | "Exit NR Node" | "Mix Node";
const roleMapping: Record<DelcaredRoleKey, RoleString> = {
entry: "Entry Node",
exit_ipr: "Exit IPR Node",
exit_nr: "Exit NR Node",
mixnode: "Mix Node",
};
function getNodeRoles(
declaredRoles: NodeDescription["declared_role"],
): RoleString[] {
const activeRoles = Object.entries(declaredRoles)
.filter(([, isActive]) => isActive)
.map(([role]) => roleMapping[role as DelcaredRoleKey]);
return activeRoles;
}
export const QualityIndicatorsCard = (props: IQualityIndicatorsCardProps) => {
const { nodeDescription } = props;
const nodeRoles = getNodeRoles(nodeDescription.declared_role);
const NodeRoles = nodeRoles.map((role) => (
<Stack key={role} direction="row" gap={1}>
<Chip key={role} label={role} size="small" />
</Stack>
));
return (
<ExplorerCard label="Quality indicatiors" sx={{ height: "100%" }}>
<ExplorerListItem
row
divider
label="Role"
value={
<Stack direction="row" gap={1}>
{NodeRoles}
</Stack>
}
/>
<ExplorerListItem
row
divider
label="Quality of service"
value={<StarRating value={5} />}
/>
<ExplorerListItem
row
divider
label="Config score"
value={<StarRating value={4} />}
/>
<ExplorerListItem
row
divider
label="Probe score"
value={<StarRating value={5} />}
/>
</ExplorerCard>
);
};
@@ -8,7 +8,7 @@ import {
interface ITitlePriceProps {
price: number;
upDownLine: IUpDownPriceIndicatorProps;
upDownLine?: IUpDownPriceIndicatorProps;
}
export const TitlePrice = (props: ITitlePriceProps): React.ReactNode => {
const { price, upDownLine } = props;
@@ -25,7 +25,7 @@ export const TitlePrice = (props: ITitlePriceProps): React.ReactNode => {
${price}
</Typography>
</Box>
<UpDownPriceIndicator {...upDownLine} />
{upDownLine && <UpDownPriceIndicator {...upDownLine} />}
</Box>
);
};
@@ -1,26 +1,60 @@
"use client";
import { Stack } from "@mui/material";
import Box from "@mui/material/Box";
import { addHours, differenceInMinutes, format } from "date-fns";
import { addHours, format } from "date-fns";
import React from "react";
import ListItem from "../list/ListItem";
import ProgressBar from "../progressBar/ProgressBar";
import ProgressBar from "./ProgressBar";
export interface IDynamicProgressBarProps {
start: string; // Start timestamp as ISO 8601 string
showEpoch: boolean;
}
const EpochProgressBar = async ({
start,
showEpoch,
}: IDynamicProgressBarProps) => {
const EpochProgressBar = ({ start, showEpoch }: IDynamicProgressBarProps) => {
const [progress, setProgress] = React.useState(0);
const startDate = new Date(start);
const endDate = addHours(new Date(start), 1);
const startTime = format(startDate, "HH:mm dd-MM-yyyy");
const endTime = format(endDate, "HH:mm dd-MM-yyyy");
const totalEpochTime = differenceInMinutes(endDate, startDate);
const progress =
(differenceInMinutes(new Date(), startDate) / totalEpochTime) * 100;
React.useEffect(() => {
// Parse the start timestamp
const startTime = new Date(start).getTime();
const endTime = startTime + 60 * 60 * 1000; // Add 1 hour to the start time
// Validate start timestamp
if (Number.isNaN(startTime)) {
console.error("Invalid start timestamp:", { start });
return;
}
// Function to calculate progress
const calculateProgress = () => {
const currentTime = Date.now();
if (currentTime < startTime) {
return 0;
}
if (currentTime >= endTime) {
return 100;
}
const elapsed = currentTime - startTime;
const total = endTime - startTime;
return (elapsed / total) * 100;
};
// Set initial progress and start timer
setProgress(calculateProgress());
const timer = setInterval(() => {
setProgress(calculateProgress());
}, 60000); // Update every minute (60000 milliseconds)
// Cleanup on unmount
return () => {
clearInterval(timer);
};
}, [start]);
return (
<Box sx={{ width: "100%" }}>
@@ -0,0 +1,22 @@
import { Box } from "@mui/material";
import { QRCodeCanvas } from "qrcode.react";
export interface ICardQRCodeProps {
url: string;
}
export const CardQRCode = (props: ICardQRCodeProps) => {
const { url } = props;
return (
<Box
border={"1px solid #C3D7D7"}
display={"flex"}
justifyContent={"center"}
alignItems={"center"}
width={144}
height={144}
>
<QRCodeCanvas value={url} />
</Box>
);
};
-1
View File
@@ -12,7 +12,6 @@
"sdk/typescript/packages/validator-client",
"ts-packages/*",
"nym-wallet",
"explorer",
"explorer-nextjs",
"types",
"clients/validator"
File diff suppressed because it is too large Load Diff
+183 -935
View File
File diff suppressed because it is too large Load Diff