Files
nym/explorer-v2/src/components/nodeTable/NodeTableWithAction.tsx
T
2025-09-17 20:42:08 +03:00

300 lines
9.3 KiB
TypeScript

"use client";
import { Card, CardContent, Skeleton, Stack, Typography } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import DOMPurify from "isomorphic-dompurify";
import { useEffect, useState } from "react";
import {
fetchEpochRewards,
fetchNSApiNodes,
getRecommendedNodes,
} from "../../app/api";
import type { ExplorerData, NS_NODE } from "../../app/api/types";
import { countryName } from "../../utils/countryName";
import AdvancedFilters from "./AdvancedFilters";
import NodeTable from "./NodeTable";
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());
}
const mappedNSApiNodes = (
nodes: NS_NODE[],
epochRewardsData: ExplorerData["currentEpochRewardsData"],
) =>
nodes
.map((node) => {
const nodeSaturationPoint = getNodeSaturationPoint(
+node.total_stake,
epochRewardsData.interval.stake_saturation_point,
);
const cleanMoniker = DOMPurify.sanitize(node.description.moniker).replace(
/&amp;/g,
"&",
);
const selfBondFormatted = node.original_pledge
? Number(node.original_pledge) / 1_000_000
: 0;
const operatingCostsFormatted = node.rewarding_details
? Number(
node.rewarding_details.cost_params.interval_operating_cost.amount,
) / 1_000_000
: 0;
return {
name: cleanMoniker,
nodeId: node.node_id,
identity_key: node.identity_key,
countryCode: node.geoip?.country || null,
countryName: countryName(node.geoip?.country || null) || null,
selfBond: selfBondFormatted,
operatingCosts: operatingCostsFormatted,
profitMarginPercentage: node.rewarding_details
? +node.rewarding_details.cost_params.profit_margin_percent * 100
: 0,
owner: node.bonding_address,
stakeSaturation: nodeSaturationPoint,
qualityOfService: +node.uptime * 100,
mixnode: node.self_description?.declared_role.mixnode === true,
gateway:
node.self_description?.declared_role.entry === true ||
node.self_description?.declared_role.exit_ipr === true ||
node.self_description?.declared_role.exit_nr === true,
};
})
.sort((a, b) => {
if (!a.countryName && !b.countryName) return 0;
if (!a.countryName) return 1;
if (!b.countryName) return -1;
return a.countryName.localeCompare(b.countryName);
});
export type MappedNymNodes = ReturnType<typeof mappedNSApiNodes>;
export type MappedNymNode = MappedNymNodes[0];
const NodeTableWithAction = () => {
// All hooks at the top!
const [activeFilter, setActiveFilter] = useState<
"all" | "mixnodes" | "gateways" | "recommended"
>(() => {
const stored = sessionStorage.getItem("nodeTableActiveFilter");
return (
(stored as "all" | "mixnodes" | "gateways" | "recommended") ||
"recommended"
);
});
const [uptime, setUptime] = useState<[number, number]>(() => {
const stored = sessionStorage.getItem("nodeTableUptime");
return stored ? JSON.parse(stored) : [0, 100];
});
const [saturation, setSaturation] = useState<[number, number]>([0, 100]);
const [profitMargin, setProfitMargin] = useState<[number, number]>(() => {
const stored = sessionStorage.getItem("nodeTableProfitMargin");
return stored ? JSON.parse(stored) : [0, 100];
});
const [advancedOpen, setAdvancedOpen] = useState(() => {
const stored = sessionStorage.getItem("nodeTableAdvancedOpen");
return stored ? JSON.parse(stored) : false;
});
// Wrapper functions to handle filter changes and sessionStorage
const handleActiveFilterChange = (
newFilter: "all" | "mixnodes" | "gateways" | "recommended"
) => {
setActiveFilter(newFilter);
sessionStorage.setItem("nodeTableActiveFilter", newFilter);
};
const handleUptimeChange = (newUptime: [number, number]) => {
setUptime(newUptime);
sessionStorage.setItem("nodeTableUptime", JSON.stringify(newUptime));
};
const handleSaturationChange = (newSaturation: [number, number]) => {
setSaturation(newSaturation);
sessionStorage.setItem(
"nodeTableSaturation",
JSON.stringify(newSaturation)
);
};
const handleProfitMarginChange = (newProfitMargin: [number, number]) => {
setProfitMargin(newProfitMargin);
sessionStorage.setItem(
"nodeTableProfitMargin",
JSON.stringify(newProfitMargin)
);
};
const handleAdvancedOpenChange = (newAdvancedOpen: boolean) => {
setAdvancedOpen(newAdvancedOpen);
sessionStorage.setItem(
"nodeTableAdvancedOpen",
JSON.stringify(newAdvancedOpen)
);
};
// Use React Query to fetch epoch rewards
const {
data: epochRewardsData,
isLoading: isEpochLoading,
isError: isEpochError,
} = useQuery({
queryKey: ["epochRewards"],
queryFn: fetchEpochRewards,
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
});
// Use React Query to fetch Nym nodes
const {
data: nsApiNodes = [],
isLoading: isNSApiNodesLoading,
isError: isNSApiNodesError,
} = useQuery({
queryKey: ["nsApiNodes"],
queryFn: fetchNSApiNodes,
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
});
const recommendedIds = getRecommendedNodes(nsApiNodes);
// Map nodes with rewards data
const nsApiNodesData = epochRewardsData
? mappedNSApiNodes(nsApiNodes || [], epochRewardsData)
: [];
// Calculate max saturation from all nodes
const maxSaturation = Math.max(
100,
...nsApiNodesData.map((n) => n.stakeSaturation || 0)
);
// Initialize saturation from sessionStorage or set to maxSaturation when data is loaded
useEffect(() => {
const stored = sessionStorage.getItem("nodeTableSaturation");
if (stored) {
setSaturation(JSON.parse(stored));
} else if (nsApiNodesData.length > 0) {
setSaturation([0, maxSaturation]);
}
}, [maxSaturation, nsApiNodesData.length]);
// Calculate node counts for each type
const nodeCounts = {
all: nsApiNodesData.length,
mixnodes: nsApiNodesData.filter((node) => node.mixnode).length,
gateways: nsApiNodesData.filter((node) => node.gateway).length,
};
// Handle loading state
if (isEpochLoading || isNSApiNodesLoading) {
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 || isNSApiNodesError) {
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>
);
}
if (!epochRewardsData) {
return null;
}
// Step 1: Filter nodes by type
const typeFilteredNodes = nsApiNodesData.filter((node) => {
switch (activeFilter) {
case "mixnodes":
return node.mixnode;
case "gateways":
return node.gateway;
case "recommended":
return recommendedIds.includes(node.nodeId);
default:
return true;
}
});
// Step 2: Apply advanced filters if open (but only if sliders moved from defaults)
const isDefault = {
uptime: uptime[0] === 0 && uptime[1] === 100,
saturation: saturation[0] === 0 && saturation[1] === maxSaturation,
profitMargin: profitMargin[0] === 0 && profitMargin[1] === 100,
};
const filteredNodes = advancedOpen
? typeFilteredNodes.filter((node) => {
const uptimeMatch =
isDefault.uptime ||
(node.qualityOfService >= uptime[0] &&
node.qualityOfService <= uptime[1]);
const saturationMatch =
isDefault.saturation ||
(node.stakeSaturation >= saturation[0] &&
node.stakeSaturation <= saturation[1]);
const profitMarginMatch =
isDefault.profitMargin ||
(node.profitMarginPercentage >= profitMargin[0] &&
node.profitMarginPercentage <= profitMargin[1]);
return uptimeMatch && saturationMatch && profitMarginMatch;
})
: typeFilteredNodes;
const recommendedCount = recommendedIds.length;
return (
<Stack spacing={3}>
<AdvancedFilters
open={advancedOpen}
setOpen={handleAdvancedOpenChange}
uptime={uptime}
setUptime={handleUptimeChange}
saturation={saturation}
setSaturation={handleSaturationChange}
profitMargin={profitMargin}
setProfitMargin={handleProfitMarginChange}
maxSaturation={maxSaturation}
activeFilter={activeFilter}
setActiveFilter={handleActiveFilterChange}
nodeCounts={nodeCounts}
recommendedCount={recommendedCount}
/>
<NodeTable nodes={filteredNodes} />
</Stack>
);
};
export default NodeTableWithAction;