diff --git a/explorer-nextjs/app/api/explorer/index.ts b/explorer-nextjs/app/api/explorer/index.ts new file mode 100644 index 0000000000..6f30ae06d7 --- /dev/null +++ b/explorer-nextjs/app/api/explorer/index.ts @@ -0,0 +1,191 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { + EXPLORER_API, + COSMOS_API, + VALIDATOR_API_EPOCH, + VALIDATOR_API_SUPPLY, + HARBOURMASTER_API_SUMMARY, + HARBOURMASTER_API_MIXNODES_STATS, + HARBOURMASTER_API_BASE, + CURRENT_EPOCH, + CURRENT_EPOCH_REWARDS, + CIRCULATING_NYM_SUPPLY, +} from "../urls"; + +export interface ExplorerData { + circulatingNymSupplyData: any; + nymNodesData: any; + packetsAndStakingData: any; + currentEpochData: any; + currentEpochRewardsData: any; +} + +export interface ExplorerCache { + data?: ExplorerData; + lastUpdated?: Date; +} + +declare global { + // Extend the global object with our custom property + var explorerCache: ExplorerCache | undefined; +} + +const CACHE_TIME_SECONDS = 60 * 5; // 5 minutes + +const getExplorerData = async () => { + // FETCH NYMNODES + const fetchNymNodes = await fetch(HARBOURMASTER_API_SUMMARY, { + headers: { + Accept: "application/json", + "Content-Type": "application/json; charset=utf-8", + }, + // refresh event list cache at given interval + next: { revalidate: Number(process.env.NEXT_PUBLIC_REVALIDATE_CACHE) }, + }); + + // FETCH CURRENT EPOCH + const fetchCurrentEpoch = await fetch(CURRENT_EPOCH, { + headers: { + Accept: "application/json", + "Content-Type": "application/json; charset=utf-8", + }, + // refresh event list cache at given interval + next: { revalidate: Number(process.env.NEXT_PUBLIC_REVALIDATE_CACHE) }, + }); + + // FETCH CURRENT EPOCH REWARDS + const fetchCurrentEpochRewards = await fetch(CURRENT_EPOCH_REWARDS, { + headers: { + Accept: "application/json", + "Content-Type": "application/json; charset=utf-8", + }, + // refresh event list cache at given interval + next: { revalidate: Number(process.env.NEXT_PUBLIC_REVALIDATE_CACHE) }, + }); + + // FETCH CIRCULATING NYM SUPPLY + const fetchCirculatingNymSupply = await fetch(CIRCULATING_NYM_SUPPLY, { + headers: { + Accept: "application/json", + "Content-Type": "application/json; charset=utf-8", + }, + // refresh event list cache at given interval + next: { revalidate: Number(process.env.NEXT_PUBLIC_REVALIDATE_CACHE) }, + }); + + // FETCH PACKETS AND STAKING + const fetchPacketsAndStaking = await fetch(HARBOURMASTER_API_MIXNODES_STATS, { + headers: { + Accept: "application/json", + "Content-Type": "application/json; charset=utf-8", + }, + // refresh event list cache at given interval + next: { revalidate: Number(process.env.NEXT_PUBLIC_REVALIDATE_CACHE) }, + }); + + const [ + circulatingNymSupplyRes, + nymNodesRes, + packetsAndStakingRes, + currentEpochRes, + currentEpochRewardsRes, + ] = await Promise.all([ + fetchCirculatingNymSupply, + fetchNymNodes, + fetchPacketsAndStaking, + fetchCurrentEpoch, + fetchCurrentEpochRewards, + ]); + + const [ + circulatingNymSupplyData, + nymNodesData, + packetsAndStakingData, + currentEpochData, + currentEpochRewardsData, + ] = await Promise.all([ + circulatingNymSupplyRes.json(), + nymNodesRes.json(), + packetsAndStakingRes.json(), + currentEpochRes.json(), + currentEpochRewardsRes.json(), + ]); + + return [ + circulatingNymSupplyData, + nymNodesData, + packetsAndStakingData, + currentEpochData, + currentEpochRewardsData, + ]; +}; + +export async function ensureCacheExists() { + // makes sure the cache exists in global memory + let doUpdate = false; + const now = new Date(); + if (!global.explorerCache) { + global.explorerCache = {}; + doUpdate = true; + } + if ( + global.explorerCache.lastUpdated && + now.getDate() - global.explorerCache.lastUpdated.getDate() > + CACHE_TIME_SECONDS + ) { + doUpdate = true; + } + + // if the cache has expired or never existed, get it from API's + if (doUpdate) { + const [ + circulatingNymSupplyData, + nymNodesData, + packetsAndStakingData, + currentEpochData, + currentEpochRewardsData, + ] = await getExplorerData(); + + packetsAndStakingData.pop(); + + global.explorerCache.data = { + circulatingNymSupplyData, + nymNodesData, + + packetsAndStakingData, + currentEpochData, + currentEpochRewardsData, + }; + global.explorerCache.lastUpdated = now; + } +} + +export async function getCacheExplorerData() { + await ensureCacheExists(); + + if (!global.explorerCache?.data) { + return null; + } + + return global.explorerCache.data || null; +} + +/** + * This is a custom API route that returns metadata from Strapi about images: height, width, strapi download url. + * + * The response from Strapi is cached in memory for CACHE_TIME_SECONDS. + */ +// export default async function handler( +// req: NextApiRequest, +// res: NextApiResponse +// ) { +// // return cached data +// const data = await getCacheExplorerData(); +// if (data) { +// res.status(200).json(data); +// res.end(); +// } + +// // catch-all +// res.status(404).end(); +// } diff --git a/explorer-nextjs/app/api/urls.ts b/explorer-nextjs/app/api/urls.ts new file mode 100644 index 0000000000..39952a07ef --- /dev/null +++ b/explorer-nextjs/app/api/urls.ts @@ -0,0 +1,18 @@ +export const HARBOURMASTER_API_SUMMARY = + "https://harbourmaster.nymtech.net/v2/summary"; +export const EXPLORER_API = "https://explorer.nymtech.net/api/v1/countries"; +export const VALIDATOR_API_SUPPLY = + "https://validator.nymtech.net/api/v1/circulating-supply"; +export const COSMOS_API = + "https://api.nymtech.net/cosmos/bank/v1beta1/balances/n1a53udazy8ayufvy0s434pfwjcedzqv34yg485t"; +export const VALIDATOR_API_EPOCH = + "https://validator.nymtech.net/api/v1/epoch/reward_params"; +export const HARBOURMASTER_API_MIXNODES_STATS = + "https://harbourmaster.nymtech.net/v2/mixnodes/stats"; +export const HARBOURMASTER_API_BASE = "https://harbourmaster.nymtech.net"; +export const CURRENT_EPOCH = + " https://validator.nymtech.net/api/v1/epoch/current"; +export const CURRENT_EPOCH_REWARDS = + "https://validator.nymtech.net/api/v1/epoch/reward_params"; +export const CIRCULATING_NYM_SUPPLY = + "https://validator.nymtech.net/api/v1/circulating-supply"; diff --git a/explorer-nextjs/app/components/ExplorerCard.tsx b/explorer-nextjs/app/components/ExplorerCard.tsx index bd7d16faae..9ab2ea7e89 100644 --- a/explorer-nextjs/app/components/ExplorerCard.tsx +++ b/explorer-nextjs/app/components/ExplorerCard.tsx @@ -8,15 +8,17 @@ import { interface ICardUpDownPriceLineProps { percentage: number; - priceWentUp: boolean; + numberWentUp: boolean; } const CardUpDownPriceLine = ( props: ICardUpDownPriceLineProps ): ReactElement => { - const { percentage, priceWentUp } = props; + const { percentage, numberWentUp } = props; return ( - {percentage}% (24H) + + {percentage}% (24H) + ); }; @@ -66,7 +68,7 @@ const CardDataRows = (props: ICardDataRowsProps): React.ReactNode => { type ContentCardProps = { overTitle?: string; - title?: string; + title?: string | number; upDownLine?: ICardUpDownPriceLineProps; titlePrice?: ICardTitlePriceProps; dataRows?: ICardDataRowsProps; @@ -90,7 +92,7 @@ export const ExplorerCard: FC = ({ {overTitle && ( - + {overTitle} )} diff --git a/explorer-nextjs/app/components/ExplorerProgressBar.tsx b/explorer-nextjs/app/components/ExplorerProgressBar.tsx index 2fe98ae613..af9cf54db8 100644 --- a/explorer-nextjs/app/components/ExplorerProgressBar.tsx +++ b/explorer-nextjs/app/components/ExplorerProgressBar.tsx @@ -6,22 +6,21 @@ import { Typography } from "@mui/material"; export interface IExplorerProgressBarProps { title?: string; start: string; // Start timestamp as ISO 8601 string - end: string; // End timestamp as ISO 8601 string showEpoch: boolean; } export const ExplorerProgressBar = (props: IExplorerProgressBarProps) => { - const { start, end, showEpoch, title } = props; + const { start, showEpoch, title } = props; const [progress, setProgress] = React.useState(0); React.useEffect(() => { - // Parse the timestamps + // Parse the start timestamp const startTime = new Date(start).getTime(); - const endTime = new Date(end).getTime(); + const endTime = startTime + 60 * 60 * 1000; // Add 1 hour to the start time - // Validate timestamps - if (isNaN(startTime) || isNaN(endTime)) { - console.error("Invalid start or end timestamp:", { start, end }); + // Validate start timestamp + if (isNaN(startTime)) { + console.error("Invalid start timestamp:", { start }); return; } @@ -49,17 +48,50 @@ export const ExplorerProgressBar = (props: IExplorerProgressBarProps) => { return () => { clearInterval(timer); }; - }, [start, end]); + }, [start]); + + // Helper function to format date + const formatDate = (timestamp: number) => { + const date = new Date(timestamp); + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const month = String(date.getMonth() + 1).padStart(2, "0"); // Months are 0-based + const year = date.getFullYear(); + return `${hours}:${minutes}, ${day}/${month}/${year}`; + }; + + const startTime = new Date(start).getTime(); + const endTime = startTime + 60 * 60 * 1000; return ( - {title && {title}} + {title && ( + + {title} + + )} - + {showEpoch && ( - Start: {new Date(start).toLocaleString()} - End: {new Date(end).toLocaleString()} + + START: + {formatDate(startTime)} + + + END: + {formatDate(endTime)} + )} diff --git a/explorer-nextjs/app/page.tsx b/explorer-nextjs/app/page.tsx index 8ebada4a88..7dfc19d7f6 100644 --- a/explorer-nextjs/app/page.tsx +++ b/explorer-nextjs/app/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect } from "react"; +import React, { useEffect, useState } from "react"; import { Box, Grid, Link, Typography } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import OpenInNewIcon from "@mui/icons-material/OpenInNew"; @@ -18,6 +18,8 @@ import { formatNumber } from "@/app/utils"; import { useMainContext } from "./context/main"; import { useRouter } from "next/navigation"; import { ExplorerCard } from "./components/ExplorerCard"; +import type { GetStaticProps, InferGetStaticPropsType } from "next"; +import { ExplorerData, getCacheExplorerData } from "./api/explorer"; // type ContentCardProps = { // overTitle?: string; @@ -36,13 +38,13 @@ const explorerCard = { title: "SINGLE", upDownLine: { percentage: 10, - priceWentUp: true, + numberWentUp: true, }, titlePrice: { price: 1.15, upDownLine: { percentage: 10, - priceWentUp: true, + numberWentUp: true, }, }, dataRows: { @@ -73,19 +75,85 @@ const explorerCard = { purpleLineNumericData: 4, }, ], - progressBar: { - title: "Current NGM epoch", - start: "2024-11-24T13:26:19Z", - end: "2024-11-24T14:26:19Z", - showEpoch: true, - }, + paragraph: "Additional line", }; +export const DATA_REVALIDATE = 60; -const PageOverview = () => { +export default function PageOverview() { + const [explorerData, setExplorerData] = useState(null); + + useEffect(() => { + async function fetchData() { + const data = await getCacheExplorerData(); + setExplorerData(data); + } + fetchData(); + }, []); const theme = useTheme(); const router = useRouter(); + console.log("explorerData :>> ", explorerData); + const currentEpochStart = + explorerData?.currentEpochData.current_epoch_start || ""; + + const progressBar = { + title: "Current NGM epoch", + start: currentEpochStart, + showEpoch: true, + }; + + const formatBigNum = (num: number) => { + if (typeof num === "number") { + if (num >= 1000000000) { + return (num / 1000000000).toFixed(1).replace(/\.0$/, "") + "B"; + } + if (num >= 1000000) { + return (num / 1000000).toFixed(1).replace(/\.0$/, "") + "M"; + } + if (num >= 1000) { + return (num / 1000).toFixed(1).replace(/\.0$/, "") + "K"; + } + return num; + } + }; + + const packetsSentLast24H = + explorerData?.packetsAndStakingData[ + explorerData.packetsAndStakingData.length - 1 + ].total_packets_sent; + + const packetsSentPrevious24H = + explorerData?.packetsAndStakingData[ + explorerData.packetsAndStakingData.length - 2 + ].total_packets_sent; + + const calculatePercentageChange = (last24H: number, previous24H: number) => { + if (previous24H === 0) { + throw new Error( + "Cannot calculate percentage change when yesterday's value is zero." + ); + } + + const change = ((last24H - previous24H) / previous24H) * 100; + + return parseFloat(change.toFixed(2)); + }; + + const percentage = calculatePercentageChange( + packetsSentLast24H, + packetsSentPrevious24H + ); + + const noiseCard = { + overTitle: "Noise generated last 24h", + title: formatBigNum(packetsSentLast24H) || "", + upDownLine: { + percentage: Math.abs(percentage), + numberWentUp: percentage > 0, + }, + }; + const { summaryOverview, gateways, @@ -107,6 +175,12 @@ const PageOverview = () => { + + + + + + router.push("/network-components/mixnodes")} @@ -218,6 +292,4 @@ const PageOverview = () => { ); -}; - -export default PageOverview; +}