Compare commits

...

102 Commits

Author SHA1 Message Date
Sachin Kamath f87b90d55d chore: bump package version (#6342)
* chore: bump package version

* chore: bump cosmos-kit react package
2026-01-20 16:00:48 +05:30
Iana Matrosova 833a75421b Updates label for Nym server locations 2025-10-24 11:47:24 -03:00
Yana Matrosova db0922bf7c Merge pull request #6049 from nymtech/refactor/recommended-nodes
Refactor/recommended nodes
2025-09-18 13:34:58 +03:00
Yana cdb6f39341 Rename constants 2025-09-17 20:42:08 +03:00
Yana 20188ff373 Refactor recommended nodes 2025-09-17 20:32:22 +03:00
import this bd576f05c0 Hotfix: Explorer-v2 table caching issues (#6045) 2025-09-17 11:31:35 +00:00
benedetta davico 005d9e9d19 Merge pull request #6019 from nymtech/feature/serinko/recommended-serves
Feature/explorer-v2: Replace recommended servers with automated selection
2025-09-15 14:41:32 +02:00
benedettadavico 1a0748ae1f . 2025-09-15 12:55:41 +02:00
benedettadavico e5dfd9ad02 . 2025-09-15 12:29:00 +02:00
benedettadavico 4fed2a8f8c add dep 2025-09-15 11:50:40 +02:00
benedettadavico 05108693ee add prebuild cmd 2025-09-15 11:17:16 +02:00
benedettadavico 990745e749 another attempt 2025-09-15 10:59:35 +02:00
benedettadavico 4dedf0bc75 bump lerna version 2025-09-15 08:48:35 +02:00
benedettadavico 3b53f380a2 fix 2025-09-15 08:35:58 +02:00
benedettadavico 5301edaf62 fix vercel file 2025-09-15 08:34:36 +02:00
benedettadavico 5990d4feeb use vercel.json file 2025-09-15 08:32:49 +02:00
benedettadavico 45aaaf5d7f fixing issues 2025-09-15 08:06:27 +02:00
benedettadavico 3691f17bf7 try to skip nx cache 2025-09-11 19:02:09 +02:00
benedettadavico 621d864ed4 version change again 2025-09-11 18:49:33 +02:00
benedettadavico 4b71331ada force version 20.18.0 2025-09-11 18:45:45 +02:00
benedettadavico a136c8b955 use nvnrc 2025-09-11 18:39:35 +02:00
benedettadavico 9f7d7d98b8 lint 2025-09-11 18:21:48 +02:00
benedettadavico 7863df17e4 Merge branch 'feat/security-mitigation' into feature/serinko/recommended-serves 2025-09-11 16:24:07 +02:00
Tommy Verrall 55a0f80d73 Feat: implement supply chain attack mitigation
- Add yarn resolutions for vulnerable packages (chalk, strip-ansi, color-convert, etc.)
- Add .npmrc and .nvmrc security configurations
2025-09-10 18:51:38 +02:00
serinko af3271cb07 finished: ready for review 2025-09-05 23:58:41 +02:00
serinko e4d41e0c18 debug 2025-09-05 23:53:01 +02:00
serinko a26bda03e9 debug 2025-09-05 23:44:06 +02:00
serinko 248241f438 debug 2025-09-05 23:35:36 +02:00
serinko 1a8dcc8a25 debug 2025-09-05 23:25:33 +02:00
serinko 355a5f9e55 clean 2025-09-05 23:18:36 +02:00
serinko 412e2f23e5 max stake cap 2025-09-05 23:12:42 +02:00
serinko 961f6c53b2 remove wss check 2025-09-05 23:06:16 +02:00
serinko 4bb67da497 update recommended selection algo 2025-09-05 22:58:05 +02:00
serinko c4410bd187 update recommended selection algo 2025-09-05 22:51:59 +02:00
serinko 7d1250b193 fig path 2025-09-05 22:42:33 +02:00
serinko 07065e025f fig page.tsx 2025-09-05 22:31:00 +02:00
serinko 812393be1a logic fix 2025-09-05 22:23:07 +02:00
serinko 2903481edb rm redundant 2025-09-05 22:14:27 +02:00
serinko 7ee6b1bd42 add recommended nodes file 2025-09-05 22:10:59 +02:00
serinko e5f6a82092 initialise attempt to replace recommended servers 2025-09-05 18:47:16 +02:00
Fouad 836720cd31 Update RECOMMENDED_NODES values (#6009) 2025-09-04 11:48:36 +01:00
Yana 7b9d22d7e3 Change social links color 2025-07-25 16:19:59 +03:00
Yana a87f8deda1 revert re-skin 2025-07-24 18:01:01 +03:00
Yana 56cf93f1b5 Add .env.example 2025-07-21 18:53:36 +03:00
Yana 5dd63157cc Add re-skinning 2025-07-17 19:05:36 +03:00
Yana 9f920e1e9c banner 2025-07-17 16:17:28 +03:00
Yana 64823b06c7 fix: replace any type with proper Grid2 size type 2025-07-12 11:56:08 +03:00
Yana fb689a35e6 feat: add conditional card wrappers to prevent empty grid spaces 2025-07-12 11:49:30 +03:00
Yana 1847a84372 Fix NoiseCard loading logic: show component once data is complete, proper TypeScript type guards 2025-07-12 00:33:01 +03:00
Yana 65d1589968 Improve NoiseCard hotfix: check array length before accessing elements, show error only if truly insufficient data 2025-07-12 00:23:29 +03:00
Yana b3d87156db Fix TypeError in NoiseCard component - add null checks and optional chaining for data access 2025-07-11 23:56:05 +03:00
Yana 28b4fe7e7e add 10 recommended nodes 2025-06-05 12:33:43 +03:00
Yana 9479d2a383 Add recommended nodes 2025-06-04 19:47:53 +03:00
Yana 886b4410aa Fix open in new tab click on NodeTable 2025-06-03 14:28:17 +03:00
Yana b51358fb12 Style fixes 2025-05-22 14:24:24 +03:00
Yana 53e3acaa37 Add countries and locations to WorldMap 2025-05-21 17:11:52 +03:00
Yana 978817baf7 fix build 2025-05-15 19:20:16 +03:00
Yana 9319a5ec04 fix self-bond, redirect articles to nym/blog 2025-05-15 19:15:29 +03:00
Yana 3186db2915 style fixes 2025-05-14 20:47:26 +03:00
Yana ff7671f28a update copy 2025-05-14 20:38:07 +03:00
Yana cbe8eec2a4 fix dark mode font color 2025-05-14 19:53:07 +03:00
Yana 42f9edd408 Add self-bond and operating costs to NodeTable 2025-05-14 19:40:31 +03:00
Yana 128cf7c070 Add colors on uptime 2025-05-09 15:46:50 +03:00
Yana 79e5004849 revamp NodeTable 2025-05-09 15:27:54 +03:00
Yana 0d6722f9f5 'Change footer version to 2.2 2025-05-08 15:17:28 +03:00
Yana d458df9c34 fix build 2025-05-08 15:08:48 +03:00
Yana 7a8ac59a36 Add default sorting by country to Node tables 2025-05-08 14:56:04 +03:00
Yana ad3eb7a84c fix build 2025-05-07 19:54:09 +03:00
Yana 135f248eba Replace spectreDao delegations 2025-05-07 18:59:05 +03:00
Yana 7012bf9886 Add node count on every quick filter 2025-05-06 16:25:40 +03:00
Yana 88aa32ddeb Fix advanced filtering UI 2025-05-06 16:15:23 +03:00
Yana 7c1c9976f0 fix build 2025-05-04 19:27:47 +03:00
Yana 4ee7f7eaf5 Fix saturation filter 2025-05-04 19:23:35 +03:00
Yana 778772d96a fix build 2025-05-04 19:16:30 +03:00
Yana 5b791b41aa Add advanced filters 2025-05-04 19:13:34 +03:00
Yana 4b7e51fc3b Add quick filters on NodeTable 2025-05-04 11:27:29 +03:00
Yana 0a42dd3e0d fix mobile map 2025-04-22 20:20:44 +03:00
Yana 7cf49f642d fix images 2025-04-22 19:47:40 +03:00
Yana 089ab65dd7 Fix maps 2025-04-22 18:51:29 +03:00
Yana c1fabae770 Clean up 2025-04-17 18:25:43 +03:00
Yana 3ed7cfa381 Replace SpectreDao on AccountPageButtonGroup 2025-04-17 18:21:30 +03:00
Yana 4fe83da99d Replace SpectreDao api in Staking Table 2025-04-17 18:16:13 +03:00
Yana 4f81fc7400 Replace SpectreDao api on Magic Search 2025-04-17 17:55:52 +03:00
Yana 6d601ca654 Replace SpectreDao api on Stakers Card 2025-04-17 17:46:35 +03:00
Yana cea3ad9908 Add dark mode on error cards 2025-04-17 17:36:27 +03:00
Yana e4ecd099cc Add dark mode on error cards 2025-04-17 17:28:08 +03:00
Yana 0723542c39 clean up 2025-04-16 21:20:14 +03:00
Yana 523e559ff8 clean up 2025-04-16 21:17:15 +03:00
Yana 02b27573de clean up 2025-04-16 21:08:31 +03:00
Yana 8f229737a3 Replace SpectreDao on NodeTable and Node page 2025-04-16 21:06:12 +03:00
Yana 1afd13d6e0 Clean up 2025-04-16 15:27:53 +03:00
Yana df10b5595a Add styles 2025-04-16 15:23:05 +03:00
Yana 443031ba66 test data fetching 2025-04-16 13:37:35 +03:00
Yana 8d340a49d3 fix data fetching 2025-04-16 09:57:27 +03:00
Yana e0925d3c7f clean up 2025-04-16 08:40:34 +03:00
Yana 89d391da29 fix build 2025-04-16 08:13:21 +03:00
Yana cc2d7d34d2 reset last changes 2025-04-16 08:05:04 +03:00
Yana 969070f938 fix build, fix map sizes 2025-04-15 21:38:05 +03:00
Yana 3dfcae9369 fix build 2025-04-15 21:04:58 +03:00
Yana 32a4bf1172 fix build 2025-04-15 20:54:37 +03:00
Yana 433cac8c58 Fix map sizing 2025-04-15 18:15:00 +03:00
Yana 4fc64a072c Add WorldMap 2025-04-15 16:47:37 +03:00
79 changed files with 54044 additions and 52291 deletions
View File
+58
View File
@@ -0,0 +1,58 @@
# Security and sensitive files
.env*
*.key
*.pem
*.p12
*.pfx
secrets/
private/
config/secrets/
# Development files
node_modules/
.npm/
.npmrc
.nvmrc
*.log
*.tmp
.DS_Store
Thumbs.db
# Build artifacts
dist/
build/
target/
*.tgz
*.tar.gz
# IDE files
.vscode/
.idea/
*.swp
*.swo
*~
# Test files
test/
tests/
__tests__/
*.test.js
*.test.ts
*.spec.js
*.spec.ts
# Documentation
docs/
*.md
!README.md
# CI/CD files
.github/
.gitlab-ci.yml
.travis.yml
.circleci/
azure-pipelines.yml
# Scripts
scripts/
!scripts/security-check.sh
+21
View File
@@ -0,0 +1,21 @@
audit-level=moderate
fund=false
update-notifier=false
ignore-scripts=false
strict-ssl=true
registry=https://registry.npmjs.org/
audit=true
package-lock=true
package-lock-only=false
save-exact=false
# use npm ci for production builds (faster and more secure)
# this will be enforced in CI/CD scripts
# prevent installation of optional dependencies that might contain vulnerabilities
optional=false
audit=true
update-notifier=false
save-exact=false
+1
View File
@@ -0,0 +1 @@
20.18.0
+1
View File
@@ -0,0 +1 @@
20
+1 -1
View File
@@ -7,7 +7,7 @@
},
"files": {
"ignoreUnknown": false,
"ignore": ["node_modules", ".next", "public"]
"ignore": ["node_modules", ".next", "public", "src/app/lib/strapi.d.ts"]
},
"formatter": {
"enabled": true,
+16 -16
View File
@@ -2,23 +2,23 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
reactStrictMode: true,
basePath: "/explorer",
assetPrefix: "/explorer",
trailingSlash: false,
basePath: "/explorer",
assetPrefix: "/explorer",
trailingSlash: false,
async redirects() {
return [
// Change the basePath to /explorer
{
source: "/",
destination: "/explorer",
basePath: false,
permanent: true,
},
];
},
async redirects() {
return [
// Change the basePath to /explorer
{
source: "/",
destination: "/explorer",
basePath: false,
permanent: true,
},
];
},
};
module.exports = nextConfig
module.exports = nextConfig;
+9 -4
View File
@@ -4,17 +4,18 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"prebuild": "yarn --cwd ../ts-packages/types build && yarn --cwd ../sdk/typescript/packages/mui-theme build && yarn --cwd ../sdk/typescript/packages/react-components build",
"build:prod": "yarn --cwd .. build && next build",
"start": "next start",
"lint": "biome check --fix"
},
"engines": {
"node": ">=20.0.0"
"node": "20.x"
},
"dependencies": {
"@chain-registry/types": "^0.50.36",
"@cosmos-kit/keplr-extension": "^2.14.0",
"@cosmos-kit/react": "^2.20.1",
"@cosmos-kit/keplr-extension": "^2.17.0",
"@cosmos-kit/react": "^2.24.0",
"@emotion/cache": "^11.13.5",
"@emotion/react": "^11.13.5",
"@emotion/styled": "^11.13.5",
@@ -35,6 +36,7 @@
"@uidotdev/usehooks": "^2.4.1",
"chain-registry": "^1.69.64",
"cldr-compact-number": "^0.4.0",
"d3-scale": "^4.0.2",
"date-fns": "^4.1.0",
"i18next": "^24.2.2",
"i18next-resources-to-backend": "^1.2.1",
@@ -49,6 +51,8 @@
"react-i18next": "^15.4.0",
"react-markdown": "^9.0.3",
"react-random-avatars": "^1.3.1",
"react-simple-maps": "^3.0.0",
"react-tooltip": "^5.28.1",
"react-world-flags": "^1.6.0",
"zod": "^3.24.1"
},
@@ -57,9 +61,10 @@
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react-simple-maps": "^3.0.6",
"eslint": "^8",
"eslint-config-next": "15.0.3",
"lefthook": "^1.8.5",
"typescript": "^5"
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

+4 -1
View File
@@ -7,7 +7,10 @@ import { Wrapper } from "@/components/wrapper";
import { Box, Stack } from "@mui/material";
// import Grid from "@mui/material/Grid2";
export default function ExplorerPage() {
export default async function ExplorerPage() {
// Resolve once on the server and pass IDs to client components
return (
<ContentLayout>
<Wrapper>
+208 -57
View File
@@ -1,12 +1,14 @@
import { countryCodeMap } from "@/assets/countryCodes";
import { addSeconds } from "date-fns";
/* eslint-disable @typescript-eslint/no-explicit-any */
import type {
CountryDataResponse,
CurrentEpochData,
ExplorerData,
GatewayStatus,
IAccountBalancesInfo,
IObservatoryNode,
IPacketsAndStakingData,
NS_NODE,
NodeRewardDetails,
NymTokenomics,
ObservatoryBalance,
@@ -14,10 +16,10 @@ import type {
import {
CURRENT_EPOCH,
CURRENT_EPOCH_REWARDS,
DATA_OBSERVATORY_BALANCES_URL,
DATA_OBSERVATORY_NODES_URL,
SPECTREDAO_BALANCES_URL,
NS_API_NODES,
NYM_ACCOUNT_ADDRESS,
NYM_PRICES_API,
SPECTREDAO_NYM_PRICES_API,
OBSERVATORY_GATEWAYS_URL,
} from "./urls";
@@ -42,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}`);
@@ -54,17 +56,14 @@ export const fetchGatewayStatus = async (
};
export const fetchNodeDelegations = async (
id: number,
id: number
): Promise<NodeRewardDetails[]> => {
const response = await fetch(
`${DATA_OBSERVATORY_NODES_URL}/${id}/delegations`,
{
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
const response = await fetch(`${NS_API_NODES}/${id}/delegations`, {
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
);
});
if (!response.ok) {
throw new Error("Failed to fetch delegations");
@@ -89,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 };
@@ -97,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",
@@ -118,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",
@@ -140,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",
@@ -160,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, {
@@ -176,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: {
@@ -192,42 +189,9 @@ export const fetchAccountBalance = async (
return data;
};
export const fetchObservatoryNodes = async (): Promise<IObservatoryNode[]> => {
const allNodes: IObservatoryNode[] = [];
let page = 1;
const PAGE_SIZE = 200;
let hasMoreData = true;
while (hasMoreData) {
const response = await fetch(
`${DATA_OBSERVATORY_NODES_URL}?page=${page}&limit=${PAGE_SIZE}`,
{
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
},
);
if (!response.ok) {
throw new Error(`Failed to fetch observatory nodes (page ${page})`);
}
const nodes: IObservatoryNode[] = await response.json();
allNodes.push(...nodes);
if (nodes.length < PAGE_SIZE) {
hasMoreData = false; // Stop fetching when the last page has fewer than 200 items
} else {
page++; // Move to the next page
}
}
return allNodes;
};
// 🔹 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",
@@ -239,3 +203,190 @@ export const fetchNymPrice = async (): Promise<NymTokenomics> => {
const data: NymTokenomics = await res.json();
return data;
};
export const fetchNSApiNodes = async (): Promise<NS_NODE[]> => {
if (!NS_API_NODES) {
throw new Error("NS_API_NODES URL is not defined");
}
const allNodes: any[] = [];
let page = 0;
const PAGE_SIZE = 200;
let totalItems = 0;
let hasMoreData = true;
while (hasMoreData) {
const response = await fetch(
`${NS_API_NODES}?page=${page}&size=${PAGE_SIZE}`,
{
headers: {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
},
},
);
if (!response.ok) {
throw new Error(
`Failed to fetch NS API nodes (page ${page}): ${response.statusText}`,
);
}
const data = await response.json();
const nodes: any[] = data.items || [];
allNodes.push(...nodes);
// Get total count from response
totalItems = data.total || 0;
// Check if we've fetched all items
if (allNodes.length >= totalItems) {
hasMoreData = false;
} else {
page++; // Move to the next page
}
}
return allNodes;
};
export const fetchWorldMapCountries = async (): Promise<{
countries: CountryDataResponse;
totalCountries: number;
uniqueLocations: number;
totalServers: number;
}> => {
// Fetch all nodes from the NS API
const nodes = await fetchNSApiNodes();
// Create a map to count nodes by country
const countryCounts: Record<string, number> = {};
// Set to track unique cities
const uniqueCities = new Set<string>();
// Process each node
for (const node of nodes) {
// Get the 2-letter country code from the node's geoip data
const twoLetterCode = node.geoip?.country;
if (twoLetterCode) {
// Convert to 3-letter country code
const threeLetterCode = countryCodeMap[twoLetterCode] || twoLetterCode;
// Increment the count for this country
countryCounts[threeLetterCode] =
(countryCounts[threeLetterCode] || 0) + 1;
// Add city to unique cities set if it exists
if (node.geoip?.city) {
uniqueCities.add(node.geoip.city);
}
} else {
// If no geoip data, count it as unknown
countryCounts[""] = (countryCounts[""] || 0) + 1;
}
}
// Convert the counts to the required format
const result: CountryDataResponse = {};
for (const [threeLetterCode, count] of Object.entries(countryCounts)) {
result[threeLetterCode] = {
ISO3: threeLetterCode,
nodes: count,
};
}
return {
countries: result,
totalCountries: Object.keys(countryCounts).length,
uniqueLocations: uniqueCities.size,
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);
};
-23417
View File
File diff suppressed because it is too large Load Diff
+96 -143
View File
@@ -72,56 +72,59 @@ export interface ExplorerData {
}
export type NodeDescription = {
last_polled: string;
authenticator: {
address: string;
};
auxiliary_details: {
accepted_operator_terms_and_conditions: boolean;
announce_ports: {
mix_port: number | null;
verloc_port: number | null;
};
location: string;
};
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: {
ip_address: string[];
hostname: string;
ip_address: [string, string];
keys: {
ed25519: string;
x25519: string;
x25519_noise: string | null;
};
};
declared_role: {
mixnode: boolean;
entry: boolean;
exit_nr: boolean;
exit_ipr: boolean;
ip_packet_router: {
address: string;
};
auxiliary_details: {
location: string;
announce_ports: {
verloc_port: number | null;
mix_port: number | null;
};
accepted_operator_terms_and_conditions: boolean;
};
build_information: {
binary_name: string;
build_timestamp: string;
build_version: string;
commit_sha: string;
commit_timestamp: string;
commit_branch: string;
rustc_version: string;
rustc_channel: string;
cargo_profile: string;
cargo_triple: string;
last_polled: string;
mixnet_websockets: {
ws_port: number;
wss_port: number;
};
network_requester: {
address: string;
uses_exit_policy: boolean;
};
ip_packet_router: {
address: string;
};
authenticator: {
address: string;
};
wireguard: string | null;
mixnet_websockets: {
ws_port: number;
wss_port: number | null;
wireguard: {
port: number;
public_key: string;
};
} | null;
@@ -165,15 +168,6 @@ export type Location = {
longitude?: number;
};
export type NodeData = {
node_id: number;
contract_node_type: string;
description: NodeDescription;
bond_information: BondInformation;
rewarding_details: RewardingDetails;
location: Location;
};
// ACCOUNT BALANCES
export interface IRewardDetails {
@@ -207,111 +201,15 @@ export interface IAccountBalancesInfo {
vesting_account?: null | string;
}
export interface IObservatoryNode {
accepted_tnc: boolean;
bonded: boolean;
bonding_address: string;
description: {
authenticator: {
address: string;
};
auxiliary_details: {
accepted_operator_terms_and_conditions: boolean;
announce_ports: {
mix_port: number | null;
verloc_port: number | null;
};
location: string | 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: string | null;
geoip: {
city: string;
country: string;
ip_address: string;
loc: string;
node_id: number;
org: string;
postal: string;
region: string;
};
};
identity_key: string;
ip_address: string;
node_id: number;
node_type: string;
original_pledge: number;
rewarding_details: {
cost_params: {
interval_operating_cost: {
amount: string;
denom: string;
};
profit_margin_percent: string;
};
delegates: string;
last_rewarded_epoch: number;
operator: string;
total_unit_reward: string;
unique_delegations: number;
unit_delegation: string;
};
self_description: {
details: string;
moniker: string;
security_contact: string;
website: string;
};
total_stake: number;
uptime: number;
}
export interface NodeRewardDetails {
amount: {
amount: string;
denom: string;
};
block_height: number;
cumulative_reward_ratio: string;
height: number;
node_id: number;
owner: string;
proxy: string;
}
export type LastProbeResult = {
@@ -480,3 +378,58 @@ export type NymTokenomics = {
symbol: string;
total_supply: number;
};
export type CountryData = {
ISO3: string;
nodes: number;
};
export interface CountryDataResponse {
[threeLetterCountryCode: string]: CountryData;
}
export type NS_NODE = {
accepted_tnc: boolean;
bonded: boolean;
bonding_address: string;
description: {
details: string;
moniker: string;
security_contact: string;
website: string;
};
geoip?: {
city: string;
country: string;
ip_address: string;
latitude: string;
longitude: string;
org: string;
postal: string;
region: string;
timezone: string;
};
identity_key: string;
ip_address: string;
node_id: number;
node_type: string;
original_pledge: number;
rewarding_details?: {
cost_params: {
interval_operating_cost: {
amount: string;
denom: string;
};
profit_margin_percent: string;
};
delegates: string;
last_rewarded_epoch: number;
operator: string;
total_unit_reward: string;
unique_delegations: number;
unit_delegation: string;
} | null;
self_description?: NodeDescription;
total_stake: string;
uptime: number;
};
+3 -7
View File
@@ -5,15 +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_NODES_URL =
"https://api.nym.spectredao.net/api/v1/nodes";
export const DATA_OBSERVATORY_DELEGATIONS_URL =
"https://api.nym.spectredao.net/api/v1/delegations";
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";
+2
View File
@@ -1 +1,3 @@
export const TABLET_WIDTH = "(min-width:700px)";
@@ -0,0 +1,36 @@
// API
import { client } from "../../../lib/strapiClient";
// Types
import type { Languages } from "../../../i18n";
import type { components } from "@/app/lib/strapi";
// Constants
import { bannerApiPath } from "../../banner/config/constants";
// Fetch footer data
export const getBanner = async (
locale: Languages,
): Promise<{
id?: number;
attributes?: components["schemas"]["ExplorerBanner"];
} | null> => {
const banner = await client.GET(bannerApiPath, {
params: {
query: {
locale,
// @ts-expect-error - populate is not typed correctly?
populate: {
links: {
populate: "*",
},
icon: {
populate: "*",
},
},
},
},
});
return banner?.data?.data ? banner?.data?.data : null;
};
@@ -0,0 +1 @@
export const bannerApiPath = "/explorer-banner";
@@ -1,72 +0,0 @@
// Types
import type { components } from "../../../lib/strapi";
// Components
import { Link } from "@/components/muiLink";
// MUI Components
import { Box, Grid2, Typography } from "@mui/material";
export const FooterLinks = ({
linkBlocks = [],
}: {
linkBlocks: components["schemas"]["FooterLinkBlockComponent"][];
}) => {
return (
<Grid2
container
spacing={{ xs: 2, md: 3 }}
columns={{ xs: 1, sm: 8, md: 5 }}
>
{linkBlocks?.map((block) => {
return (
<Grid2 key={block.id} size={{ xs: 1, sm: 4, md: 1 }}>
<Typography
component={block?.heading?.level || "h3"}
variant="subtitle1"
sx={{ mb: 4 }}
>
{block?.heading?.title}
</Typography>
<Box
component={"ul"}
sx={{
display: "flex",
flexDirection: "column",
gap: 2,
}}
>
{block?.links?.map((link) => {
const isLinkExternal = link.url?.startsWith("http");
return (
<Box
sx={{
listStyle: "none",
}}
component={"li"}
key={link.id}
>
<Link
href={link?.url || ""}
sx={{
textDecoration: "none",
"&:hover": {
textDecoration: "underline",
},
}}
>
<Typography variant="body3">
{link.title}
{isLinkExternal ? " ↗" : ""}
</Typography>
</Link>
</Box>
);
})}
</Box>
</Grid2>
);
})}
</Grid2>
);
};
+2
View File
@@ -1,3 +1,4 @@
import { Banner } from "@/components/banner/Banner";
import { Header } from "@/components/header";
import { Wrapper } from "@/components/wrapper";
import Providers from "@/providers";
@@ -21,6 +22,7 @@ export default function RootLayout({
<body>
<Providers>
<Header />
<Banner />
<Wrapper>{children}</Wrapper>
<Footer />
</Providers>
+35223 -22138
View File
File diff suppressed because it is too large Load Diff
+15 -23
View File
@@ -1,13 +1,14 @@
import { WorldMap } from "@/components/worldMap/WorldMap";
import { Stack } from "@mui/material";
import Grid from "@mui/material/Grid2";
import BlogArticlesCards from "../components/blogs/BlogArticleCards";
import { ContentLayout } from "../components/contentLayout/ContentLayout";
import SectionHeading from "../components/headings/SectionHeading";
import { CurrentEpochCard } from "../components/landingPageComponents/CurrentEpochCard";
import { NetworkStakeCard } from "../components/landingPageComponents/NetworkStakeCard";
import { NoiseCard } from "../components/landingPageComponents/NoiseCard";
import { StakersNumberCard } from "../components/landingPageComponents/StakersNumberCard";
import { TokenomicsCard } from "../components/landingPageComponents/TokenomicsCard";
import { CurrentEpochCardWrapper } from "../components/landingPageComponents/CurrentEpochCardWrapper";
import { NetworkStakeCardWrapper } from "../components/landingPageComponents/NetworkStakeCardWrapper";
import { NoiseCardWrapper } from "../components/landingPageComponents/NoiseCardWrapper";
import { StakersNumberCardWrapper } from "../components/landingPageComponents/StakersNumberCardWrapper";
import { TokenomicsCardWrapper } from "../components/landingPageComponents/TokenomicsCardWrapper";
import NodeTable from "../components/nodeTable/NodeTableWithAction";
import NodeAndAddressSearch from "../components/search/NodeAndAddressSearch";
@@ -16,38 +17,29 @@ export default async function Home() {
<ContentLayout>
<Stack gap={5}>
<NodeAndAddressSearch />
<WorldMap />
</Stack>
<Grid container columnSpacing={5} rowSpacing={5}>
<Grid size={12}>
<SectionHeading title="Noise Generating Network Overview" />
</Grid>
<Grid size={{ xs: 12, sm: 6, lg: 3 }}>
<NoiseCard />
<SectionHeading title="Network Overview" />
</Grid>
<NoiseCardWrapper />
<Grid
container
columnSpacing={5}
rowSpacing={5}
size={{ xs: 12, sm: 6, lg: 3 }}
>
<Grid size={12}>
<StakersNumberCard />
</Grid>
<Grid size={12}>
<CurrentEpochCard />
</Grid>
<StakersNumberCardWrapper />
<CurrentEpochCardWrapper />
</Grid>
<Grid size={{ xs: 12, sm: 6, lg: 3 }}>
<NetworkStakeCard />
</Grid>
<Grid size={{ xs: 12, sm: 6, lg: 3 }}>
<TokenomicsCard />
</Grid>
<NetworkStakeCardWrapper />
<TokenomicsCardWrapper />
</Grid>
<Grid container>
<Grid container rowSpacing={5}>
<Grid size={12}>
<SectionHeading title="Nym Nodes" />
<SectionHeading title="Nym Servers" />
</Grid>
<Grid size={12}>
<NodeTable />
+195
View File
@@ -0,0 +1,195 @@
// Map of 2-letter country codes to 3-letter country codes
export const countryCodeMap: Record<string, string> = {
AF: "AFG", // Afghanistan
AL: "ALB", // Albania
DZ: "DZA", // Algeria
AD: "AND", // Andorra
AO: "AGO", // Angola
AG: "ATG", // Antigua and Barbuda
AR: "ARG", // Argentina
AM: "ARM", // Armenia
AU: "AUS", // Australia
AT: "AUT", // Austria
AZ: "AZE", // Azerbaijan
BS: "BHS", // Bahamas
BH: "BHR", // Bahrain
BD: "BGD", // Bangladesh
BB: "BRB", // Barbados
BY: "BLR", // Belarus
BE: "BEL", // Belgium
BZ: "BLZ", // Belize
BJ: "BEN", // Benin
BT: "BTN", // Bhutan
BO: "BOL", // Bolivia
BA: "BIH", // Bosnia and Herzegovina
BW: "BWA", // Botswana
BR: "BRA", // Brazil
BN: "BRN", // Brunei
BG: "BGR", // Bulgaria
BF: "BFA", // Burkina Faso
BI: "BDI", // Burundi
KH: "KHM", // Cambodia
CM: "CMR", // Cameroon
CA: "CAN", // Canada
CV: "CPV", // Cape Verde
CF: "CAF", // Central African Republic
TD: "TCD", // Chad
CL: "CHL", // Chile
CN: "CHN", // China
CO: "COL", // Colombia
KM: "COM", // Comoros
CG: "COG", // Congo
CR: "CRI", // Costa Rica
HR: "HRV", // Croatia
CU: "CUB", // Cuba
CY: "CYP", // Cyprus
CZ: "CZE", // Czech Republic
DK: "DNK", // Denmark
DJ: "DJI", // Djibouti
DM: "DMA", // Dominica
DO: "DOM", // Dominican Republic
EC: "ECU", // Ecuador
EG: "EGY", // Egypt
SV: "SLV", // El Salvador
GQ: "GNQ", // Equatorial Guinea
ER: "ERI", // Eritrea
EE: "EST", // Estonia
ET: "ETH", // Ethiopia
FJ: "FJI", // Fiji
FI: "FIN", // Finland
FR: "FRA", // France
GA: "GAB", // Gabon
GM: "GMB", // Gambia
GE: "GEO", // Georgia
DE: "DEU", // Germany
GH: "GHA", // Ghana
GR: "GRC", // Greece
GD: "GRD", // Grenada
GT: "GTM", // Guatemala
GN: "GIN", // Guinea
GW: "GNB", // Guinea-Bissau
GY: "GUY", // Guyana
HT: "HTI", // Haiti
HN: "HND", // Honduras
HU: "HUN", // Hungary
IS: "ISL", // Iceland
IN: "IND", // India
ID: "IDN", // Indonesia
IR: "IRN", // Iran
IQ: "IRQ", // Iraq
IE: "IRL", // Ireland
IL: "ISR", // Israel
IT: "ITA", // Italy
JM: "JAM", // Jamaica
JP: "JPN", // Japan
JO: "JOR", // Jordan
KZ: "KAZ", // Kazakhstan
KE: "KEN", // Kenya
KI: "KIR", // Kiribati
KP: "PRK", // North Korea
KR: "KOR", // South Korea
KW: "KWT", // Kuwait
KG: "KGZ", // Kyrgyzstan
LA: "LAO", // Laos
LV: "LVA", // Latvia
LB: "LBN", // Lebanon
LS: "LSO", // Lesotho
LR: "LBR", // Liberia
LY: "LBY", // Libya
LI: "LIE", // Liechtenstein
LT: "LTU", // Lithuania
LU: "LUX", // Luxembourg
MG: "MDG", // Madagascar
MW: "MWI", // Malawi
MY: "MYS", // Malaysia
MV: "MDV", // Maldives
ML: "MLI", // Mali
MT: "MLT", // Malta
MH: "MHL", // Marshall Islands
MR: "MRT", // Mauritania
MU: "MUS", // Mauritius
MX: "MEX", // Mexico
FM: "FSM", // Micronesia
MD: "MDA", // Moldova
MC: "MCO", // Monaco
MN: "MNG", // Mongolia
ME: "MNE", // Montenegro
MA: "MAR", // Morocco
MZ: "MOZ", // Mozambique
MM: "MMR", // Myanmar
NA: "NAM", // Namibia
NR: "NRU", // Nauru
NP: "NPL", // Nepal
NL: "NLD", // Netherlands
NZ: "NZL", // New Zealand
NI: "NIC", // Nicaragua
NE: "NER", // Niger
NG: "NGA", // Nigeria
NO: "NOR", // Norway
OM: "OMN", // Oman
PK: "PAK", // Pakistan
PW: "PLW", // Palau
PA: "PAN", // Panama
PG: "PNG", // Papua New Guinea
PY: "PRY", // Paraguay
PE: "PER", // Peru
PH: "PHL", // Philippines
PL: "POL", // Poland
PT: "PRT", // Portugal
QA: "QAT", // Qatar
RO: "ROU", // Romania
RU: "RUS", // Russia
RW: "RWA", // Rwanda
KN: "KNA", // Saint Kitts and Nevis
LC: "LCA", // Saint Lucia
VC: "VCT", // Saint Vincent and the Grenadines
WS: "WSM", // Samoa
SM: "SMR", // San Marino
ST: "STP", // Sao Tome and Principe
SA: "SAU", // Saudi Arabia
SN: "SEN", // Senegal
RS: "SRB", // Serbia
SC: "SYC", // Seychelles
SL: "SLE", // Sierra Leone
SG: "SGP", // Singapore
SK: "SVK", // Slovakia
SI: "SVN", // Slovenia
SB: "SLB", // Solomon Islands
SO: "SOM", // Somalia
ZA: "ZAF", // South Africa
SS: "SSD", // South Sudan
ES: "ESP", // Spain
LK: "LKA", // Sri Lanka
SD: "SDN", // Sudan
SR: "SUR", // Suriname
SZ: "SWZ", // Swaziland
SE: "SWE", // Sweden
CH: "CHE", // Switzerland
SY: "SYR", // Syria
TW: "TWN", // Taiwan
TJ: "TJK", // Tajikistan
TZ: "TZA", // Tanzania
TH: "THA", // Thailand
TL: "TLS", // Timor-Leste
TG: "TGO", // Togo
TO: "TON", // Tonga
TT: "TTO", // Trinidad and Tobago
TN: "TUN", // Tunisia
TR: "TUR", // Turkey
TM: "TKM", // Turkmenistan
TV: "TUV", // Tuvalu
UG: "UGA", // Uganda
UA: "UKR", // Ukraine
AE: "ARE", // United Arab Emirates
GB: "GBR", // United Kingdom
US: "USA", // United States
UY: "URY", // Uruguay
UZ: "UZB", // Uzbekistan
VU: "VUT", // Vanuatu
VA: "VAT", // Vatican City
VE: "VEN", // Venezuela
VN: "VNM", // Vietnam
YE: "YEM", // Yemen
ZM: "ZMB", // Zambia
ZW: "ZWE", // Zimbabwe
};
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
"use client";
import { fetchAccountBalance, fetchNymPrice } from "@/app/api";
import { Skeleton, Stack, Typography } from "@mui/material";
import { Skeleton, Stack, Typography, useTheme } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import type { IRewardDetails } from "../../app/api/types";
import ExplorerCard from "../cards/ExplorerCard";
@@ -61,6 +61,8 @@ const calculateStakingRewards = (
export const AccountBalancesCard = (props: IAccountBalancesCardProps) => {
const { address } = props;
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
const {
data: accountInfo,
@@ -101,7 +103,10 @@ export const AccountBalancesCard = (props: IAccountBalancesCardProps) => {
if (isError || priceError || !accountInfo || !nymPrice) {
return (
<ExplorerCard label="Total value">
<Typography variant="h5" sx={{ color: "pine.600", letterSpacing: 0.7 }}>
<Typography
variant="h5"
sx={{ color: isDarkMode ? "base.white" : "pine.950" }}
>
Failed to account data.
</Typography>
<Skeleton variant="text" height={238} />
@@ -1,6 +1,6 @@
"use client";
import { fetchAccountBalance } from "@/app/api";
import { Box, Skeleton, Stack, Typography } from "@mui/material";
import { Box, Skeleton, Stack, Typography, useTheme } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import ExplorerCard from "../cards/ExplorerCard";
import CopyToClipboard from "../copyToClipboard/CopyToClipboard";
@@ -13,6 +13,8 @@ interface IAccountInfoCardProps {
export const AccountInfoCard = (props: IAccountInfoCardProps) => {
const { address } = props;
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
const { data, isLoading, isError } = useQuery({
queryKey: ["accountBalance", address],
@@ -38,7 +40,10 @@ export const AccountInfoCard = (props: IAccountInfoCardProps) => {
if (isError || !data) {
return (
<ExplorerCard label="Total NYM">
<Typography variant="h5" sx={{ color: "pine.600", letterSpacing: 0.7 }}>
<Typography
variant="h5"
sx={{ color: isDarkMode ? "base.white" : "pine.950" }}
>
Failed to account data.
</Typography>
<Skeleton variant="text" height={238} />
@@ -1,6 +1,7 @@
"use client";
import { fetchObservatoryNodes } from "@/app/api";
import { fetchNSApiNodes } from "@/app/api";
import type { NS_NODE } from "@/app/api/types";
import ExplorerButtonGroup from "@/components/toggleButton/ToggleButton";
import { useQuery } from "@tanstack/react-query";
@@ -9,18 +10,20 @@ type Props = {
};
export default function AccountPageButtonGroup({ address }: Props) {
const { data: nymNodes, isError } = useQuery({
queryKey: ["nymNodes"],
queryFn: fetchObservatoryNodes,
const { data: nsApiNodes = [], isError: isNSApiNodesError } = useQuery({
queryKey: ["nsApiNodes"],
queryFn: fetchNSApiNodes,
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
refetchOnMount: false,
});
if (!nymNodes || isError) return null;
if (!nsApiNodes || isNSApiNodesError) return null;
const nymNode = nymNodes.find((node) => node.bonding_address === address);
const nymNode = nsApiNodes.find(
(node: NS_NODE) => node.bonding_address === address,
);
if (!nymNode) return null;
@@ -0,0 +1,198 @@
"use client";
import { getBanner } from "@/app/features/banner/api/getBanner";
import type { components } from "@/app/lib/strapi";
import { Close, Launch } from "@mui/icons-material";
import { Box, Button, IconButton, Stack, Typography } from "@mui/material";
import { useEffect, useState } from "react";
import { Link } from "../muiLink";
import { Wrapper } from "../wrapper";
type BannerData = {
id?: number;
attributes?: components["schemas"]["ExplorerBanner"];
} | null;
export const Banner = () => {
const [bannerData, setBannerData] = useState<BannerData>(null);
const [isVisible, setIsVisible] = useState(true);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchBanner = async () => {
try {
const data = await getBanner("en");
setBannerData(data);
setIsLoading(false);
} catch (error) {
console.error("Failed to fetch banner:", error);
setIsLoading(false);
}
};
fetchBanner();
}, []);
const handleClose = () => {
setIsVisible(false);
};
// Don't render if not visible
if (!isVisible) {
return null;
}
// Only show banner if API data is available and show is true
const shouldShowBanner = bannerData?.attributes?.show === true;
const hasValidData = bannerData?.attributes && shouldShowBanner;
// If loading and no data yet, don't show anything
if (isLoading && !bannerData) {
return null;
}
// If API data says don't show and we have valid data, don't show
if (hasValidData && !shouldShowBanner) {
return null;
}
// If no valid data from API, don't show anything
if (!hasValidData) {
return null;
}
const { title, text, links, icon } = bannerData.attributes || {};
return (
<Box
sx={{
backgroundColor: "accent.main",
color: "base.black",
py: { xs: 1.5, md: 2 },
borderBottom: "1px solid",
borderColor: "divider",
}}
>
<Wrapper>
<Stack
direction={{ xs: "column", md: "row" }}
alignItems={{ xs: "flex-start", md: "center" }}
justifyContent="space-between"
spacing={{ xs: 1.5, md: 2 }}
>
<Stack
direction="row"
alignItems="center"
spacing={3}
flex={1}
sx={{ width: "100%" }}
>
{/* Icon */}
{icon?.data?.attributes?.url && (
<Box
component="img"
src={icon.data.attributes.url}
alt={icon.data.attributes.alternativeText || "Banner icon"}
sx={{
width: { xs: 20, md: 24 },
height: { xs: 20, md: 24 },
flexShrink: 0,
}}
/>
)}
{/* Content */}
<Box flex={1}>
<Typography
variant="subtitle1"
sx={{
fontWeight: 600,
mb: 0.5,
fontSize: { xs: "0.875rem", md: "1rem" },
}}
>
{title}
</Typography>
<Typography
variant="body2"
sx={{
opacity: 0.9,
fontSize: { xs: "0.75rem", md: "0.875rem" },
}}
>
{text}
</Typography>
</Box>
</Stack>
{/* Actions */}
<Stack
direction="row"
alignItems="center"
spacing={1}
sx={{
alignSelf: { xs: "flex-end", md: "center" },
width: { xs: "auto", md: "auto" },
}}
>
{/* Links */}
{links && links.length > 0 && (
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={1}
sx={{ flexWrap: "wrap" }}
>
{links.map((link) => (
<Link
key={link.id}
href={link.url || "#"}
target={link.url?.startsWith("http") ? "_blank" : "_self"}
rel={
link.url?.startsWith("http") ? "noopener noreferrer" : ""
}
style={{ textDecoration: "none" }}
>
<Button
variant="outlined"
size="small"
endIcon={
link.url?.startsWith("http") ? <Launch /> : undefined
}
sx={{
color: "base.black",
borderColor: "base.black",
fontSize: { xs: "0.75rem", md: "0.875rem" },
px: { xs: 1, md: 2 },
py: { xs: 0.5, md: 1 },
"&:hover": {
borderColor: "base.black",
backgroundColor: "rgba(0, 0, 0, 0.1)",
},
}}
>
{link.title}
</Button>
</Link>
))}
</Stack>
)}
{/* Close button */}
<IconButton
onClick={handleClose}
size="small"
sx={{
color: "base.black",
"&:hover": {
backgroundColor: "rgba(0, 0, 0, 0.1)",
},
}}
>
<Close sx={{ fontSize: { xs: "1.25rem", md: "1.5rem" } }} />
</IconButton>
</Stack>
</Stack>
</Wrapper>
</Box>
);
};
@@ -26,7 +26,6 @@ const BlogArticlesCards = async ({
const blogArticle = JSON.parse(fileContent);
return {
...blogArticle,
link: `/onboarding/${filename.replace(".json", "")}`,
};
}),
);
+2 -3
View File
@@ -8,6 +8,7 @@ type BlogArticle = {
image: string;
iconLight: string;
iconDark: string;
link: string;
attributes: {
blogAuthors: string[];
date: Date;
@@ -23,8 +24,6 @@ type BlogArticle = {
}[];
};
export type BlogArticleWithLink = BlogArticle & {
link: string;
};
export type BlogArticleWithLink = BlogArticle;
export default BlogArticle;
@@ -49,9 +49,9 @@ const ExplorerHeroCard = ({
const dynamicCardStyles = {
...cardStyles,
bgcolor: isDarkMode ? "pine.300" : "background.paper",
bgcolor: isDarkMode ? "#EFFFF0" : "background.paper",
"&:hover": {
bgcolor: isDarkMode ? "pine.600" : "accent.main",
bgcolor: isDarkMode ? "#C2FFC7" : "#E5E7EB",
},
...sx,
};
@@ -68,7 +68,12 @@ const ExplorerHeroCard = ({
const iconSrc = isDarkMode ? iconDarkSrc : iconLightSrc;
return (
<Link href={link} sx={{ textDecoration: "none", height: "100%" }}>
<Link
href={link}
sx={{ textDecoration: "none", height: "100%" }}
target="_blank"
rel="noopener noreferrer"
>
<Card sx={dynamicCardStyles} elevation={0}>
<CardHeader
title={
@@ -1,5 +1,6 @@
"use client";
import CheckIcon from "@mui/icons-material/Check";
import { IconButton, Typography, useTheme } from "@mui/material";
import { useCopyToClipboard } from "@uidotdev/usehooks";
import { useEffect } from "react";
@@ -34,12 +35,8 @@ const CopyToClipboard = ({
if (hasCopied) {
return (
<Typography
sx={{ color: isDarkMode ? "base.white" : "pine.950" }}
variant="h6"
color="textSecondary"
>
Copied
<Typography sx={{ color: isDarkMode ? "base.white" : "pine.950" }}>
<CheckIcon fontSize="small" />
</Typography>
);
}
@@ -23,7 +23,7 @@ export const SocialChannels = () => {
width: "32px",
height: "32px",
color: "background.main",
backgroundColor: "light.main",
backgroundColor: "secondary.main",
"&:hover": {
backgroundColor: "background.default",
},
+1 -1
View File
@@ -15,7 +15,7 @@ export async function Footer() {
const locale = "en";
const footerData = await getFooter(locale);
const legalContent1 =
"Nym Noise Generating Network Explorer, V 2.1.0 Public Beta release.";
"Nym Noise Generating Network Explorer, V 2.2.0 Public Beta release.";
const legalContent2 = footerData?.attributes?.legalContent2 || false;
const footerLinkBlocks = footerData?.attributes?.linkBlocks || [];
+8 -2
View File
@@ -1,12 +1,18 @@
import { Box } from "@mui/material";
import { DesktopHeader } from "./DesktopHeader";
import { MobileHeader } from "./MobileHeader";
export const Header = async () => {
return (
<header>
<Box
component="header"
sx={{
backgroundColor: "background.default",
}}
>
<DesktopHeader />
<MobileHeader />
{/* Mobile header will go here */}
</header>
</Box>
);
};
@@ -0,0 +1,23 @@
"use client";
import Grid from "@mui/material/Grid2";
import type { ReactNode } from "react";
interface ConditionalCardWrapperProps {
children: ReactNode;
size?:
| number
| { xs?: number; sm?: number; md?: number; lg?: number; xl?: number };
visible?: boolean;
}
export const ConditionalCardWrapper = ({
children,
size,
visible = true,
}: ConditionalCardWrapperProps) => {
if (!visible) {
return null;
}
return <Grid size={size}>{children}</Grid>;
};
@@ -4,7 +4,7 @@ import {
type EpochResponseData,
useEpochContext,
} from "@/providers/EpochProvider";
import { Skeleton, Typography } from "@mui/material";
import { Skeleton, Typography, useTheme } from "@mui/material";
import { differenceInMinutes, format } from "date-fns";
import { useCallback, useEffect, useState } from "react";
import ExplorerCard from "../cards/ExplorerCard";
@@ -34,6 +34,9 @@ export const CurrentEpochCard = () => {
const [endTime, setEndTime] = useState("");
const [progress, setProgress] = useState(0);
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
const updateState = useCallback((data: NonNullable<EpochResponseData>) => {
const { startTime, endTime } = getStartEndTime(
data.current_epoch_start,
@@ -65,30 +68,28 @@ export const CurrentEpochCard = () => {
);
}
if (isError) {
if (isError || !data) {
return (
<ExplorerCard label="Current mixnet epoch">
<Typography variant="body3" fontWeight="light">
<Typography
variant="h5"
sx={{ color: isDarkMode ? "base.white" : "pine.950" }}
>
Failed to load data
</Typography>
</ExplorerCard>
);
}
if (!data) {
return (
<ExplorerCard label="Current mixnet epoch">
<Typography variant="body3" fontWeight="light">
No data available
</Typography>
</ExplorerCard>
);
}
if (epochStatus === "pending") {
return (
<ExplorerCard label="Current mixnet epoch">
<Typography variant="body3" fontWeight="light" height={80}>
<Typography
variant="body3"
fontWeight="light"
height={80}
sx={{ color: isDarkMode ? "base.white" : "pine.950" }}
>
Waiting for next epoch to start...
</Typography>
</ExplorerCard>
@@ -0,0 +1,19 @@
"use client";
import { useEpochContext } from "@/providers/EpochProvider";
import { ConditionalCardWrapper } from "./ConditionalCardWrapper";
import { CurrentEpochCard } from "./CurrentEpochCard";
export const CurrentEpochCardWrapper = () => {
const { data, isError, isLoading, epochStatus } = useEpochContext();
// Determine if the card should be visible
// Show the card if we have data and it's not in a pending state, or if we're still loading
const isVisible =
!isError && (data || isLoading) && epochStatus !== "pending";
return (
<ConditionalCardWrapper size={12} visible={isVisible}>
<CurrentEpochCard />
</ConditionalCardWrapper>
);
};
@@ -33,21 +33,14 @@ export const NetworkStakeCard = () => {
);
}
if (isStakingError || !packetsAndStaking) {
return (
<ExplorerCard label="Current network stake">
<Typography
variant="h5"
sx={{
color: isDarkMode ? "base.white" : "pine.950",
letterSpacing: 0.7,
}}
>
Failed to load data
</Typography>
<Skeleton variant="text" height={238} />
</ExplorerCard>
);
// Don't display the card if there's an error or insufficient data
if (
isStakingError ||
!packetsAndStaking ||
!Array.isArray(packetsAndStaking) ||
packetsAndStaking.length < 10
) {
return null;
}
const packetsAndStakingData: ExplorerData["packetsAndStakingData"] =
@@ -0,0 +1,25 @@
"use client";
import { fetchNoise } from "@/app/api";
import { useQuery } from "@tanstack/react-query";
import { ConditionalCardWrapper } from "./ConditionalCardWrapper";
import { NetworkStakeCard } from "./NetworkStakeCard";
export const NetworkStakeCardWrapper = () => {
const { data, isLoading, isError } = useQuery({
queryKey: ["noise"],
queryFn: fetchNoise,
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
// Determine if the card should be visible
const isVisible =
!isLoading && !isError && data && Array.isArray(data) && data.length >= 10;
return (
<ConditionalCardWrapper size={{ xs: 12, sm: 6, lg: 3 }} visible={isVisible}>
<NetworkStakeCard />
</ConditionalCardWrapper>
);
};
@@ -41,31 +41,21 @@ export const NoiseCard = () => {
);
}
if (isError || !data) {
return (
<ExplorerCard label="Mixnet traffic">
<Typography
variant="h5"
sx={{
color: isDarkMode ? "base.white" : "pine.950",
letterSpacing: 0.7,
}}
>
Failed to load data
</Typography>
<Skeleton variant="text" height={238} />
</ExplorerCard>
);
// Don't display the card if there's an error or insufficient data
if (isError || !data || !Array.isArray(data) || data.length < 10) {
return null;
}
const todaysData = data[data.length - 2];
const yesterdaysData = data[data.length - 3];
const noiseLast24H =
todaysData.total_packets_sent + todaysData.total_packets_received;
(todaysData?.total_packets_sent || 0) +
(todaysData?.total_packets_received || 0);
const noisePrevious24H =
yesterdaysData.total_packets_sent + yesterdaysData.total_packets_received;
(yesterdaysData?.total_packets_sent || 0) +
(yesterdaysData?.total_packets_received || 0);
const formatNoiseVolume = (packets: number): string => {
if (packets < 0) {
@@ -107,8 +97,9 @@ export const NoiseCard = () => {
.slice(0, -1)
.map((item: IPacketsAndStakingData) => {
return {
date_utc: item.date_utc,
numericData: item.total_packets_sent + item.total_packets_received,
date_utc: item?.date_utc,
numericData:
(item?.total_packets_sent || 0) + (item?.total_packets_received || 0),
};
})
.filter((item) => item.numericData >= 2_500_000_000);
@@ -0,0 +1,25 @@
"use client";
import { fetchNoise } from "@/app/api";
import { useQuery } from "@tanstack/react-query";
import { ConditionalCardWrapper } from "./ConditionalCardWrapper";
import { NoiseCard } from "./NoiseCard";
export const NoiseCardWrapper = () => {
const { data, isLoading, isError } = useQuery({
queryKey: ["noise"],
queryFn: fetchNoise,
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
// Determine if the card should be visible
const isVisible =
!isLoading && !isError && data && Array.isArray(data) && data.length >= 10;
return (
<ConditionalCardWrapper size={{ xs: 12, sm: 6, lg: 3 }} visible={isVisible}>
<NoiseCard />
</ConditionalCardWrapper>
);
};
@@ -1,18 +1,18 @@
"use client";
import { fetchObservatoryNodes } from "@/app/api";
import type { IObservatoryNode } from "@/app/api/types";
import { fetchNSApiNodes } from "@/app/api";
import type { NS_NODE } from "@/app/api/types";
import { Skeleton, Typography, useTheme } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import ExplorerCard from "../cards/ExplorerCard";
export const StakersNumberCard = () => {
const {
data: nymNodes,
isLoading,
isError,
data: nsApiNodes = [],
isLoading: isNSApiNodesLoading,
isError: isNSApiNodesError,
} = useQuery({
queryKey: ["nymNodes"],
queryFn: () => fetchObservatoryNodes(),
queryKey: ["nsApiNodes"],
queryFn: fetchNSApiNodes,
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
@@ -22,7 +22,7 @@ export const StakersNumberCard = () => {
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
if (isLoading) {
if (isNSApiNodesLoading) {
return (
<ExplorerCard label="Number of delegations">
<Skeleton variant="text" height={90} />
@@ -30,11 +30,11 @@ export const StakersNumberCard = () => {
);
}
if (isError || !nymNodes) {
if (isNSApiNodesError || !nsApiNodes) {
return (
<ExplorerCard label="Number of delegations">
<Typography
variant="h3"
variant="h5"
sx={{ color: isDarkMode ? "base.white" : "pine.950" }}
>
Failed to load node data.
@@ -43,13 +43,13 @@ export const StakersNumberCard = () => {
);
}
const getActiveStakersNumber = (nodes: IObservatoryNode[]): number => {
const getActiveStakersNumber = (nodes: NS_NODE[]): number => {
return nodes.reduce(
(sum, node) => sum + node.rewarding_details.unique_delegations,
(sum, node) => sum + (node.rewarding_details?.unique_delegations || 0),
0,
);
};
const allStakers = getActiveStakersNumber(nymNodes);
const allStakers = getActiveStakersNumber(nsApiNodes);
return (
<ExplorerCard label="Number of delegations">
@@ -0,0 +1,26 @@
"use client";
import { fetchNSApiNodes } from "@/app/api";
import { useQuery } from "@tanstack/react-query";
import { ConditionalCardWrapper } from "./ConditionalCardWrapper";
import { StakersNumberCard } from "./StakersNumberCard";
export const StakersNumberCardWrapper = () => {
const { data, isLoading, isError } = useQuery({
queryKey: ["nsApiNodes"],
queryFn: fetchNSApiNodes,
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
});
// Determine if the card should be visible
const isVisible =
!isLoading && !isError && data && Array.isArray(data) && data.length > 0;
return (
<ConditionalCardWrapper size={12} visible={isVisible}>
<StakersNumberCard />
</ConditionalCardWrapper>
);
};
@@ -1,7 +1,7 @@
"use client";
import { fetchEpochRewards, fetchNoise, fetchNymPrice } from "@/app/api";
import { formatBigNum } from "@/utils/formatBigNumbers";
import { Box, Skeleton, Stack, Typography } from "@mui/material";
import { Box, Skeleton, Stack, Typography, useTheme } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import type { ExplorerData, NymTokenomics } from "../../app/api/types";
import ExplorerCard from "../cards/ExplorerCard";
@@ -9,6 +9,8 @@ import ExplorerListItem from "../list/ListItem";
import { TitlePrice } from "../price/TitlePrice";
export const TokenomicsCard = () => {
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
const {
data: nymPrice,
isLoading,
@@ -69,7 +71,10 @@ export const TokenomicsCard = () => {
) {
return (
<ExplorerCard label="Tokenomics overview">
<Typography variant="h5" sx={{ color: "pine.600", letterSpacing: 0.7 }}>
<Typography
variant="h5"
sx={{ color: isDarkMode ? "base.white" : "pine.950" }}
>
Failed to load tokenomics overview.
</Typography>
<Skeleton variant="text" height={80} />
@@ -0,0 +1,64 @@
"use client";
import { fetchEpochRewards, fetchNoise, fetchNymPrice } from "@/app/api";
import { useQuery } from "@tanstack/react-query";
import { ConditionalCardWrapper } from "./ConditionalCardWrapper";
import { TokenomicsCard } from "./TokenomicsCard";
export const TokenomicsCardWrapper = () => {
const {
data: nymPrice,
isLoading: isPriceLoading,
isError: isPriceError,
} = useQuery({
queryKey: ["nymPrice"],
queryFn: fetchNymPrice,
staleTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
});
const {
data: epochRewards,
isLoading: isEpochLoading,
isError: isEpochError,
} = useQuery({
queryKey: ["epochRewards"],
queryFn: fetchEpochRewards,
staleTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
});
const {
data: packetsAndStaking,
isLoading: isStakingLoading,
isError: isStakingError,
} = useQuery({
queryKey: ["noise"],
queryFn: fetchNoise,
staleTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
});
// Determine if the card should be visible
const isLoading = isPriceLoading || isEpochLoading || isStakingLoading;
const hasError = isPriceError || isEpochError || isStakingError;
const hasData =
nymPrice &&
epochRewards &&
packetsAndStaking &&
Array.isArray(packetsAndStaking) &&
packetsAndStaking.length >= 2;
const isVisible = !hasError && (hasData || isLoading);
return (
<ConditionalCardWrapper size={{ xs: 12, sm: 6, lg: 3 }} visible={isVisible}>
<TokenomicsCard />
</ConditionalCardWrapper>
);
};
@@ -0,0 +1,341 @@
"use client";
import {
Box,
Button,
Collapse,
Slider,
Typography,
useTheme,
} from "@mui/material";
import Grid from "@mui/material/Grid2";
import React from "react";
import AccessTimeIcon from "@mui/icons-material/AccessTime";
import FilterAltIcon from "@mui/icons-material/FilterAlt";
import PercentIcon from "@mui/icons-material/Percent";
import PieChartIcon from "@mui/icons-material/PieChart";
import NodeFilterButtonGroup from "../toggleButton/NodeFilterButtonGroup";
type AdvancedFiltersProps = {
uptime: [number, number];
setUptime: (value: [number, number]) => void;
saturation: [number, number];
setSaturation: (value: [number, number]) => void;
profitMargin: [number, number];
setProfitMargin: (value: [number, number]) => void;
open?: boolean;
setOpen?: (open: boolean) => void;
maxSaturation?: number;
activeFilter: "all" | "mixnodes" | "gateways" | "recommended";
setActiveFilter: (
filter: "all" | "mixnodes" | "gateways" | "recommended",
) => void;
nodeCounts: {
all: number;
mixnodes: number;
gateways: number;
};
/** Count of recommended nodes (passed from server) */
recommendedCount: number;
};
export default function AdvancedFilters({
uptime,
setUptime,
saturation,
setSaturation,
profitMargin,
setProfitMargin,
open,
setOpen,
maxSaturation = 100,
activeFilter,
setActiveFilter,
nodeCounts,
recommendedCount,
}: AdvancedFiltersProps) {
const theme = useTheme();
const green = "#14e76f"; // from theme colours
const marksPercent: { value: number }[] = [{ value: 0 }, { value: 100 }];
const marksSaturation: { value: number }[] = [
{ value: 0 },
{ value: maxSaturation },
];
const panel = (
<Box
sx={{
mt: 3,
p: 2,
borderRadius: 3,
background: theme.palette.background.paper,
border: `1px solid ${theme.palette.divider}`,
}}
>
<Typography
variant="subtitle1"
sx={{
fontStyle: "italic",
color:
theme.palette.mode === "light"
? theme.palette.common.black
: theme.palette.common.white,
mb: 2,
}}
>
Advanced filtering mode is active
</Typography>
<Grid container spacing={3}>
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
<Box
sx={{
p: 3,
borderRadius: 3,
background: theme.palette.background.default,
mb: { xs: 2, sm: 0 },
border: `1px solid ${theme.palette.divider}`,
}}
>
<Box display="flex" alignItems="center" mb={1}>
<AccessTimeIcon
sx={{
color:
theme.palette.mode === "dark"
? theme.palette.common.white
: theme.palette.common.black,
mr: 1,
}}
/>
<Typography
variant="h6"
sx={{ color: theme.palette.text.primary, fontSize: 17 }}
>
Uptime
</Typography>
<Box flexGrow={1} />
<Typography
variant="h6"
sx={{ color: theme.palette.primary.main, fontSize: 17 }}
>
{uptime[0]}% - {uptime[1]}%
</Typography>
</Box>
<Slider
value={uptime}
onChange={(_, v) => setUptime(v as [number, number])}
valueLabelDisplay="off"
min={0}
max={100}
marks={marksPercent}
sx={{
color: green,
height: 8,
"& .MuiSlider-thumb": {
width: 24,
height: 24,
backgroundColor: green,
},
}}
/>
</Box>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
<Box
sx={{
p: 3,
borderRadius: 3,
background: theme.palette.background.default,
mb: { xs: 2, sm: 0 },
border: `1px solid ${theme.palette.divider}`,
}}
>
<Box display="flex" alignItems="center" mb={1}>
<PieChartIcon
sx={{
color:
theme.palette.mode === "dark"
? theme.palette.common.white
: theme.palette.common.black,
mr: 1,
}}
/>
<Typography
variant="h6"
sx={{ color: theme.palette.text.primary, fontSize: 17 }}
>
Saturation
</Typography>
<Box flexGrow={1} />
<Typography
variant="h6"
sx={{ color: theme.palette.primary.main, fontSize: 17 }}
>
{saturation[0]}% - {saturation[1]}%
</Typography>
</Box>
<Slider
value={saturation}
onChange={(_, v) => setSaturation(v as [number, number])}
valueLabelDisplay="off"
min={0}
max={maxSaturation}
marks={marksSaturation}
sx={{
color: green,
height: 8,
"& .MuiSlider-thumb": {
width: 24,
height: 24,
backgroundColor: green,
},
}}
/>
</Box>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 4 }} sx={{ mx: { sm: "auto" } }}>
<Box
sx={{
p: 3,
borderRadius: 3,
background: theme.palette.background.default,
mb: { xs: 2, sm: 0 },
border: `1px solid ${theme.palette.divider}`,
}}
>
<Box display="flex" alignItems="center" mb={1}>
<PercentIcon
sx={{
color:
theme.palette.mode === "dark"
? theme.palette.common.white
: theme.palette.common.black,
mr: 1,
}}
/>
<Typography
variant="h6"
sx={{ color: theme.palette.text.primary, fontSize: 17 }}
>
Profit Margin
</Typography>
<Box flexGrow={1} />
<Typography
variant="h6"
sx={{ color: theme.palette.primary.main, fontSize: 17 }}
>
{profitMargin[0]}% - {profitMargin[1]}%
</Typography>
</Box>
<Slider
value={profitMargin}
onChange={(_, v) => setProfitMargin(v as [number, number])}
valueLabelDisplay="off"
min={0}
max={100}
marks={marksPercent}
sx={{
color: green,
height: 8,
"& .MuiSlider-thumb": {
width: 24,
height: 24,
backgroundColor: green,
},
}}
/>
</Box>
</Grid>
</Grid>
</Box>
);
return (
<Box sx={{ width: "100%" }}>
<Box
sx={{
display: "flex",
flexDirection: { xs: "column", sm: "row" },
alignItems: { xs: "stretch", sm: "center" },
gap: 2,
}}
>
<Box sx={{ width: { xs: "100%", sm: "auto" } }}>
<NodeFilterButtonGroup
size="medium"
options={[
{
label: `Recommended servers (${recommendedCount})`,
isSelected: activeFilter === "recommended",
value: "recommended",
},
{
label: `All servers (${nodeCounts.all})`,
isSelected: activeFilter === "all",
value: "all",
},
{
label: `Mixnodes (${nodeCounts.mixnodes})`,
isSelected: activeFilter === "mixnodes",
value: "mixnodes",
},
{
label: `Gateways (${nodeCounts.gateways})`,
isSelected: activeFilter === "gateways",
value: "gateways",
},
]}
onPage={activeFilter}
onFilterChange={setActiveFilter}
/>
</Box>
<Button
variant="outlined"
color="inherit"
startIcon={
<FilterAltIcon
sx={{
color:
theme.palette.mode === "light"
? `${theme.palette.common.black} !important`
: `${theme.palette.common.white} !important`,
}}
/>
}
onClick={() => setOpen?.(!open)}
sx={{
borderRadius: 3,
px: 4,
py: 1.5,
color:
theme.palette.mode === "light"
? `${theme.palette.common.black} !important`
: `${theme.palette.common.white} !important`,
borderColor:
theme.palette.mode === "light"
? theme.palette.common.black
: theme.palette.common.white,
background: "none",
fontWeight: 500,
fontSize: 16,
"&:hover, &:focus": {
background:
theme.palette.mode === "light"
? "rgba(0,0,0,0.04)"
: "rgba(255,255,255,0.05)",
borderColor:
theme.palette.mode === "light"
? theme.palette.grey[400]
: theme.palette.common.white,
},
}}
>
Advanced Filters
</Button>
</Box>
<Collapse in={open} timeout="auto" unmountOnExit>
{panel}
</Collapse>
</Box>
);
}
@@ -4,6 +4,7 @@ import {
Box,
Button,
Stack,
Tooltip,
Typography,
useMediaQuery,
useTheme,
@@ -18,8 +19,10 @@ import {
import { useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { colours } from "@/theme/colours";
import { COSMOS_KIT_USE_CHAIN } from "../../config";
import { useNymClient } from "../../hooks/useNymClient";
import CopyToClipboard from "../copyToClipboard/CopyToClipboard";
import CountryFlag from "../countryFlag/CountryFlag";
import { Favorite } from "../favorite/Favorite";
import Loading from "../loading";
@@ -189,54 +192,6 @@ const NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
},
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",
@@ -255,6 +210,98 @@ const NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
"-"
),
},
{
id: "name",
header: "",
size: 210,
Header: <ColumnHeading>Node</ColumnHeading>,
accessorKey: "name",
Cell: ({ row }) => (
<Stack spacing={1}>
<Typography variant="body4">{row.original.name || "-"}</Typography>
<Tooltip
title={row.original.identity_key}
placement="bottom"
slotProps={{
tooltip: {
sx: {
maxWidth: "none",
whiteSpace: "nowrap",
bgcolor: isDarkMode ? "#374042" : "#E5E7EB",
color: isDarkMode ? "#FFFFFF" : "#000000",
"& .MuiTooltip-arrow": {
color: isDarkMode ? "#374042" : "#E5E7EB",
},
},
},
}}
>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
onClick={(e) => e.stopPropagation()}
sx={{ height: "24px" }}
>
<Typography
variant="body5"
sx={{
height: "24px",
display: "flex",
alignItems: "center",
}}
>
{row.original.identity_key.length > 17
? `${row.original.identity_key.slice(0, 10)}...${row.original.identity_key.slice(-8)}`
: row.original.identity_key}
</Typography>
<CopyToClipboard text={row.original.identity_key} />
</Stack>
</Tooltip>
</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: "qos",
header: "Qlt of Service",
align: "center",
accessorKey: "qualityOfService",
size: 100,
Header: <ColumnHeading>Uptime</ColumnHeading>,
Cell: ({ row }) => {
const value = row.original.qualityOfService;
let color = "#000000";
if (value >= 80) {
color = "#22C55E"; // green
} else if (value >= 50) {
color = "#F59E0B"; // amber/orange-yellow
} else {
color = "#EF4444"; // red
}
return (
<Typography variant="body4" sx={{ color, fontWeight: 400 }}>
{value.toFixed()}%
</Typography>
);
},
},
{
id: "stakeSaturation",
header: "Stake saturation",
@@ -262,9 +309,58 @@ const NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
size: 120,
Header: <ColumnHeading>Saturation</ColumnHeading>,
Cell: ({ row }) => {
const value = row.original.stakeSaturation;
let color = "#000000";
if (value > 100) {
color = "#EF4444";
} else if (value >= 75) {
color = "#22C55E";
} else if (value >= 25) {
color = "#F59E0B";
} else {
color = "#EF4444";
}
return (
<Typography variant="body4" sx={{ color, fontWeight: 400 }}>
{value}%
</Typography>
);
},
},
{
id: "selfBond",
header: "Self-bond",
accessorKey: "selfBond",
Header: <ColumnHeading>Self-bond</ColumnHeading>,
Cell: ({ row }) => {
const value = row.original.selfBond;
let color = isDarkMode ? "#FFFFFF" : "#000000";
if (value === 0) {
color = "#EF4444";
}
return (
<Typography
variant="body4"
sx={{ color, fontWeight: value === 0 ? 400 : 300 }}
>
{row.original.selfBond} NYM
</Typography>
);
},
},
{
id: "operatingCosts",
header: "Operating costs",
accessorKey: "operatingCosts",
Header: <ColumnHeading>Operating costs</ColumnHeading>,
Cell: ({ row }) => (
<Typography variant="body4">
{row.original.stakeSaturation}%
{row.original.operatingCosts} NYM
</Typography>
),
},
@@ -303,7 +399,7 @@ const NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
enableSorting: false,
},
],
[isWalletConnected, handleOnSelectStake, favorites],
[isWalletConnected, handleOnSelectStake, favorites, isDarkMode],
);
const table = useMaterialReactTable({
columns,
@@ -342,9 +438,10 @@ const NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
bgcolor: isDarkMode ? "#0F1720" : "background.paper",
},
},
muiTableHeadRowProps: {
sx: {
bgcolor: isDarkMode ? "#374042" : "background.paper",
bgcolor: isDarkMode ? "background.default" : "background.paper",
},
},
muiTableHeadCellProps: {
@@ -410,20 +507,29 @@ const NodeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
},
},
muiTableBodyRowProps: ({ row }) => ({
onClick: () => {
router.push(`/nym-node/${row.original.nodeId}`);
onClick: (e) => {
if (e.ctrlKey || e.metaKey) {
window.open(`/explorer/nym-node/${row.original.nodeId}`, "_blank");
} else {
router.push(`/nym-node/${row.original.nodeId}`);
}
},
hover: true,
sx: {
backgroundColor: isDarkMode
? row.index % 2 === 0
? "#3E4A4C !important"
: "#374042 !important"
: row.index % 2 === 0
? "#F3F7FB"
: "white",
":nth-child(even)": {
bgcolor:
theme.palette.mode === "dark"
? `${colours.pine[950]} !important`
: `${colours.base.white} !important`,
},
":nth-child(odd)": {
bgcolor:
theme.palette.mode === "dark"
? `${colours.pine[800]} !important`
: `${colours.haze[25]} !important`,
},
"&:hover": {
backgroundColor: `${isDarkMode ? "#2A3436" : "#E5E7EB"} !important`,
backgroundColor: `${theme.palette.mode === "dark" ? "#004449" : "#E5E7EB"} !important`,
transition: "background-color 0.2s ease",
},
cursor: "pointer",
@@ -3,61 +3,150 @@
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 { 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";
// 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[],
const mappedNSApiNodes = (
nodes: NS_NODE[],
epochRewardsData: ExplorerData["currentEpochRewardsData"],
) =>
nodes.map((node) => {
const nodeSaturationPoint = getNodeSaturationPoint(
node.total_stake,
epochRewardsData.interval.stake_saturation_point,
);
nodes
.map((node) => {
const nodeSaturationPoint = getNodeSaturationPoint(
+node.total_stake,
epochRewardsData.interval.stake_saturation_point,
);
const cleanMoniker = DOMPurify.sanitize(
node.self_description.moniker,
).replace(/&amp;/g, "&");
const cleanMoniker = DOMPurify.sanitize(node.description.moniker).replace(
/&amp;/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,
};
});
const selfBondFormatted = node.original_pledge
? Number(node.original_pledge) / 1_000_000
: 0;
export type MappedNymNodes = ReturnType<typeof mappedNymNodes>;
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,
@@ -67,27 +156,57 @@ const NodeTableWithAction = () => {
queryKey: ["epochRewards"],
queryFn: fetchEpochRewards,
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
});
// Use React Query to fetch Nym nodes
const {
data: nymNodes = [],
isLoading: isNodesLoading,
isError: isNodesError,
data: nsApiNodes = [],
isLoading: isNSApiNodesLoading,
isError: isNSApiNodesError,
} = useQuery({
queryKey: ["nymNodes"],
queryFn: fetchObservatoryNodes,
queryKey: ["nsApiNodes"],
queryFn: fetchNSApiNodes,
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
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 || isNodesLoading) {
if (isEpochLoading || isNSApiNodesLoading) {
return (
<Card sx={{ height: "100%", mt: 5 }}>
<CardContent>
@@ -101,7 +220,7 @@ const NodeTableWithAction = () => {
}
// Handle error state
if (isEpochError || isNodesError) {
if (isEpochError || isNSApiNodesError) {
return (
<Stack direction="row" spacing={1}>
<Typography variant="h5" sx={{ color: "pine.600", letterSpacing: 0.7 }}>
@@ -111,15 +230,70 @@ const NodeTableWithAction = () => {
);
}
// Map nodes with rewards data
if (!epochRewardsData) {
return null;
}
const data = mappedNymNodes(nymNodes || [], epochRewardsData);
// 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;
}
});
return <NodeTable nodes={data} />;
// 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;
@@ -1,9 +1,9 @@
"use client";
import type { IObservatoryNode } from "@/app/api/types";
import { Skeleton, Stack, Typography } from "@mui/material";
import type { NS_NODE } from "@/app/api/types";
import { Skeleton, Stack, Typography, useTheme } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import { fetchObservatoryNodes } from "../../app/api";
import { fetchNSApiNodes } from "../../app/api";
import { formatBigNum } from "../../utils/formatBigNumbers";
import ExplorerCard from "../cards/ExplorerCard";
import CopyToClipboard from "../copyToClipboard/CopyToClipboard";
@@ -14,22 +14,25 @@ type Props = {
};
export const BasicInfoCard = ({ paramId }: Props) => {
let nodeInfo: IObservatoryNode | undefined;
let nodeInfo: NS_NODE | undefined;
const {
data: nymNodes,
isLoading,
isError,
data: nsApiNodes = [],
isLoading: isNSApiNodesLoading,
isError: isNSApiNodesError,
} = useQuery({
queryKey: ["nymNodes"],
queryFn: fetchObservatoryNodes,
queryKey: ["nsApiNodes"],
queryFn: fetchNSApiNodes,
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
refetchOnMount: false,
});
if (isLoading) {
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
if (isNSApiNodesLoading) {
return (
<ExplorerCard label="Basic info">
<Skeleton variant="text" height={90} />
@@ -42,10 +45,13 @@ export const BasicInfoCard = ({ paramId }: Props) => {
);
}
if (isError || !nymNodes) {
if (!nsApiNodes || isNSApiNodesError) {
return (
<ExplorerCard label="Basic info">
<Typography variant="h3" sx={{ color: "pine.950" }}>
<Typography
variant="h5"
sx={{ color: isDarkMode ? "base.white" : "pine.950" }}
>
Failed to load node data.
</Typography>
</ExplorerCard>
@@ -55,16 +61,20 @@ export const BasicInfoCard = ({ paramId }: Props) => {
// get node info based on wether it's dentity_key or node_id
if (paramId.length > 10) {
nodeInfo = nymNodes.find((node) => node.identity_key === paramId);
nodeInfo = nsApiNodes.find(
(node: NS_NODE) => node.identity_key === paramId,
);
} else {
nodeInfo = nymNodes.find((node) => node.node_id === Number(paramId));
nodeInfo = nsApiNodes.find(
(node: NS_NODE) => node.node_id === Number(paramId),
);
}
if (!nodeInfo) return null;
const selfBond = formatBigNum(
Number(nodeInfo.rewarding_details.operator) / 1_000_000,
);
const selfBond = nodeInfo.original_pledge
? formatBigNum(Number(nodeInfo.original_pledge) / 1_000_000)
: 0;
const selfBondFormatted = `${selfBond} NYM`;
return (
@@ -85,9 +95,13 @@ export const BasicInfoCard = ({ paramId }: Props) => {
variant="body4"
sx={{ wordWrap: "break-word", maxWidth: "90%" }}
>
{nodeInfo.bonding_address}
{nodeInfo.bonding_address
? nodeInfo.bonding_address
: "Node not bonded"}
</Typography>
<CopyToClipboard text={nodeInfo.bonding_address} />
{nodeInfo.bonding_address && (
<CopyToClipboard text={nodeInfo.bonding_address} />
)}
</Stack>
}
/>
@@ -113,12 +127,14 @@ export const BasicInfoCard = ({ paramId }: Props) => {
}
/>
<ExplorerListItem
row
divider
label="Nr. of stakers"
value={nodeInfo.rewarding_details.unique_delegations.toString()}
/>
{nodeInfo.rewarding_details && (
<ExplorerListItem
row
divider
label="Nr. of stakers"
value={nodeInfo.rewarding_details.unique_delegations.toString()}
/>
)}
<ExplorerListItem row label="Self bonded" value={selfBondFormatted} />
</Stack>
</ExplorerCard>
@@ -1,6 +1,7 @@
"use client";
import { fetchNodeDelegations } from "@/app/api";
import { colours } from "@/theme/colours";
import { Stack, Typography, useTheme } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import {
@@ -36,6 +37,7 @@ type Props = {
const DelegationsTable = ({ id }: Props) => {
const router = useRouter();
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
const { data: delegations = [], isError } = useQuery({
queryKey: ["nodeDelegations", id],
@@ -55,7 +57,7 @@ const DelegationsTable = ({ id }: Props) => {
accessorKey: "height",
Cell: ({ row }) => (
<Stack spacing={1}>
<Typography variant="body4">{row.original.height}</Typography>
<Typography variant="body4">{row.original.block_height}</Typography>
</Stack>
),
},
@@ -125,10 +127,7 @@ const DelegationsTable = ({ id }: Props) => {
},
muiTableHeadRowProps: {
sx: {
bgcolor:
theme.palette.mode === "dark"
? "rgba(255, 255, 255, 0.05)"
: "background.paper",
bgcolor: isDarkMode ? "background.default" : "background.paper",
},
},
@@ -143,25 +142,23 @@ const DelegationsTable = ({ id }: Props) => {
},
hover: true,
sx: {
":nth-child(odd)": {
bgcolor:
theme.palette.mode === "dark"
? "rgba(255, 255, 255, 0.05) !important"
: "#F3F7FB !important",
},
":nth-child(even)": {
bgcolor:
theme.palette.mode === "dark"
? "transparent !important"
: "white !important",
? `${colours.pine[950]} !important`
: `${colours.base.white} !important`,
},
cursor: "pointer",
"&:hover": {
":nth-child(odd)": {
bgcolor:
theme.palette.mode === "dark"
? "rgba(255, 255, 255, 0.1) !important"
: "rgba(0, 0, 0, 0.04) !important",
? `${colours.pine[800]} !important`
: `${colours.haze[25]} !important`,
},
"&:hover": {
backgroundColor: `${theme.palette.mode === "dark" ? "#004449" : "#E5E7EB"} !important`,
transition: "background-color 0.2s ease",
},
cursor: "pointer",
},
}),
});
@@ -1,10 +1,10 @@
"use client";
import type { IObservatoryNode } from "@/app/api/types";
import { Skeleton, Typography } from "@mui/material";
import type { NS_NODE } from "@/app/api/types";
import { Skeleton, Typography, useTheme } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import { format } from "date-fns";
import { fetchEpochRewards, fetchObservatoryNodes } from "../../app/api";
import { fetchEpochRewards, fetchNSApiNodes } from "../../app/api";
import ExplorerCard from "../cards/ExplorerCard";
import ExplorerListItem from "../list/ListItem";
@@ -13,7 +13,7 @@ type Props = {
};
export const NodeDataCard = ({ paramId }: Props) => {
let nodeInfo: IObservatoryNode | undefined;
let nodeInfo: NS_NODE | undefined;
const {
data: epochRewardsData,
@@ -30,19 +30,22 @@ export const NodeDataCard = ({ paramId }: Props) => {
// Fetch node information
const {
data: nymNodes,
isLoading,
isError,
data: nsApiNodes = [],
isLoading: isNSApiNodesLoading,
isError: isNSApiNodesError,
} = useQuery({
queryKey: ["nymNodes"],
queryFn: fetchObservatoryNodes,
queryKey: ["nsApiNodes"],
queryFn: fetchNSApiNodes,
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
refetchOnMount: false,
});
if (isEpochLoading || isLoading) {
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
if (isEpochLoading || isNSApiNodesLoading) {
return (
<ExplorerCard label="Nym node data" sx={{ height: "100%" }}>
<Skeleton variant="text" height={50} />
@@ -53,10 +56,13 @@ export const NodeDataCard = ({ paramId }: Props) => {
);
}
if (isEpochError || isError || !nymNodes || !epochRewardsData) {
if (isEpochError || isNSApiNodesError || !nsApiNodes || !epochRewardsData) {
return (
<ExplorerCard label="Nym node data" sx={{ height: "100%" }}>
<Typography variant="h3" sx={{ color: "pine.950" }}>
<Typography
variant="h5"
sx={{ color: isDarkMode ? "base.white" : "pine.950" }}
>
Failed to load node data.
</Typography>
</ExplorerCard>
@@ -66,17 +72,23 @@ export const NodeDataCard = ({ paramId }: Props) => {
// get node info based on wether it's dentity_key or node_id
if (paramId.length > 10) {
nodeInfo = nymNodes.find((node) => node.identity_key === paramId);
nodeInfo = nsApiNodes.find(
(node: NS_NODE) => node.identity_key === paramId,
);
} else {
nodeInfo = nymNodes.find((node) => node.node_id === Number(paramId));
nodeInfo = nsApiNodes.find(
(node: NS_NODE) => node.node_id === Number(paramId),
);
}
if (!nodeInfo) return null;
const softwareUpdateTime = format(
new Date(nodeInfo.description.build_information.build_timestamp),
"dd/MM/yyyy",
);
const softwareUpdateTime = nodeInfo.self_description
? format(
new Date(nodeInfo.self_description.build_information.build_timestamp),
"dd/MM/yyyy",
)
: "N/A";
return (
<ExplorerCard label="Nym node data" sx={{ height: "100%" }}>
@@ -90,13 +102,21 @@ export const NodeDataCard = ({ paramId }: Props) => {
row
divider
label="Host"
value={nodeInfo.description.host_information.ip_address.toString()}
value={
nodeInfo.self_description
? nodeInfo.self_description.host_information.ip_address.toString()
: "N/A"
}
/>
<ExplorerListItem
row
divider
label="Version"
value={nodeInfo.description.build_information.build_version}
value={
nodeInfo.self_description
? nodeInfo.self_description.build_information.build_version
: "N/A"
}
/>
<ExplorerListItem
row
@@ -1,8 +1,8 @@
"use client";
import { fetchObservatoryNodes } from "@/app/api";
import type { IObservatoryNode } from "@/app/api/types";
import { Skeleton, Typography } from "@mui/material";
import { fetchNSApiNodes } from "@/app/api";
import type { NS_NODE } from "@/app/api/types";
import { Skeleton, Typography, useTheme } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import ExplorerCard from "../cards/ExplorerCard";
import DelegationsTable from "./DelegationsTable";
@@ -12,15 +12,17 @@ type Props = {
};
const NodeDelegationsCard = ({ paramId }: Props) => {
let nodeInfo: IObservatoryNode | undefined;
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
let nodeInfo: NS_NODE | undefined;
const {
data: nymNodes,
isError,
isLoading,
data: nsApiNodes = [],
isLoading: isNSApiNodesLoading,
isError: isNSApiNodesError,
} = useQuery({
queryKey: ["nymNodes"],
queryFn: fetchObservatoryNodes,
queryKey: ["nsApiNodes"],
queryFn: fetchNSApiNodes,
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
@@ -28,16 +30,20 @@ const NodeDelegationsCard = ({ paramId }: Props) => {
});
if (paramId.length > 10) {
nodeInfo = nymNodes?.find((node) => node.identity_key === paramId);
nodeInfo = nsApiNodes.find(
(node: NS_NODE) => node.identity_key === paramId,
);
} else {
nodeInfo = nymNodes?.find((node) => node.node_id === Number(paramId));
nodeInfo = nsApiNodes.find(
(node: NS_NODE) => node.node_id === Number(paramId),
);
}
if (!nodeInfo) return null;
const id = nodeInfo.node_id;
if (isLoading) {
if (isNSApiNodesLoading) {
return (
<ExplorerCard label="Delegations" sx={{ height: "100%" }}>
<Skeleton variant="text" height={50} />
@@ -48,10 +54,13 @@ const NodeDelegationsCard = ({ paramId }: Props) => {
);
}
if (isError) {
if (isNSApiNodesError) {
return (
<ExplorerCard label="Delegations" sx={{ height: "100%" }}>
<Typography variant="h3" sx={{ color: "pine.950" }}>
<Typography
variant="h3"
sx={{ color: isDarkMode ? "base.white" : "pine.950" }}
>
Failed to load delegations. Please try again later.
</Typography>
</ExplorerCard>
@@ -1,7 +1,7 @@
"use client";
import { fetchObservatoryNodes } from "@/app/api";
import type { IObservatoryNode } from "@/app/api/types";
import { fetchNSApiNodes } from "@/app/api";
import type { NS_NODE } from "@/app/api/types";
import ExplorerButtonGroup from "@/components/toggleButton/ToggleButton";
import { useQuery } from "@tanstack/react-query";
@@ -10,25 +10,29 @@ type Props = {
};
export default function NodePageButtonGroup({ paramId }: Props) {
let nodeInfo: IObservatoryNode | undefined;
let nodeInfo: NS_NODE | undefined;
const { data: nymNodes, isError } = useQuery({
queryKey: ["nymNodes"],
queryFn: fetchObservatoryNodes,
const { data: nsApiNodes = [], isError: isNSApiNodesError } = useQuery({
queryKey: ["nsApiNodes"],
queryFn: fetchNSApiNodes,
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
refetchOnMount: false,
});
if (!nymNodes || isError) return null;
if (!nsApiNodes || isNSApiNodesError) return null;
// get node info based on wether it's dentity_key or node_id
if (paramId.length > 10) {
nodeInfo = nymNodes.find((node) => node.identity_key === paramId);
nodeInfo = nsApiNodes.find(
(node: NS_NODE) => node.identity_key === paramId,
);
} else {
nodeInfo = nymNodes.find((node) => node.node_id === Number(paramId));
nodeInfo = nsApiNodes.find(
(node: NS_NODE) => node.node_id === Number(paramId),
);
}
if (!nodeInfo) return null;
@@ -1,10 +1,10 @@
"use client";
import { formatBigNum } from "@/utils/formatBigNumbers";
import { Skeleton, Typography } from "@mui/material";
import { Skeleton, Typography, useTheme } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import { fetchEpochRewards, fetchObservatoryNodes } from "../../app/api";
import type { IObservatoryNode, RewardingDetails } from "../../app/api/types";
import { fetchEpochRewards, fetchNSApiNodes } from "../../app/api";
import type { NS_NODE } from "../../app/api/types";
import ExplorerCard from "../cards/ExplorerCard";
import ExplorerListItem from "../list/ListItem";
@@ -13,7 +13,9 @@ type Props = {
};
export const NodeParametersCard = ({ paramId }: Props) => {
let nodeInfo: IObservatoryNode | undefined;
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
let nodeInfo: NS_NODE | undefined;
// Fetch epoch rewards
const {
@@ -31,19 +33,19 @@ export const NodeParametersCard = ({ paramId }: Props) => {
// Fetch node information
const {
data: nymNodes,
isLoading,
isError,
data: nsApiNodes = [],
isLoading: isNSApiNodesLoading,
isError: isNSApiNodesError,
} = useQuery({
queryKey: ["nymNodes"],
queryFn: fetchObservatoryNodes,
queryKey: ["nsApiNodes"],
queryFn: fetchNSApiNodes,
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
refetchOnMount: false,
});
if (isEpochLoading || isLoading) {
if (isEpochLoading || isNSApiNodesLoading) {
return (
<ExplorerCard label="Node parameters" sx={{ height: "100%" }}>
<Skeleton variant="text" height={50} />
@@ -54,10 +56,13 @@ export const NodeParametersCard = ({ paramId }: Props) => {
);
}
if (isEpochError || isError || !nymNodes || !epochRewardsData) {
if (isEpochError || isNSApiNodesError || !nsApiNodes || !epochRewardsData) {
return (
<ExplorerCard label="Node parameters" sx={{ height: "100%" }}>
<Typography variant="h3" sx={{ color: "pine.950" }}>
<Typography
variant="h5"
sx={{ color: isDarkMode ? "base.white" : "pine.950" }}
>
Failed to load node data.
</Typography>
</ExplorerCard>
@@ -66,9 +71,13 @@ export const NodeParametersCard = ({ paramId }: Props) => {
// get node info based on wether it's dentity_key or node_id
if (paramId.length > 10) {
nodeInfo = nymNodes.find((node) => node.identity_key === paramId);
nodeInfo = nsApiNodes.find(
(node: NS_NODE) => node.identity_key === paramId,
);
} else {
nodeInfo = nymNodes.find((node) => node.node_id === Number(paramId));
nodeInfo = nsApiNodes.find(
(node: NS_NODE) => node.node_id === Number(paramId),
);
}
if (!nodeInfo) return null;
@@ -77,22 +86,25 @@ export const NodeParametersCard = ({ paramId }: Props) => {
const totalStakeFormatted = `${totalStake} NYM`;
// Extract reward details
const rewardDetails: RewardingDetails = nodeInfo.rewarding_details;
const profitMarginPercent =
Number(rewardDetails.cost_params.profit_margin_percent) * 100;
const profitMarginPercent = nodeInfo.rewarding_details
? Number(nodeInfo.rewarding_details.cost_params.profit_margin_percent) * 100
: 0;
const profitMarginPercentFormated = `${profitMarginPercent}%`;
const operatingCosts =
Number(rewardDetails.cost_params.interval_operating_cost.amount) /
1_000_000;
const operatingCosts = nodeInfo.rewarding_details
? Number(
nodeInfo.rewarding_details.cost_params.interval_operating_cost.amount,
) / 1_000_000
: 0;
const operatingCostsFormated = `${operatingCosts.toString()} NYM`;
const getNodeSaturationPoint = (
totalStake: number,
nodeTotalStake: string,
stakeSaturationPoint: string,
): string => {
const saturation = Number.parseFloat(stakeSaturationPoint);
const totalStake = Number.parseFloat(nodeTotalStake);
if (Number.isNaN(saturation) || saturation <= 0) {
throw new Error("Invalid stake saturation point provided");
@@ -1,6 +1,6 @@
"use client";
import type { IObservatoryNode } from "@/app/api/types";
import type { NS_NODE } from "@/app/api/types";
import { useChain } from "@cosmos-kit/react";
import {
Box,
@@ -14,7 +14,7 @@ import { useQuery } from "@tanstack/react-query";
import DOMPurify from "isomorphic-dompurify";
import { useCallback, useState } from "react";
import { RandomAvatar } from "react-random-avatars";
import { fetchObservatoryNodes } from "../../app/api";
import { fetchNSApiNodes } from "../../app/api";
import { COSMOS_KIT_USE_CHAIN } from "../../config";
import { useNymClient } from "../../hooks/useNymClient";
import ExplorerCard from "../cards/ExplorerCard";
@@ -31,7 +31,7 @@ type Props = {
};
export const NodeProfileCard = ({ paramId }: Props) => {
let nodeInfo: IObservatoryNode | undefined;
let nodeInfo: NS_NODE | undefined;
const theme = useTheme();
const { isWalletConnected } = useChain(COSMOS_KIT_USE_CHAIN);
@@ -47,12 +47,12 @@ export const NodeProfileCard = ({ paramId }: Props) => {
// Fetch node info
const {
data: nymNodes,
isLoading: isLoadingNymNodes,
isError,
data: nsApiNodes = [],
isLoading: isNSApiNodesLoading,
isError: isNSApiNodesError,
} = useQuery({
queryKey: ["nymNodes"],
queryFn: fetchObservatoryNodes,
queryKey: ["nsApiNodes"],
queryFn: fetchNSApiNodes,
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
@@ -62,9 +62,13 @@ export const NodeProfileCard = ({ paramId }: Props) => {
// get node info based on wether it's dentity_key or node_id
if (paramId.length > 10) {
nodeInfo = nymNodes?.find((node) => node.identity_key === paramId);
nodeInfo = nsApiNodes.find(
(node: NS_NODE) => node.identity_key === paramId,
);
} else {
nodeInfo = nymNodes?.find((node) => node.node_id === Number(paramId));
nodeInfo = nsApiNodes.find(
(node: NS_NODE) => node.node_id === Number(paramId),
);
}
const handleOnSelectStake = useCallback(() => {
@@ -96,7 +100,7 @@ export const NodeProfileCard = ({ paramId }: Props) => {
}
}, [isWalletConnected, nodeInfo]);
if (isLoadingNymNodes) {
if (isNSApiNodesLoading) {
return (
<ExplorerCard label="Nym Node" sx={{ height: "100%" }}>
<Skeleton variant="rectangular" height={80} width={80} />
@@ -105,11 +109,11 @@ export const NodeProfileCard = ({ paramId }: Props) => {
</ExplorerCard>
);
}
if (isError || !nymNodes) {
if (isNSApiNodesError || !nsApiNodes) {
return (
<ExplorerCard label="Nym Node" sx={{ height: "100%" }}>
<Typography
variant="h3"
variant="h5"
sx={{
color: theme.palette.mode === "dark" ? "base.white" : "pine.950",
}}
@@ -164,12 +168,13 @@ export const NodeProfileCard = ({ paramId }: Props) => {
if (!nodeInfo) return null;
const cleanMoniker = DOMPurify.sanitize(
nodeInfo?.self_description.moniker,
).replace(/&amp;/g, "&");
const cleanMoniker = DOMPurify.sanitize(nodeInfo.description.moniker).replace(
/&amp;/g,
"&",
);
const cleanDescription = DOMPurify.sanitize(
nodeInfo?.self_description.details,
nodeInfo.description.details,
).replace(/&amp;/g, "&");
// get full country name
@@ -197,7 +202,7 @@ export const NodeProfileCard = ({ paramId }: Props) => {
>
{cleanMoniker || "Moniker"}
</Typography>
{nodeInfo.description.auxiliary_details.location && (
{nodeInfo.geoip?.country && (
<Box display={"flex"} gap={1}>
<Typography
variant="h6"
@@ -210,10 +215,8 @@ export const NodeProfileCard = ({ paramId }: Props) => {
<Box>
<CountryFlag
countryCode={nodeInfo.description.auxiliary_details.location}
countryName={countryName(
nodeInfo.description.auxiliary_details.location,
)}
countryCode={nodeInfo.geoip?.country || ""}
countryName={countryName(nodeInfo.geoip?.country || "")}
/>
</Box>
</Box>
@@ -1,15 +1,15 @@
"use client";
import { Chip, Skeleton, Stack, Typography } from "@mui/material";
import { Chip, Skeleton, Stack, Typography, useTheme } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import {
fetchEpochRewards,
fetchGatewayStatus,
fetchObservatoryNodes,
fetchNSApiNodes,
} from "../../app/api";
import type {
IObservatoryNode,
LastProbeResult,
NS_NODE,
NodeDescription,
} from "../../app/api/types";
import ExplorerCard from "../cards/ExplorerCard";
@@ -148,21 +148,23 @@ function calculateWireguardPerformance(probeResult: LastProbeResult): number {
}
export const NodeRoleCard = ({ paramId }: Props) => {
let nodeInfo: IObservatoryNode | undefined;
let nodeInfo: NS_NODE | undefined;
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
// Fetch node info
const {
data: nymNodes,
isLoading,
isError,
data: nsApiNodes = [],
isLoading: isNSApiNodesLoading,
isError: isNSApiNodesError,
} = useQuery({
queryKey: ["nymNodes"],
queryFn: fetchObservatoryNodes,
queryKey: ["nsApiNodes"],
queryFn: fetchNSApiNodes,
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
refetchOnMount: false,
});
const {
data: epochRewardsData,
isLoading: isEpochLoading,
@@ -177,19 +179,19 @@ export const NodeRoleCard = ({ paramId }: Props) => {
});
if (paramId.length > 10) {
nodeInfo = nymNodes?.find((node) => node.identity_key === paramId);
nodeInfo = nsApiNodes.find((node) => node.identity_key === paramId);
} else {
nodeInfo = nymNodes?.find((node) => node.node_id === Number(paramId));
nodeInfo = nsApiNodes.find((node) => node.node_id === Number(paramId));
} // Extract node roles once `nodeInfo` is available
const nodeRoles = nodeInfo
? getNodeRoles(nodeInfo.description.declared_role)
const nodeRoles = nodeInfo?.self_description
? getNodeRoles(nodeInfo.self_description.declared_role)
: [];
// Define whether to fetch gateway status
const shouldFetchGatewayStatus = nodeRoles.some((role) =>
["Entry Node", "Exit IPR Node", "Exit NR Node"].includes(role),
);
// Fetch gateway status only if `shouldFetchGatewayStatus` is true
const { data: gatewayStatus } = useQuery({
queryKey: ["gatewayStatus", nodeInfo?.identity_key],
@@ -200,7 +202,7 @@ export const NodeRoleCard = ({ paramId }: Props) => {
refetchOnReconnect: false,
});
if (isLoading || isEpochLoading) {
if (isNSApiNodesLoading || isEpochLoading) {
return (
<ExplorerCard label="Node role & performance">
<Skeleton variant="text" height={70} />
@@ -210,15 +212,19 @@ export const NodeRoleCard = ({ paramId }: Props) => {
);
}
if (isError || !nymNodes || !epochRewardsData || isEpochError) {
if (isNSApiNodesError || !nsApiNodes || !epochRewardsData || isEpochError) {
return (
<ExplorerCard label="Node role & performance">
<Typography variant="h3" sx={{ color: "pine.950" }}>
<Typography
variant="h5"
sx={{ color: isDarkMode ? "base.white" : "pine.950" }}
>
Failed to load node data.
</Typography>
</ExplorerCard>
);
}
if (!nodeInfo) return null;
const NodeRoles = nodeRoles.map((role) => (
<Stack key={role} direction="row" gap={1}>
@@ -226,8 +232,6 @@ export const NodeRoleCard = ({ paramId }: Props) => {
</Stack>
));
if (!nodeInfo) return null;
const qualityOfServiceStars = nodeInfo?.uptime
? calculateQualityOfServiceStars(nodeInfo.uptime)
: gatewayStatus
@@ -247,9 +251,10 @@ export const NodeRoleCard = ({ paramId }: Props) => {
// Function to calculate active set probability
const getActiveSetProbability = (
totalStake: number,
nodeTotalStake: string,
stakeSaturationPoint: string,
): string => {
const totalStake = Number.parseFloat(nodeTotalStake);
const saturation = Number.parseFloat(stakeSaturationPoint);
if (Number.isNaN(saturation) || saturation <= 0) {
@@ -1,5 +1,6 @@
"use client";
import type { IObservatoryNode } from "@/app/api/types";
import type { NS_NODE } from "@/app/api/types";
import { NYM_ACCOUNT_ADDRESS } from "@/app/api/urls";
import { Search } from "@mui/icons-material";
import {
Autocomplete,
@@ -12,22 +13,22 @@ import {
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { fetchObservatoryNodes } from "../../app/api";
import { NYM_ACCOUNT_ADDRESS } from "@/app/api/urls";
import { fetchNSApiNodes } from "../../app/api";
const NodeAndAddressSearch = () => {
const router = useRouter();
const [inputValue, setInputValue] = useState("");
const [errorText, setErrorText] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [searchOptions, setSearchOptions] = useState<IObservatoryNode[]>([]);
const [searchOptions, setSearchOptions] = useState<NS_NODE[]>([]);
// Use React Query to fetch nodes
const { data: nymNodes = [], isLoading: isLoadingNodes } = useQuery({
queryKey: ["nymNodes"],
queryFn: fetchObservatoryNodes,
const { data: nsApiNodes = [], isLoading: isNSApiNodesLoading } = useQuery({
queryKey: ["nsApiNodes"],
queryFn: fetchNSApiNodes,
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false,
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
refetchOnMount: false,
});
@@ -50,7 +51,7 @@ const NodeAndAddressSearch = () => {
}
} catch {
setErrorText(
"No node found with the provided Name, Node ID or Identity Key. Please check your input and try again."
"No node found with the provided Name, Node ID or Identity Key. Please check your input and try again.",
);
setIsLoading(false); // Stop loading
@@ -58,7 +59,7 @@ const NodeAndAddressSearch = () => {
}
} else {
setErrorText(
"No node found with the provided Name, Node ID or Identity Key. Please check your input and try again."
"No node found with the provided Name, Node ID or Identity Key. Please check your input and try again.",
);
setIsLoading(false); // Stop loading
@@ -66,9 +67,9 @@ const NodeAndAddressSearch = () => {
}
} else {
// Check if it's a node identity key
if (nymNodes) {
const matchingNode = nymNodes.find(
(node) => node.identity_key === inputValue
if (nsApiNodes) {
const matchingNode = nsApiNodes.find(
(node: NS_NODE) => node.identity_key === inputValue,
);
if (matchingNode) {
@@ -77,13 +78,13 @@ const NodeAndAddressSearch = () => {
}
}
setErrorText(
"No node found with the provided Name, Node ID or Identity Key. Please check your input and try again."
"No node found with the provided Name, Node ID or Identity Key. Please check your input and try again.",
);
setIsLoading(false);
}
} catch (error) {
setErrorText(
"No node found with the provided Name, Node ID or Identity Key. Please check your input and try again."
"No node found with the provided Name, Node ID or Identity Key. Please check your input and try again.",
);
console.error(error);
setIsLoading(false); // Stop loading
@@ -92,7 +93,7 @@ const NodeAndAddressSearch = () => {
// Handle search input change
const handleSearchInputChange = (
event: React.ChangeEvent<HTMLInputElement>
event: React.ChangeEvent<HTMLInputElement>,
) => {
const value = event.target.value;
setInputValue(value);
@@ -104,10 +105,8 @@ const NodeAndAddressSearch = () => {
// Filter nodes by moniker if input is not empty
if (value.trim() !== "") {
const filteredNodes = nymNodes.filter((node) =>
node.self_description?.moniker
?.toLowerCase()
.includes(value.toLowerCase())
const filteredNodes = nsApiNodes.filter((node: NS_NODE) =>
node.description.moniker?.toLowerCase().includes(value.toLowerCase()),
);
setSearchOptions(filteredNodes);
} else {
@@ -118,7 +117,7 @@ const NodeAndAddressSearch = () => {
// Handle node selection from dropdown
const handleNodeSelect = (
event: React.SyntheticEvent,
value: string | IObservatoryNode | null
value: string | NS_NODE | null,
) => {
if (value && typeof value !== "string") {
setIsLoading(true); // Show loading spinner
@@ -132,9 +131,9 @@ const NodeAndAddressSearch = () => {
<Autocomplete
freeSolo
options={searchOptions}
getOptionLabel={(option: string | IObservatoryNode) => {
getOptionLabel={(option: string | NS_NODE) => {
if (typeof option === "string") return option;
return option.self_description?.moniker || "";
return option.description.moniker || "";
}}
isOptionEqualToValue={(option, value) => {
if (typeof option === "string" || typeof value === "string")
@@ -146,10 +145,10 @@ const NodeAndAddressSearch = () => {
return (
<li
{...props}
key={`${option.node_id}-${option.self_description?.moniker || ""}`}
key={`${option.node_id}-${option.description.moniker || ""}`}
style={{ fontSize: "0.875rem" }}
>
{option.self_description?.moniker || "Unnamed Node"}
{option.description.moniker || "Unnamed Node"}
</li>
);
}}
@@ -182,7 +181,7 @@ const NodeAndAddressSearch = () => {
/>
)}
onChange={handleNodeSelect}
loading={isLoadingNodes}
loading={isNSApiNodesLoading}
loadingText="Loading nodes..."
noOptionsText="No nodes found"
slotProps={{
@@ -12,6 +12,7 @@ import {
} from "@mui/material";
import { useCallback, useEffect, useMemo, useState } from "react";
import { colours } from "@/theme/colours";
import type { Delegation } from "@nymproject/contract-clients/Mixnet.types";
import { useQueryClient } from "@tanstack/react-query";
import { useLocalStorage } from "@uidotdev/usehooks";
@@ -584,7 +585,7 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
},
muiTableHeadRowProps: {
sx: {
bgcolor: isDarkMode ? "#374042" : "background.paper",
bgcolor: isDarkMode ? "background.default" : "background.paper",
},
},
muiTableHeadCellProps: {
@@ -655,15 +656,20 @@ const StakeTable = ({ nodes }: { nodes: MappedNymNodes }) => {
},
hover: true,
sx: {
backgroundColor: isDarkMode
? row.index % 2 === 0
? "#3E4A4C !important"
: "#374042 !important"
: row.index % 2 === 0
? "#F3F7FB"
: "white",
":nth-child(even)": {
bgcolor:
theme.palette.mode === "dark"
? `${colours.pine[950]} !important`
: `${colours.base.white} !important`,
},
":nth-child(odd)": {
bgcolor:
theme.palette.mode === "dark"
? `${colours.pine[800]} !important`
: `${colours.haze[25]} !important`,
},
"&:hover": {
backgroundColor: `${isDarkMode ? "#2A3436" : "#E5E7EB"} !important`,
backgroundColor: `${theme.palette.mode === "dark" ? "#004449" : "#E5E7EB"} !important`,
transition: "background-color 0.2s ease",
},
cursor: "pointer",
@@ -3,8 +3,8 @@
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 { fetchEpochRewards, fetchNSApiNodes } from "../../app/api";
import type { ExplorerData, NS_NODE } from "../../app/api/types";
import { countryName } from "../../utils/countryName";
import StakeTable from "./StakeTable";
@@ -24,35 +24,46 @@ function getNodeSaturationPoint(
}
// Map nodes with rewards data
const mappedNymNodes = (
nodes: IObservatoryNode[],
const mappedNSApiNodes = (
nodes: NS_NODE[],
epochRewardsData: ExplorerData["currentEpochRewardsData"],
) =>
nodes.map((node) => {
const nodeSaturationPoint = getNodeSaturationPoint(
node.total_stake,
epochRewardsData.interval.stake_saturation_point,
);
nodes
.map((node) => {
const nodeSaturationPoint = getNodeSaturationPoint(
+node.total_stake,
epochRewardsData.interval.stake_saturation_point,
);
const cleanMoniker = DOMPurify.sanitize(
node.self_description.moniker,
).replace(/&amp;/g, "&");
const cleanMoniker = DOMPurify.sanitize(node.description.moniker).replace(
/&amp;/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 || 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,
profitMarginPercentage: node.rewarding_details
? +node.rewarding_details.cost_params.profit_margin_percent * 100
: 0,
owner: node.bonding_address,
stakeSaturation: nodeSaturationPoint,
};
})
.sort((a, b) => {
// Handle null country names by putting them at the end
if (!a.countryName && !b.countryName) return 0;
if (!a.countryName) return 1;
if (!b.countryName) return -1;
export type MappedNymNodes = ReturnType<typeof mappedNymNodes>;
// Sort alphabetically by country name
return a.countryName.localeCompare(b.countryName);
});
export type MappedNymNodes = ReturnType<typeof mappedNSApiNodes>;
export type MappedNymNode = MappedNymNodes[0];
const StakeTableWithAction = () => {
@@ -72,12 +83,12 @@ const StakeTableWithAction = () => {
// Use React Query to fetch Nym nodes
const {
data: nymNodes = [],
isLoading: isNodesLoading,
isError: isNodesError,
data: nsApiNodes = [],
isLoading: isNSApiNodesLoading,
isError: isNSApiNodesError,
} = useQuery({
queryKey: ["nymNodes"],
queryFn: fetchObservatoryNodes,
queryKey: ["nsApiNodes"],
queryFn: fetchNSApiNodes,
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false, // Prevents unnecessary refetching
refetchOnReconnect: false,
@@ -85,7 +96,7 @@ const StakeTableWithAction = () => {
});
// Handle loading state
if (isEpochLoading || isNodesLoading) {
if (isEpochLoading || isNSApiNodesLoading) {
return (
<Card sx={{ height: "100%", mt: 5 }}>
<CardContent>
@@ -99,7 +110,7 @@ const StakeTableWithAction = () => {
}
// Handle error state
if (isEpochError || isNodesError) {
if (isEpochError || isNSApiNodesError) {
return (
<Stack direction="row" spacing={1}>
<Typography variant="h5" sx={{ color: "pine.600", letterSpacing: 0.7 }}>
@@ -115,9 +126,9 @@ const StakeTableWithAction = () => {
return null;
}
const data = mappedNymNodes(nymNodes || [], epochRewardsData);
const nsApiNodesData = mappedNSApiNodes(nsApiNodes || [], epochRewardsData);
return <StakeTable nodes={data} />;
return <StakeTable nodes={nsApiNodesData} />;
};
export default StakeTableWithAction;
@@ -8,7 +8,6 @@ import type { Delegation } from "@nymproject/contract-clients/Mixnet.types";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useState } from "react";
import { fetchTotalStakerRewards } from "../../app/api";
import type { NodeRewardDetails } from "../../app/api/types";
import { COSMOS_KIT_USE_CHAIN, NYM_MIXNET_CONTRACT } from "../../config";
import { useNymClient } from "../../hooks/useNymClient";
import Loading from "../loading";
@@ -87,7 +86,7 @@ const SubHeaderRowActions = () => {
{ gasPrice },
);
const messages = delegations.map((delegation: NodeRewardDetails) => ({
const messages = delegations.map((delegation: Delegation) => ({
contractAddress: NYM_MIXNET_CONTRACT,
funds: [],
msg: {
@@ -0,0 +1,90 @@
"use client";
import { Button, ButtonGroup, Stack } from "@mui/material";
type Option = {
label: string;
isSelected: boolean;
value: "all" | "mixnodes" | "gateways" | "recommended";
};
type Options = [Option, Option, Option, Option];
const NodeFilterButtonGroup = ({
size = "small",
options,
onPage,
onFilterChange,
}: {
size?: "small" | "medium" | "large";
options: Options;
onPage: string;
onFilterChange: (
filter: "all" | "mixnodes" | "gateways" | "recommended",
) => void;
}) => {
const handleClick = (
value: "all" | "mixnodes" | "gateways" | "recommended",
) => {
if (onPage === value) return;
onFilterChange(value);
};
const getMobileButtonStyles = (isSelected: boolean) => ({
color: isSelected ? "primary.contrastText" : "text.primary",
"&:hover": {
bgcolor: isSelected ? "primary.main" : "",
},
bgcolor: isSelected ? "primary.main" : "transparent",
width: "100%",
py: 1.5,
px: 2,
});
const getDesktopButtonStyles = (isSelected: boolean) => ({
color: isSelected ? "primary.contrastText" : "text.primary",
"&:hover": {
bgcolor: isSelected ? "primary.main" : "",
},
bgcolor: isSelected ? "primary.main" : "transparent",
});
return (
<>
{/* Mobile view - Stack */}
<Stack
spacing={1.5}
sx={{
display: { xs: "flex", sm: "none" },
width: "100%",
}}
>
{options.map((option) => (
<Button
key={option.label}
onClick={() => handleClick(option.value)}
sx={getMobileButtonStyles(option.isSelected)}
variant="outlined"
>
{option.label}
</Button>
))}
</Stack>
{/* Desktop view - ButtonGroup */}
<ButtonGroup size={size} sx={{ display: { xs: "none", sm: "flex" } }}>
{options.map((option) => (
<Button
key={option.label}
onClick={() => handleClick(option.value)}
sx={getDesktopButtonStyles(option.isSelected)}
variant="outlined"
>
{option.label}
</Button>
))}
</ButtonGroup>
</>
);
};
export default NodeFilterButtonGroup;
@@ -0,0 +1,406 @@
"use client";
import { fetchWorldMapCountries } from "@/app/api";
import AddIcon from "@mui/icons-material/Add";
import RemoveIcon from "@mui/icons-material/Remove";
import RestartAltIcon from "@mui/icons-material/RestartAlt";
import { Box, IconButton, Skeleton, Typography } from "@mui/material";
import { useTheme } from "@mui/material/styles";
import { useQuery } from "@tanstack/react-query";
import { scaleLinear } from "d3-scale";
import Image from "next/image";
import * as React from "react";
import {
ComposableMap,
Geographies,
Geography,
ZoomableGroup,
} from "react-simple-maps";
import { Tooltip } from "react-tooltip";
import type { CountryDataResponse } from "../../app/api/types";
import MAP_TOPOJSON from "../../assets/world-110m.json";
import ExplorerCard from "../cards/ExplorerCard";
const mapPlaceholderDark = "/explorer/map-placeholder-dark.png";
const mapPlaceholderLight = "/explorer/map-placeholder-light.png";
export const WorldMap = (): JSX.Element => {
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
const [position, setPosition] = React.useState<{
coordinates: [number, number];
zoom: number;
}>({ coordinates: [0, 0], zoom: 1 });
const {
data: {
countries = [],
totalCountries = 0,
uniqueLocations = 0,
totalServers = 0,
} = {
countries: [],
totalCountries: 0,
uniqueLocations: 0,
totalServers: 0,
},
isLoading: isLoadingCountries,
isError: isCountriesError,
} = useQuery({
queryKey: ["nymNodesCountries"],
queryFn: fetchWorldMapCountries,
staleTime: 60 * 60 * 1000, // 1 hour
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const [tooltipContent, setTooltipContent] = React.useState<string>("");
React.useEffect(() => {
const handleMouseLeave = () => setTooltipContent("");
return () => {
handleMouseLeave();
};
}, []);
const colorScale = React.useMemo(() => {
if (countries) {
const heighestNumberOfNodes = Math.max(
...Object.values(countries).map((country) => country.nodes),
);
return scaleLinear<string, string>()
.domain([
0,
1,
heighestNumberOfNodes / 4,
heighestNumberOfNodes / 2,
heighestNumberOfNodes,
])
.range(
isDarkMode
? [
theme.palette.pine[950],
"#0F5A2E", // Dark green
"#147A3D", // Medium green
"#1A994C", // Light green
theme.palette.accent.main,
]
: [
theme.palette.pine[300],
"#0F5A2E", // Dark green
"#147A3D", // Medium green
"#1A994C", // Light green
theme.palette.accent.main,
],
)
.unknown(isDarkMode ? theme.palette.pine[950] : theme.palette.pine[25]);
}
return () =>
isDarkMode ? theme.palette.pine[950] : theme.palette.pine[25];
}, [countries, theme.palette.pine, theme.palette.accent, isDarkMode]);
if (isLoadingCountries) {
return (
<ExplorerCard label="Nym server locations" sx={{ width: "100%" }}>
<div
style={{
position: "relative",
width: "100%",
aspectRatio: "16/7",
}}
>
<Image
src={isDarkMode ? mapPlaceholderDark : mapPlaceholderLight}
alt="World Map Placeholder"
fill
style={{ objectFit: "contain" }}
priority
/>
</div>
</ExplorerCard>
);
}
if (isCountriesError) {
return (
<ExplorerCard label="Nym server locations" sx={{ width: "100%" }}>
<Typography
variant="h5"
sx={{
color: isDarkMode ? "base.white" : "pine.950",
letterSpacing: 0.7,
}}
>
Failed to load data
</Typography>
<Skeleton variant="text" height={500} />
</ExplorerCard>
);
}
return (
<>
<ExplorerCard
label="Nym server locations"
sx={{
width: "100%",
position: "relative",
p: { xs: 2, sm: 3 },
"& > .MuiCardContent-root": {
height: {
xs: "200px",
sm: "auto",
},
aspectRatio: {
xs: "unset",
sm: "16/7",
},
},
}}
>
<Box
sx={{
position: "relative",
width: "100%",
height: "100%",
overflow: "hidden",
margin: "0 auto",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<ComposableMap
data-tip=""
style={{
backgroundColor: isDarkMode ? "#000000" : theme.palette.pine[25],
width: "100%",
height: "100%",
}}
viewBox="0 0 800 400"
projection="geoMercator"
projectionConfig={{
scale: 130,
}}
>
<ZoomableGroup
center={position.coordinates}
zoom={position.zoom}
minZoom={1}
maxZoom={8}
translateExtent={[
[-800, -400],
[800, 400],
]}
onMoveEnd={({
coordinates,
zoom,
}: {
coordinates: [number, number];
zoom: number;
}) => {
setPosition({ coordinates, zoom });
}}
onClick={(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Geographies geography={MAP_TOPOJSON}>
{({ geographies }: { geographies: GeoJSON.Feature[] }) =>
geographies.map((geo) => {
const d = Array.isArray(countries)
? { nodes: 0 }
: (countries as CountryDataResponse)[
geo.properties?.ISO_A3 as string
] || { nodes: 0 };
return (
<Geography
key={`${geo.properties?.ISO_A3 || ""}-${geo.id}-${
geo.properties?.NAME_LONG || ""
}`}
geography={geo}
fill={colorScale(d?.nodes || 0)}
stroke={
theme.palette.mode === "dark"
? theme.palette.pine[800]
: theme.palette.pine[200]
}
strokeWidth={0.2}
data-tooltip-id="map-tooltip"
onMouseEnter={() => {
const { NAME_LONG } = geo.properties as {
NAME_LONG: string;
};
setTooltipContent(`${NAME_LONG} | ${d?.nodes || 0}`);
}}
onMouseLeave={() => {
setTooltipContent("");
}}
style={{
hover: countries
? {
fill: theme.palette.accent.main,
outline: "white",
cursor: "pointer",
}
: undefined,
default: {
outline: "none",
},
pressed: {
outline: "none",
},
}}
/>
);
})
}
</Geographies>
</ZoomableGroup>
</ComposableMap>
<div
style={{
position: "absolute",
top: 10,
left: 10,
zIndex: 1000,
display: "flex",
flexDirection: "column",
gap: "4px",
backgroundColor: isDarkMode
? "rgba(0,0,0,0.5)"
: "rgba(255,255,255,0.5)",
padding: "4px",
borderRadius: "4px",
}}
>
<IconButton
size="small"
onClick={() =>
setPosition((prev) => ({
...prev,
zoom: Math.min(prev.zoom + 0.5, 8),
}))
}
sx={{
backgroundColor: isDarkMode
? "rgba(255,255,255,0.1)"
: "rgba(0,0,0,0.1)",
"&:hover": {
backgroundColor: isDarkMode
? "rgba(255,255,255,0.2)"
: "rgba(0,0,0,0.2)",
},
}}
>
<AddIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() =>
setPosition((prev) => ({
...prev,
zoom: Math.max(prev.zoom - 0.5, 1),
}))
}
sx={{
backgroundColor: isDarkMode
? "rgba(255,255,255,0.1)"
: "rgba(0,0,0,0.1)",
"&:hover": {
backgroundColor: isDarkMode
? "rgba(255,255,255,0.2)"
: "rgba(0,0,0,0.2)",
},
}}
>
<RemoveIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() => setPosition({ coordinates: [0, 0], zoom: 1 })}
sx={{
backgroundColor: isDarkMode
? "rgba(255,255,255,0.1)"
: "rgba(0,0,0,0.1)",
"&:hover": {
backgroundColor: isDarkMode
? "rgba(255,255,255,0.2)"
: "rgba(0,0,0,0.2)",
},
}}
>
<RestartAltIcon fontSize="small" />
</IconButton>
</div>
</Box>
<Tooltip
id="map-tooltip"
content={tooltipContent}
float={true}
style={{
fontSize: "12px",
padding: "4px 8px",
backgroundColor:
theme.palette.mode === "dark"
? theme.palette.pine[800]
: theme.palette.pine[200],
color:
theme.palette.mode === "dark"
? theme.palette.base.white
: theme.palette.pine[950],
borderRadius: "4px",
zIndex: 9999,
}}
/>
</ExplorerCard>
<Box
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
sm: "repeat(3, 1fr)",
},
gap: 2,
mt: 2,
}}
>
<ExplorerCard label="Nym servers around the world">
<Typography
variant="h4"
sx={{
color: isDarkMode ? "base.white" : "pine.950",
letterSpacing: 0.7,
}}
>
{totalServers}
</Typography>
</ExplorerCard>
<ExplorerCard label="Countries with Nym servers">
<Typography
variant="h4"
sx={{
color: isDarkMode ? "base.white" : "pine.950",
letterSpacing: 0.7,
}}
>
{totalCountries}
</Typography>
</ExplorerCard>
<ExplorerCard label="Cities with Nym servers">
<Typography
variant="h4"
sx={{
color: isDarkMode ? "base.white" : "pine.950",
letterSpacing: 0.7,
}}
>
{uniqueLocations}
</Typography>
</ExplorerCard>
</Box>
</>
);
};
@@ -11,6 +11,7 @@
"iconLight": "explorerCard",
"iconDark": "explorerCardDark",
"image": "/explorer/images/Network.webp",
"link": "https://nym.com/blog/welcome-to-explorer",
"overview": {
"content": [
{
+1
View File
@@ -11,6 +11,7 @@
"iconLight": "stakeCard",
"iconDark": "stakeCardDark",
"image": "/explorer/images/stake-article.webp",
"link": "https://nym.com/blog/stake-Nym-tokens",
"overview": {
"content": [
{
+4 -2
View File
@@ -5,7 +5,9 @@ export const colours = {
error: "#E01400",
},
green: {
500: "#14e76f",
300: "#EFFFF0",
400: "#C2FFC7",
500: "#07FF94",
},
haze: {
25: "#F3F7FB",
@@ -17,7 +19,7 @@ export const colours = {
200: "#CAD6D7",
300: "#A6B9BA",
600: "#4C666A",
800: "#3E4A4C",
800: "#374042",
900: "#374042",
950: "#242B2D",
},
+5
View File
@@ -0,0 +1,5 @@
declare module "*.json" {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const content: any;
export default content;
}
+1
View File
@@ -0,0 +1 @@
declare module "react-simple-maps";
+1
View File
@@ -0,0 +1 @@
declare module "react-tooltip";
+6
View File
@@ -0,0 +1,6 @@
{
"buildCommand": "yarn build",
"installCommand": "yarn install",
"framework": "nextjs",
"build": { "env": { "NODE_VERSION": "20", "LERNA_USE_NX": "false", "NX_DAEMON": "false" } }
}
+17 -3
View File
@@ -45,11 +45,18 @@
"types:lint:fix": "lerna run lint:fix --scope @nymproject/types --scope @nymproject/nym-wallet-app",
"audit:fix": "npm_config_yes=true npx yarn-audit-fix -- --dry-run",
"dev:on": "node sdk/typescript/scripts/dev-mode-add.mjs",
"dev:off": "node sdk/typescript/scripts/dev-mode-remove.mjs"
"dev:off": "node sdk/typescript/scripts/dev-mode-remove.mjs",
"security:audit": "yarn audit --level moderate",
"security:audit:fix": "yarn audit --fix",
"security:audit:ci": "yarn install --frozen-lockfile && yarn audit --level moderate",
"security:check": "yarn audit --level high && yarn list --depth=0",
"security:outdated": "yarn outdated",
"security:verify": "yarn audit --level moderate && yarn list --depth=0 && yarn outdated",
"security:full": "./scripts/security-check.sh",
"security:ci": "yarn install --frozen-lockfile && ./scripts/security-check.sh"
},
"devDependencies": {
"@npmcli/node-gyp": "^3.0.0",
"lerna": "^7.3.0",
"node-gyp": "^9.3.1",
"npm-run-all": "^4.1.5",
"tslog": "3.3.3"
@@ -62,6 +69,13 @@
"@cosmjs/proto-signing": "^0.32.4",
"@cosmjs/stargate": "^0.32.4",
"@cosmjs/cosmwasm-stargate": "^0.32.4",
"cosmjs-types": "^0.9.0"
"cosmjs-types": "^0.9.0",
"chalk": "5.3.0",
"strip-ansi": "7.1.0",
"color-convert": "2.0.1",
"color-name": "1.1.4",
"is-core-module": "2.13.1",
"error-ex": "1.3.2",
"has-ansi": "5.0.1"
}
}
+54
View File
@@ -0,0 +1,54 @@
#!/bin/bash
set -e
echo "starting security checks..."
if [ ! -f "package.json" ]; then
echo "error: package.json not found, please run this script from the project root."
exit 1
fi
echo "checking Node.js version..."
if [ -f ".nvmrc" ]; then
REQUIRED_NODE_VERSION=$(cat .nvmrc)
CURRENT_NODE_VERSION=$(node --version | sed 's/v//')
echo "required Node.js version: $REQUIRED_NODE_VERSION"
echo "current Node.js version: $CURRENT_NODE_VERSION"
if [ "$CURRENT_NODE_VERSION" != "$REQUIRED_NODE_VERSION" ]; then
echo "warning: Node.js version mismatch, consider using nvm to switch to the required version."
fi
fi
echo "checking .npmrc configuration..."
if [ ! -f ".npmrc" ]; then
echo "Error: .npmrc file not found, security configurations are missing."
exit 1
fi
echo "checking yarn.lock..."
if [ ! -f "yarn.lock" ]; then
echo "error: yarn.lock not found, run 'yarn install' to generate it."
exit 1
fi
echo "running yarn audit..."
yarn audit --level moderate
echo "checking for outdated packages..."
yarn outdated || true
echo "verifying package integrity..."
yarn list --depth=0
echo "checking for known vulnerable packages..."
yarn audit --level high
echo "checking package sources..."
yarn list --depth=0 --json | jq -r '.data.trees[] | select(.children) | .children[] | select(.name | test("^https?://(?!registry\\.npmjs\\.org)")) | .name' || true
echo "checks completed successfully!"
echo ""
echo "always use 'yarn install --frozen-lockfile' in production environments"
@@ -1,5 +1,6 @@
import * as React from 'react';
import { createNymMixnetClient, IWebWorkerEvents, MimeTypes, NymClientConfig, NymMixnetClient } from '@nymproject/sdk';
export interface BinaryMessageHeaders {
filename: string;
mimeType: string;
@@ -1,6 +1,6 @@
import * as React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
import * as React from 'react';
import ReactDOM from 'react-dom/client';
import { App } from './App';
const root = ReactDOM.createRoot(document.getElementById("app") as HTMLElement);
const root = ReactDOM.createRoot(document.getElementById('app') as HTMLElement);
root.render(<App />);
@@ -47,16 +47,16 @@
"devDependencies": {
"@babel/core": "^7.17.5",
"@nymproject/eslint-config-react-typescript": "^1.0.0",
"@storybook/addon-actions": "^6.5.8",
"@storybook/addon-essentials": "^6.5.8",
"@storybook/addon-interactions": "^6.5.8",
"@storybook/addon-links": "^6.5.8",
"@storybook/builder-webpack5": "^6.5.8",
"@storybook/manager-webpack5": "^6.5.8",
"@storybook/react": "^6.5.15",
"@storybook/testing-library": "^0.0.9",
"@storybook/addon-actions": "9.0.8",
"@storybook/addon-essentials": "^8.4.7",
"@storybook/addon-interactions": "^8.4.7",
"@storybook/addon-links": "^8.4.7",
"@storybook/react": "^8.4.7",
"@storybook/react-webpack5": "^8.4.7",
"@storybook/testing-library": "0.2.1",
"@svgr/webpack": "^6.1.1",
"@types/flat": "^5.0.2",
"@types/minimatch": "^5.1.2",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@typescript-eslint/eslint-plugin": "^5.13.0",
@@ -80,7 +80,9 @@
"rimraf": "^3.0.2",
"ts-jest": "^27.0.5",
"tsconfig-paths-webpack-plugin": "^3.5.2",
"typescript": "^4.6.2"
"typescript": "^4.6.2",
"@nymproject/types": "1.0.0",
"@nymproject/mui-theme": "1.0.0"
},
"scripts": {
"clean": "rimraf dist",
@@ -1 +1,4 @@
// no-op
import '@nymproject/mui-theme';
export { CurrencyFormField } from './components/currency/CurrencyFormField';
export { IdentityKeyFormField } from './components/mixnodes/IdentityKeyFormField';
@@ -25,14 +25,13 @@
"devDependencies": {
"@babel/core": "^7.17.5",
"@nymproject/eslint-config-react-typescript": "^1.0.0",
"@storybook/addon-actions": "^6.5.8",
"@storybook/addon-essentials": "^6.5.8",
"@storybook/addon-interactions": "^6.5.8",
"@storybook/addon-links": "^6.5.8",
"@storybook/builder-webpack5": "^6.5.8",
"@storybook/manager-webpack5": "^6.5.8",
"@storybook/react": "^6.5.15",
"@storybook/testing-library": "^0.0.9",
"@storybook/addon-actions": "^8.4.7",
"@storybook/addon-essentials": "^8.4.7",
"@storybook/addon-interactions": "^8.4.7",
"@storybook/addon-links": "^8.4.7",
"@storybook/react": "^8.4.7",
"@storybook/react-webpack5": "^8.4.7",
"@storybook/testing-library": "^0.2.1",
"@svgr/webpack": "^6.1.1",
"@types/flat": "^5.0.2",
"@types/react": "^18.0.26",
+2 -1
View File
@@ -29,7 +29,8 @@
"ts-jest": "^27.0.5",
"jest": "^27.1.0",
"babel-plugin-root-import": "^5.1.0",
"rimraf": "^3.0.2"
"rimraf": "^3.0.2",
"@types/minimatch": "^5.1.2"
},
"scripts": {
"clean": "rimraf dist",
+3991 -5967
View File
File diff suppressed because it is too large Load Diff