Merge pull request #6049 from nymtech/refactor/recommended-nodes
Refactor/recommended nodes
This commit is contained in:
@@ -7,11 +7,9 @@ import { Wrapper } from "@/components/wrapper";
|
||||
import { Box, Stack } from "@mui/material";
|
||||
// import Grid from "@mui/material/Grid2";
|
||||
|
||||
import { RECOMMENDED_NODES } from "@/app/constants"; // ⬅ dynamic Promise<number[]>
|
||||
|
||||
export default async function ExplorerPage() {
|
||||
// Resolve once on the server and pass IDs to client components
|
||||
const recommendedIds = await RECOMMENDED_NODES;
|
||||
|
||||
return (
|
||||
<ContentLayout>
|
||||
@@ -21,7 +19,7 @@ export default async function ExplorerPage() {
|
||||
<NodeAndAddressSearch />
|
||||
</Stack>
|
||||
<Box sx={{ mt: 5 }}>
|
||||
<NodeTableWithAction recommendedIds={recommendedIds} />
|
||||
<NodeTableWithAction />
|
||||
</Box>
|
||||
{/* <Grid container columnSpacing={5} rowSpacing={5} mt={10}>
|
||||
<Grid size={12}>
|
||||
|
||||
@@ -16,10 +16,10 @@ import type {
|
||||
import {
|
||||
CURRENT_EPOCH,
|
||||
CURRENT_EPOCH_REWARDS,
|
||||
DATA_OBSERVATORY_BALANCES_URL,
|
||||
SPECTREDAO_BALANCES_URL,
|
||||
NS_API_NODES,
|
||||
NYM_ACCOUNT_ADDRESS,
|
||||
NYM_PRICES_API,
|
||||
SPECTREDAO_NYM_PRICES_API,
|
||||
OBSERVATORY_GATEWAYS_URL,
|
||||
} from "./urls";
|
||||
|
||||
@@ -44,7 +44,7 @@ export const fetchEpochRewards = async (): Promise<
|
||||
|
||||
// Fetch gateway status based on identity key
|
||||
export const fetchGatewayStatus = async (
|
||||
identityKey: string,
|
||||
identityKey: string
|
||||
): Promise<GatewayStatus | null> => {
|
||||
const response = await fetch(`${OBSERVATORY_GATEWAYS_URL}/${identityKey}`);
|
||||
|
||||
@@ -56,7 +56,7 @@ export const fetchGatewayStatus = async (
|
||||
};
|
||||
|
||||
export const fetchNodeDelegations = async (
|
||||
id: number,
|
||||
id: number
|
||||
): Promise<NodeRewardDetails[]> => {
|
||||
const response = await fetch(`${NS_API_NODES}/${id}/delegations`, {
|
||||
headers: {
|
||||
@@ -88,7 +88,7 @@ export const fetchCurrentEpoch = async () => {
|
||||
const data: CurrentEpochData = await response.json();
|
||||
const epochEndTime = addSeconds(
|
||||
new Date(data.current_epoch_start),
|
||||
data.epoch_length.secs,
|
||||
data.epoch_length.secs
|
||||
).toISOString();
|
||||
|
||||
return { ...data, current_epoch_end: epochEndTime };
|
||||
@@ -96,7 +96,7 @@ export const fetchCurrentEpoch = async () => {
|
||||
|
||||
// Fetch balances based on the address
|
||||
export const fetchBalances = async (address: string): Promise<number> => {
|
||||
const response = await fetch(`${DATA_OBSERVATORY_BALANCES_URL}/${address}`, {
|
||||
const response = await fetch(`${SPECTREDAO_BALANCES_URL}/${address}`, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
@@ -117,10 +117,8 @@ export const fetchBalances = async (address: string): Promise<number> => {
|
||||
};
|
||||
|
||||
// Fetch function to get total staker rewards
|
||||
export const fetchTotalStakerRewards = async (
|
||||
address: string,
|
||||
): Promise<number> => {
|
||||
const response = await fetch(`${DATA_OBSERVATORY_BALANCES_URL}/${address}`, {
|
||||
export const fetchTotalStakerRewards = async (address: string): Promise<number> => {
|
||||
const response = await fetch(`${SPECTREDAO_BALANCES_URL}/${address}`, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
@@ -139,7 +137,7 @@ export const fetchTotalStakerRewards = async (
|
||||
|
||||
// Fetch function to get the original stake
|
||||
export const fetchOriginalStake = async (address: string): Promise<number> => {
|
||||
const response = await fetch(`${DATA_OBSERVATORY_BALANCES_URL}/${address}`, {
|
||||
const response = await fetch(`${SPECTREDAO_BALANCES_URL}/${address}`, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
@@ -159,7 +157,7 @@ export const fetchOriginalStake = async (address: string): Promise<number> => {
|
||||
export const fetchNoise = async (): Promise<IPacketsAndStakingData[]> => {
|
||||
if (!process.env.NEXT_PUBLIC_NS_API_MIXNODES_STATS) {
|
||||
throw new Error(
|
||||
"NEXT_PUBLIC_NS_API_MIXNODES_STATS environment variable is not defined",
|
||||
"NEXT_PUBLIC_NS_API_MIXNODES_STATS environment variable is not defined"
|
||||
);
|
||||
}
|
||||
const response = await fetch(process.env.NEXT_PUBLIC_NS_API_MIXNODES_STATS, {
|
||||
@@ -175,7 +173,7 @@ export const fetchNoise = async (): Promise<IPacketsAndStakingData[]> => {
|
||||
|
||||
// Fetch Account Balance
|
||||
export const fetchAccountBalance = async (
|
||||
address: string,
|
||||
address: string
|
||||
): Promise<IAccountBalancesInfo> => {
|
||||
const res = await fetch(`${NYM_ACCOUNT_ADDRESS}/${address}`, {
|
||||
headers: {
|
||||
@@ -193,7 +191,7 @@ export const fetchAccountBalance = async (
|
||||
|
||||
// 🔹 Fetch NYM Price
|
||||
export const fetchNymPrice = async (): Promise<NymTokenomics> => {
|
||||
const res = await fetch(NYM_PRICES_API, {
|
||||
const res = await fetch(SPECTREDAO_NYM_PRICES_API, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
@@ -306,3 +304,89 @@ export const fetchWorldMapCountries = async (): Promise<{
|
||||
totalServers: nodes.length,
|
||||
};
|
||||
};
|
||||
|
||||
export const getRecommendedNodes = (nodes: NS_NODE[]): number[] => {
|
||||
function toNumber(x: unknown, fallback = 0): number {
|
||||
const n =
|
||||
typeof x === "string" || typeof x === "number" ? Number(x) : Number.NaN;
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
const MIN_STAKE = 50_000_000_000; // 50k NYM (uNYM)
|
||||
const MAX_STAKE = 150_000_000_000; // 150k NYM (uNYM)
|
||||
const MAX_PM = 0.2; // ≤ 20%
|
||||
const MIN_UPTIME = 0.95; // ≥ 95%
|
||||
|
||||
// require gateway roles: entry + exit_ipr + exit_nr; NOT a mixnode
|
||||
function hasRequiredRoles(n: NS_NODE): boolean {
|
||||
const r = n.self_description?.declared_role;
|
||||
if (!r) return false;
|
||||
const mixnodeFalse = r.mixnode === false || r.mixnode === undefined;
|
||||
return mixnodeFalse && !!r.entry && !!r.exit_ipr && !!r.exit_nr;
|
||||
}
|
||||
|
||||
function hasGoodPM(n: NS_NODE): boolean {
|
||||
const pm = toNumber(
|
||||
n.rewarding_details?.cost_params?.profit_margin_percent,
|
||||
Number.NaN
|
||||
);
|
||||
return !Number.isNaN(pm) && pm <= MAX_PM;
|
||||
}
|
||||
|
||||
function stakeInRange(n: NS_NODE): boolean {
|
||||
const s = toNumber(n.total_stake, 0);
|
||||
return s > MIN_STAKE && s < MAX_STAKE;
|
||||
}
|
||||
|
||||
function meetsUptime(n: NS_NODE): boolean {
|
||||
const u = toNumber(n.uptime, -1);
|
||||
return u >= MIN_UPTIME;
|
||||
}
|
||||
|
||||
function wireguardOn(n: NS_NODE): boolean {
|
||||
return n.self_description?.wireguard != null;
|
||||
}
|
||||
|
||||
function sortByUptimeDescStakeAsc(a: NS_NODE, b: NS_NODE): number {
|
||||
const ua = toNumber(a.uptime, 0);
|
||||
const ub = toNumber(b.uptime, 0);
|
||||
if (ub !== ua) return ub - ua; // higher uptime first
|
||||
const sa = toNumber(a.total_stake, 0);
|
||||
const sb = toNumber(b.total_stake, 0);
|
||||
return sa - sb; // then lower stake first
|
||||
}
|
||||
const baseFilter = (n: NS_NODE) =>
|
||||
(n.bonded === true || n.bonded === undefined) &&
|
||||
hasRequiredRoles(n) &&
|
||||
hasGoodPM(n) &&
|
||||
stakeInRange(n) &&
|
||||
meetsUptime(n); // uptime hard floor
|
||||
|
||||
// prefer wg-enabled nodes first
|
||||
const wgCandidates = nodes
|
||||
.filter((n) => baseFilter(n) && wireguardOn(n))
|
||||
.sort(sortByUptimeDescStakeAsc);
|
||||
|
||||
let picked = wgCandidates.slice(0, 10);
|
||||
|
||||
// if fewer than 10, drop wg pref but keep base filter
|
||||
if (picked.length < 10) {
|
||||
const relaxed = nodes.filter(baseFilter).sort(sortByUptimeDescStakeAsc);
|
||||
const have = new Set(picked.map((n) => n.node_id));
|
||||
for (const n of relaxed) {
|
||||
if (have.size >= 10) break;
|
||||
const id =
|
||||
typeof n.node_id === "number" ? n.node_id : toNumber(n.node_id, 0);
|
||||
if (!have.has(id)) {
|
||||
picked = [...picked, n];
|
||||
have.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return picked
|
||||
.map((n) =>
|
||||
typeof n.node_id === "number" ? n.node_id : toNumber(n.node_id, 0)
|
||||
)
|
||||
.filter((id) => Number.isFinite(id) && id > 0);
|
||||
};
|
||||
|
||||
@@ -5,10 +5,11 @@ export const CURRENT_EPOCH_REWARDS =
|
||||
|
||||
export const NYM_ACCOUNT_ADDRESS =
|
||||
"https://explorer.nymtech.net/api/v1/tmp/unstable/account";
|
||||
export const NYM_PRICES_API = "https://api.nym.spectredao.net/api/v1/nym-price";
|
||||
export const SPECTREDAO_NYM_PRICES_API =
|
||||
"https://api.nym.spectredao.net/api/v1/nym-price";
|
||||
export const VALIDATOR_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_VALIDATOR_URL || "https://rpc.nymtech.net";
|
||||
export const DATA_OBSERVATORY_BALANCES_URL =
|
||||
export const SPECTREDAO_BALANCES_URL =
|
||||
"https://api.nym.spectredao.net/api/v1/balances";
|
||||
export const OBSERVATORY_GATEWAYS_URL =
|
||||
"https://mainnet-node-status-api.nymtech.cc/v2/gateways";
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
export const TABLET_WIDTH = "(min-width:700px)";
|
||||
|
||||
import { getRecommendedNodes } from "./lib/recommended";
|
||||
|
||||
export const RECOMMENDED_NODES = getRecommendedNodes();
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
// NOTE: removed the cache() import to avoid sticky results across requests
|
||||
// import { cache } from "react";
|
||||
|
||||
type DeclaredRole = {
|
||||
entry?: boolean;
|
||||
exit_ipr?: boolean;
|
||||
exit_nr?: boolean;
|
||||
mixnode?: boolean;
|
||||
};
|
||||
|
||||
type ApiNode = {
|
||||
node_id?: number;
|
||||
total_stake?: number | string;
|
||||
uptime?: number | string; // fraction 0..1
|
||||
description?: {
|
||||
wireguard?: unknown | null;
|
||||
declared_role?: DeclaredRole;
|
||||
};
|
||||
self_description?: {
|
||||
declared_role?: DeclaredRole;
|
||||
};
|
||||
rewarding_details?: {
|
||||
cost_params?: {
|
||||
profit_margin_percent?: string | number;
|
||||
};
|
||||
};
|
||||
bonded?: boolean;
|
||||
};
|
||||
|
||||
function toNumber(x: unknown, fallback = 0): number {
|
||||
const n =
|
||||
typeof x === "string" || typeof x === "number" ? Number(x) : Number.NaN;
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
const MIN_STAKE = 50_000_000_000; // 50k NYM (uNYM)
|
||||
const MAX_STAKE = 150_000_000_000; // 150k NYM (uNYM)
|
||||
const MAX_PM = 0.2; // ≤ 20%
|
||||
const MIN_UPTIME = 0.95; // ≥ 95%
|
||||
|
||||
// require gateway roles: entry + exit_ipr + exit_nr; NOT a mixnode
|
||||
function hasRequiredRoles(n: ApiNode): boolean {
|
||||
const r =
|
||||
n.self_description?.declared_role ?? n.description?.declared_role ?? {};
|
||||
const mixnodeFalse = r.mixnode === false || r.mixnode === undefined;
|
||||
return mixnodeFalse && !!r.entry && !!r.exit_ipr && !!r.exit_nr;
|
||||
}
|
||||
|
||||
function hasGoodPM(n: ApiNode): boolean {
|
||||
const pm = toNumber(
|
||||
n.rewarding_details?.cost_params?.profit_margin_percent,
|
||||
Number.NaN,
|
||||
);
|
||||
return !Number.isNaN(pm) && pm <= MAX_PM;
|
||||
}
|
||||
|
||||
function stakeInRange(n: ApiNode): boolean {
|
||||
const s = toNumber(n.total_stake, 0);
|
||||
return s > MIN_STAKE && s < MAX_STAKE;
|
||||
}
|
||||
|
||||
function meetsUptime(n: ApiNode): boolean {
|
||||
const u = toNumber(n.uptime, -1);
|
||||
return u >= MIN_UPTIME;
|
||||
}
|
||||
|
||||
function wireguardOn(n: ApiNode): boolean {
|
||||
return n.description?.wireguard != null;
|
||||
}
|
||||
|
||||
function sortByUptimeDescStakeAsc(a: ApiNode, b: ApiNode): number {
|
||||
const ua = toNumber(a.uptime, 0);
|
||||
const ub = toNumber(b.uptime, 0);
|
||||
if (ub !== ua) return ub - ua; // higher uptime first
|
||||
const sa = toNumber(a.total_stake, 0);
|
||||
const sb = toNumber(b.total_stake, 0);
|
||||
return sa - sb; // then lower stake first
|
||||
}
|
||||
|
||||
// fetch all for the nodes API
|
||||
async function fetchAllNodes(): Promise<ApiNode[]> {
|
||||
const base = "https://api.nym.spectredao.net/api/v1/nodes";
|
||||
|
||||
// 1. Try limit/offset
|
||||
{
|
||||
const limit = 1000;
|
||||
const all: ApiNode[] = [];
|
||||
let offset = 0;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const res = await fetch(`${base}?limit=${limit}&offset=${offset}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) break;
|
||||
const js = await res.json();
|
||||
const data: ApiNode[] = Array.isArray(js) ? js : Array.isArray(js?.data) ? js.data : [];
|
||||
if (!data.length) break;
|
||||
all.push(...data);
|
||||
if (data.length < limit) return all;
|
||||
offset += limit;
|
||||
}
|
||||
if (all.length) return all;
|
||||
}
|
||||
|
||||
// 2. try page/size
|
||||
{
|
||||
const size = 1000;
|
||||
const all: ApiNode[] = [];
|
||||
let page = 0;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const res = await fetch(`${base}?page=${page}&size=${size}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) break;
|
||||
const js = await res.json();
|
||||
const data: ApiNode[] = Array.isArray(js) ? js : Array.isArray(js?.data) ? js.data : [];
|
||||
if (!data.length) break;
|
||||
all.push(...data);
|
||||
const total = Number(js?.pagination?.total ?? Number.NaN);
|
||||
if (Number.isFinite(total) && all.length >= total) return all;
|
||||
page += 1;
|
||||
}
|
||||
if (all.length) return all;
|
||||
}
|
||||
|
||||
// 3. fallback single-shot
|
||||
const res = await fetch(base, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`Failed to fetch nodes: ${res.status}`);
|
||||
const js = await res.json();
|
||||
return Array.isArray(js) ? js : Array.isArray(js?.data) ? js.data : [];
|
||||
}
|
||||
|
||||
async function fetchRecommendedNodes(): Promise<number[]> {
|
||||
const nodes = await fetchAllNodes();
|
||||
|
||||
const baseFilter = (n: ApiNode) =>
|
||||
(n.bonded === true || n.bonded === undefined) &&
|
||||
hasRequiredRoles(n) &&
|
||||
hasGoodPM(n) &&
|
||||
stakeInRange(n) &&
|
||||
meetsUptime(n); // uptime hard floor
|
||||
|
||||
// prefer wg-enabled nodes first
|
||||
const wgCandidates = nodes
|
||||
.filter((n) => baseFilter(n) && wireguardOn(n))
|
||||
.sort(sortByUptimeDescStakeAsc);
|
||||
|
||||
let picked = wgCandidates.slice(0, 10);
|
||||
|
||||
// if fewer than 10, drop wg pref but keep base filter
|
||||
if (picked.length < 10) {
|
||||
const relaxed = nodes.filter(baseFilter).sort(sortByUptimeDescStakeAsc);
|
||||
const have = new Set(picked.map((n) => n.node_id));
|
||||
for (const n of relaxed) {
|
||||
if (have.size >= 10) break;
|
||||
const id = typeof n.node_id === "number" ? n.node_id : toNumber(n.node_id, 0);
|
||||
if (!have.has(id)) {
|
||||
picked = [...picked, n];
|
||||
have.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return picked
|
||||
.map((n) =>
|
||||
typeof n.node_id === "number" ? n.node_id : toNumber(n.node_id, 0),
|
||||
)
|
||||
.filter((id) => Number.isFinite(id) && id > 0);
|
||||
}
|
||||
|
||||
// keep the same exports the code expects:
|
||||
export async function getRecommendedNodes(): Promise<number[]> {
|
||||
return fetchRecommendedNodes();
|
||||
}
|
||||
export const RECOMMENDED_NODES: Promise<number[]> = getRecommendedNodes();
|
||||
@@ -11,11 +11,8 @@ import { StakersNumberCardWrapper } from "../components/landingPageComponents/St
|
||||
import { TokenomicsCardWrapper } from "../components/landingPageComponents/TokenomicsCardWrapper";
|
||||
import NodeTable from "../components/nodeTable/NodeTableWithAction";
|
||||
import NodeAndAddressSearch from "../components/search/NodeAndAddressSearch";
|
||||
import { RECOMMENDED_NODES } from "./constants";
|
||||
|
||||
export default async function Home() {
|
||||
const recommendedIds = await RECOMMENDED_NODES;
|
||||
|
||||
return (
|
||||
<ContentLayout>
|
||||
<Stack gap={5}>
|
||||
@@ -45,7 +42,7 @@ export default async function Home() {
|
||||
<SectionHeading title="Nym Servers" />
|
||||
</Grid>
|
||||
<Grid size={12}>
|
||||
<NodeTable recommendedIds={recommendedIds} />
|
||||
<NodeTable />
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid container columnSpacing={5} rowSpacing={5}>
|
||||
|
||||
@@ -4,16 +4,17 @@ 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 } from "../../app/api";
|
||||
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";
|
||||
|
||||
type Props = {
|
||||
/** Recommended node IDs provided by the server page */
|
||||
recommendedIds: number[];
|
||||
};
|
||||
|
||||
|
||||
function getNodeSaturationPoint(
|
||||
totalStake: number,
|
||||
@@ -84,7 +85,7 @@ const mappedNSApiNodes = (
|
||||
export type MappedNymNodes = ReturnType<typeof mappedNSApiNodes>;
|
||||
export type MappedNymNode = MappedNymNodes[0];
|
||||
|
||||
const NodeTableWithAction = ({ recommendedIds }: Props) => {
|
||||
const NodeTableWithAction = () => {
|
||||
// All hooks at the top!
|
||||
const [activeFilter, setActiveFilter] = useState<
|
||||
"all" | "mixnodes" | "gateways" | "recommended"
|
||||
@@ -111,7 +112,7 @@ const NodeTableWithAction = ({ recommendedIds }: Props) => {
|
||||
|
||||
// Wrapper functions to handle filter changes and sessionStorage
|
||||
const handleActiveFilterChange = (
|
||||
newFilter: "all" | "mixnodes" | "gateways" | "recommended",
|
||||
newFilter: "all" | "mixnodes" | "gateways" | "recommended"
|
||||
) => {
|
||||
setActiveFilter(newFilter);
|
||||
sessionStorage.setItem("nodeTableActiveFilter", newFilter);
|
||||
@@ -126,7 +127,7 @@ const NodeTableWithAction = ({ recommendedIds }: Props) => {
|
||||
setSaturation(newSaturation);
|
||||
sessionStorage.setItem(
|
||||
"nodeTableSaturation",
|
||||
JSON.stringify(newSaturation),
|
||||
JSON.stringify(newSaturation)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -134,7 +135,7 @@ const NodeTableWithAction = ({ recommendedIds }: Props) => {
|
||||
setProfitMargin(newProfitMargin);
|
||||
sessionStorage.setItem(
|
||||
"nodeTableProfitMargin",
|
||||
JSON.stringify(newProfitMargin),
|
||||
JSON.stringify(newProfitMargin)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -142,7 +143,7 @@ const NodeTableWithAction = ({ recommendedIds }: Props) => {
|
||||
setAdvancedOpen(newAdvancedOpen);
|
||||
sessionStorage.setItem(
|
||||
"nodeTableAdvancedOpen",
|
||||
JSON.stringify(newAdvancedOpen),
|
||||
JSON.stringify(newAdvancedOpen)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -174,6 +175,8 @@ const NodeTableWithAction = ({ recommendedIds }: Props) => {
|
||||
refetchOnMount: false,
|
||||
});
|
||||
|
||||
const recommendedIds = getRecommendedNodes(nsApiNodes);
|
||||
|
||||
// Map nodes with rewards data
|
||||
const nsApiNodesData = epochRewardsData
|
||||
? mappedNSApiNodes(nsApiNodes || [], epochRewardsData)
|
||||
@@ -182,7 +185,7 @@ const NodeTableWithAction = ({ recommendedIds }: Props) => {
|
||||
// Calculate max saturation from all nodes
|
||||
const maxSaturation = Math.max(
|
||||
100,
|
||||
...nsApiNodesData.map((n) => n.stakeSaturation || 0),
|
||||
...nsApiNodesData.map((n) => n.stakeSaturation || 0)
|
||||
);
|
||||
|
||||
// Initialize saturation from sessionStorage or set to maxSaturation when data is loaded
|
||||
|
||||
Reference in New Issue
Block a user