Explorer V2 (#5548)
* remove pnpm lock file (should only be using yarn) * Add lefthook configuration for pre-commit checks * Add explorer-v2 to package.json dependencies * add explorer v2 * update explorer v2 package name * + basepath + redirect to basepath + blog icons refactor + icons refactor * Add Getting Started instructions to README * fix noise graph bug and line graph UI * Delete unused translations, clean up console logs * / test image url * update yarn.lock --------- Co-authored-by: RadekSabacky <radek@nymtech.net> Co-authored-by: windy-ux <75579979+windy-ux@users.noreply.github.com> Co-authored-by: Yana <iana.matrosova@gmail.com> Co-authored-by: Mark Sinclair <mmsinclair@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,396 @@
|
||||
"use client";
|
||||
import { useChain } from "@cosmos-kit/react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Stack,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useLocalStorage } from "@uidotdev/usehooks";
|
||||
import {
|
||||
type MRT_ColumnDef,
|
||||
MaterialReactTable,
|
||||
useMaterialReactTable,
|
||||
} from "material-react-table";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { COSMOS_KIT_USE_CHAIN } from "../../config";
|
||||
import { useNymClient } from "../../hooks/useNymClient";
|
||||
import CountryFlag from "../countryFlag/CountryFlag";
|
||||
import { Favorite } from "../favorite/Favorite";
|
||||
import Loading from "../loading";
|
||||
// import Loading from "../loading";
|
||||
import InfoModal, { type InfoModalProps } from "../modal/InfoModal";
|
||||
import StakeModal from "../staking/StakeModal";
|
||||
import { fee } from "../staking/schemas";
|
||||
import ConnectWallet from "../wallet/ConnectWallet";
|
||||
import type { MappedNymNode, MappedNymNodes } from "./NodeTableWithAction";
|
||||
|
||||
const ColumnHeading = ({
|
||||
children,
|
||||
}: {
|
||||
children: string | React.ReactNode;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: isMobile ? "80px" : "unset",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "baseline",
|
||||
p: 0,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
py: 2,
|
||||
textAlign: "center",
|
||||
whiteSpace: isMobile ? "normal" : "unset", // Ensure text can wrap
|
||||
wordWrap: isMobile ? "break-word" : "unset", // Break long words
|
||||
overflowWrap: isMobile ? "break-word" : "unset", // Ensure text breaks inside the cell
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
variant={isMobile ? "caption" : "h5"}
|
||||
>
|
||||
{children}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
|
||||
const router = useRouter();
|
||||
const { nymClient } = useNymClient();
|
||||
const queryClient = useQueryClient();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
|
||||
const [infoModalProps, setInfoModalProps] = useState<InfoModalProps>({
|
||||
open: false,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedNodeForStaking, setSelectedNodeForStaking] = useState<{
|
||||
nodeId: number;
|
||||
identityKey: string;
|
||||
}>();
|
||||
const [favorites] = useLocalStorage<string[]>("nym-node-favorites", []);
|
||||
const { isWalletConnected } = useChain(COSMOS_KIT_USE_CHAIN);
|
||||
|
||||
const handleRefetch = useCallback(async () => {
|
||||
await queryClient.invalidateQueries();
|
||||
}, [queryClient]);
|
||||
|
||||
const handleStakeOnNode = useCallback(
|
||||
async ({ nodeId, amount }: { nodeId: number; amount: string }) => {
|
||||
const amountToDelegate = (Number(amount) * 1_000_000).toString();
|
||||
const uNymFunds = [{ amount: amountToDelegate, denom: "unym" }];
|
||||
|
||||
setIsLoading(true);
|
||||
setSelectedNodeForStaking(undefined);
|
||||
try {
|
||||
const tx = await nymClient?.delegate(
|
||||
{ nodeId },
|
||||
fee,
|
||||
"Delegation from Nym Explorer V2",
|
||||
uNymFunds,
|
||||
);
|
||||
setSelectedNodeForStaking(undefined);
|
||||
setInfoModalProps({
|
||||
open: true,
|
||||
title: "Success",
|
||||
message: "This operation can take up to one hour to process",
|
||||
tx: tx?.transactionHash,
|
||||
|
||||
onClose: async () => {
|
||||
await handleRefetch();
|
||||
setInfoModalProps({ open: false });
|
||||
},
|
||||
});
|
||||
await handleRefetch();
|
||||
} catch (e) {
|
||||
const errorMessage =
|
||||
e instanceof Error ? e.message : "An error occurred while staking";
|
||||
setInfoModalProps({
|
||||
open: true,
|
||||
title: "Error",
|
||||
message: errorMessage,
|
||||
onClose: () => {
|
||||
setInfoModalProps({ open: false });
|
||||
},
|
||||
});
|
||||
}
|
||||
setIsLoading(false);
|
||||
},
|
||||
[nymClient, handleRefetch],
|
||||
);
|
||||
|
||||
const handleOnSelectStake = useCallback(
|
||||
(node: MappedNymNode) => {
|
||||
if (!isWalletConnected) {
|
||||
setInfoModalProps({
|
||||
open: true,
|
||||
title: "Connect Wallet",
|
||||
message: "Connect your wallet to stake",
|
||||
Action: (
|
||||
<ConnectWallet
|
||||
fullWidth
|
||||
onClick={() =>
|
||||
setInfoModalProps({
|
||||
open: false,
|
||||
})
|
||||
}
|
||||
/>
|
||||
),
|
||||
onClose: () => setInfoModalProps({ open: false }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSelectedNodeForStaking({
|
||||
nodeId: node.nodeId,
|
||||
identityKey: node.identity_key,
|
||||
});
|
||||
},
|
||||
[isWalletConnected],
|
||||
);
|
||||
|
||||
const columns: MRT_ColumnDef<MappedNymNode>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "Favorite",
|
||||
enableColumnFilter: false,
|
||||
header: "Favorite",
|
||||
accessorKey: "Favorite",
|
||||
size: 50,
|
||||
|
||||
Header: (
|
||||
<Stack direction="row" alignItems="center">
|
||||
<ColumnHeading>Fav</ColumnHeading>
|
||||
</Stack>
|
||||
),
|
||||
sortingFn: (a, b) => {
|
||||
const aIsFavorite = favorites.includes(a.original.owner);
|
||||
const bIsFavorite = favorites.includes(b.original.owner);
|
||||
|
||||
if (aIsFavorite && !bIsFavorite) {
|
||||
return -1;
|
||||
}
|
||||
if (!aIsFavorite && bIsFavorite) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
Cell: ({ row }) => <Favorite address={row.original.owner} />,
|
||||
},
|
||||
|
||||
{
|
||||
id: "name",
|
||||
header: "",
|
||||
Header: <ColumnHeading>Name</ColumnHeading>,
|
||||
accessorKey: "name",
|
||||
Cell: ({ row }) => (
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="body4">{row.original.name || "-"}</Typography>
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "id",
|
||||
header: "",
|
||||
Header: <ColumnHeading>Node ID</ColumnHeading>,
|
||||
accessorKey: "nodeId",
|
||||
size: 90,
|
||||
Cell: ({ row }) => (
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="body4">{row.original.nodeId}</Typography>
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "identity_key",
|
||||
header: "",
|
||||
Header: <ColumnHeading>Identity Key</ColumnHeading>,
|
||||
accessorKey: "identity_key",
|
||||
Cell: ({ row }) => (
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="body5">{row.original.identity_key}</Typography>
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "qos",
|
||||
header: "Qlt of Service",
|
||||
align: "center",
|
||||
accessorKey: "qualityOfService",
|
||||
size: 100,
|
||||
Header: <ColumnHeading>Qlt of Service</ColumnHeading>,
|
||||
Cell: ({ row }) => (
|
||||
<Typography variant="body4">
|
||||
{row.original.qualityOfService.toFixed()}%
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "location",
|
||||
header: "Location",
|
||||
accessorKey: "countryName",
|
||||
size: 140,
|
||||
Header: <ColumnHeading>Location</ColumnHeading>,
|
||||
Cell: ({ row }) =>
|
||||
row.original.countryCode && row.original.countryName ? (
|
||||
<Box width="100%">
|
||||
<CountryFlag
|
||||
countryCode={row.original.countryCode || ""}
|
||||
countryName={row.original.countryName || ""}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
"-"
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "stakeSaturation",
|
||||
header: "Stake saturation",
|
||||
accessorKey: "stakeSaturation",
|
||||
size: 120,
|
||||
|
||||
Header: <ColumnHeading>Saturation</ColumnHeading>,
|
||||
Cell: ({ row }) => (
|
||||
<Typography variant="body4">
|
||||
{row.original.stakeSaturation}%
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "profitMarginPercentage",
|
||||
header: "Profit margin",
|
||||
accessorKey: "profitMarginPercentage",
|
||||
Header: <ColumnHeading>Profit margin</ColumnHeading>,
|
||||
Cell: ({ row }) => (
|
||||
<Typography variant="body4">
|
||||
{row.original.profitMarginPercentage}%
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "Action",
|
||||
header: "Action",
|
||||
accessorKey: "Action",
|
||||
size: 120,
|
||||
|
||||
Header: <ColumnHeading>Action</ColumnHeading>,
|
||||
hidden: !isWalletConnected,
|
||||
enableColumnFilter: false,
|
||||
Cell: ({ row }) => (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleOnSelectStake(row.original);
|
||||
}}
|
||||
>
|
||||
Stake
|
||||
</Button>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
],
|
||||
[isWalletConnected, handleOnSelectStake, favorites],
|
||||
);
|
||||
const table = useMaterialReactTable({
|
||||
columns,
|
||||
data: nodes,
|
||||
enableRowSelection: false,
|
||||
enableColumnOrdering: false,
|
||||
enableColumnActions: false,
|
||||
enableFullScreenToggle: false,
|
||||
enableHiding: false,
|
||||
enableDensityToggle: false,
|
||||
enableFilterMatchHighlighting: true,
|
||||
paginationDisplayMode: "pages",
|
||||
muiPaginationProps: {
|
||||
showRowsPerPage: false,
|
||||
SelectProps: {
|
||||
sx: {
|
||||
fontFamily: "labGrotesqueMono",
|
||||
fontSize: "14px",
|
||||
},
|
||||
},
|
||||
color: "primary",
|
||||
shape: "circular",
|
||||
},
|
||||
initialState: {
|
||||
columnPinning: isMobile ? {} : { right: ["Action"] }, // No pinning on mobile
|
||||
},
|
||||
muiColumnActionsButtonProps: {
|
||||
sx: {
|
||||
color: "red",
|
||||
},
|
||||
size: "small",
|
||||
},
|
||||
muiTablePaperProps: {
|
||||
elevation: 0,
|
||||
},
|
||||
muiTableHeadRowProps: {
|
||||
sx: {
|
||||
bgcolor: "background.paper",
|
||||
},
|
||||
},
|
||||
muiTableHeadCellProps: {
|
||||
sx: {
|
||||
alignItems: "center",
|
||||
paddingRight: 0,
|
||||
},
|
||||
},
|
||||
|
||||
muiTableBodyCellProps: {
|
||||
sx: {
|
||||
border: "none",
|
||||
whiteSpace: "unset", // Allow text wrapping in body cells
|
||||
wordBreak: "break-word", // Ensure long text breaks correctly
|
||||
paddingRight: 0,
|
||||
},
|
||||
},
|
||||
muiTableBodyRowProps: ({ row }) => ({
|
||||
onClick: () => {
|
||||
router.push(`/nym-node/${row.original.nodeId}`);
|
||||
},
|
||||
hover: true,
|
||||
sx: {
|
||||
":nth-child(odd)": {
|
||||
bgcolor: "#F3F7FB !important",
|
||||
},
|
||||
":nth-child(even)": {
|
||||
bgcolor: "white !important",
|
||||
},
|
||||
cursor: "pointer",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading && <Loading />}
|
||||
|
||||
<StakeModal
|
||||
nodeId={selectedNodeForStaking?.nodeId}
|
||||
identityKey={selectedNodeForStaking?.identityKey}
|
||||
onStake={handleStakeOnNode}
|
||||
onClose={() => setSelectedNodeForStaking(undefined)}
|
||||
/>
|
||||
|
||||
<InfoModal {...infoModalProps} />
|
||||
|
||||
<MaterialReactTable table={table} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NodeTable;
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, Skeleton, Stack, Typography } from "@mui/material";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
import { fetchEpochRewards, fetchObservatoryNodes } from "../../app/api";
|
||||
import type { ExplorerData, IObservatoryNode } from "../../app/api/types";
|
||||
import { countryName } from "../../utils/countryName";
|
||||
import NodeTable from "./NodeTable";
|
||||
|
||||
// Utility function to calculate node saturation point
|
||||
function getNodeSaturationPoint(
|
||||
totalStake: number,
|
||||
stakeSaturationPoint: string,
|
||||
): number {
|
||||
const saturation = Number.parseFloat(stakeSaturationPoint);
|
||||
|
||||
if (Number.isNaN(saturation) || saturation <= 0) {
|
||||
throw new Error("Invalid stake saturation point provided");
|
||||
}
|
||||
|
||||
const ratio = (totalStake / saturation) * 100;
|
||||
|
||||
return Number(ratio.toFixed());
|
||||
}
|
||||
|
||||
// Map nodes with rewards data
|
||||
const mappedNymNodes = (
|
||||
nodes: IObservatoryNode[],
|
||||
epochRewardsData: ExplorerData["currentEpochRewardsData"],
|
||||
) =>
|
||||
nodes.map((node) => {
|
||||
const nodeSaturationPoint = getNodeSaturationPoint(
|
||||
node.total_stake,
|
||||
epochRewardsData.interval.stake_saturation_point,
|
||||
);
|
||||
|
||||
const cleanMoniker = DOMPurify.sanitize(
|
||||
node.self_description.moniker,
|
||||
).replace(/&/g, "&");
|
||||
|
||||
return {
|
||||
name: cleanMoniker,
|
||||
nodeId: node.node_id,
|
||||
identity_key: node.identity_key,
|
||||
countryCode: node.description.auxiliary_details.location || null,
|
||||
countryName:
|
||||
countryName(node.description.auxiliary_details.location) || null,
|
||||
profitMarginPercentage:
|
||||
+node.rewarding_details.cost_params.profit_margin_percent * 100,
|
||||
owner: node.bonding_address,
|
||||
stakeSaturation: nodeSaturationPoint,
|
||||
qualityOfService: +node.uptime * 100,
|
||||
};
|
||||
});
|
||||
|
||||
export type MappedNymNodes = ReturnType<typeof mappedNymNodes>;
|
||||
export type MappedNymNode = MappedNymNodes[0];
|
||||
|
||||
const NodeTableWithAction = () => {
|
||||
// Use React Query to fetch epoch rewards
|
||||
const {
|
||||
data: epochRewardsData,
|
||||
isLoading: isEpochLoading,
|
||||
isError: isEpochError,
|
||||
} = useQuery({
|
||||
queryKey: ["epochRewards"],
|
||||
queryFn: fetchEpochRewards,
|
||||
});
|
||||
|
||||
// Use React Query to fetch Nym nodes
|
||||
const {
|
||||
data: nymNodes = [],
|
||||
isLoading: isNodesLoading,
|
||||
isError: isNodesError,
|
||||
} = useQuery({
|
||||
queryKey: ["nymNodes"],
|
||||
queryFn: fetchObservatoryNodes,
|
||||
});
|
||||
|
||||
// Handle loading state
|
||||
if (isEpochLoading || isNodesLoading) {
|
||||
return (
|
||||
<Card sx={{ height: "100%", mt: 5 }}>
|
||||
<CardContent>
|
||||
<Skeleton variant="text" height={100} />
|
||||
<Skeleton variant="text" height={100} />
|
||||
<Skeleton variant="text" height={100} />
|
||||
<Skeleton variant="text" height={100} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle error state
|
||||
if (isEpochError || isNodesError) {
|
||||
return (
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Typography variant="h5" sx={{ color: "pine.600", letterSpacing: 0.7 }}>
|
||||
Error loading data. Please try again later.
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// Map nodes with rewards data
|
||||
|
||||
if (!epochRewardsData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = mappedNymNodes(nymNodes || [], epochRewardsData);
|
||||
|
||||
return <NodeTable nodes={data} />;
|
||||
};
|
||||
|
||||
export default NodeTableWithAction;
|
||||
Reference in New Issue
Block a user