-
-
-
- {children}
-
+
+
+
+ {children}
+
>
- )
+ );
}
diff --git a/src/components/Map/index.tsx b/src/components/Map/index.tsx
deleted file mode 100644
index 1e75b8ad..00000000
--- a/src/components/Map/index.tsx
+++ /dev/null
@@ -1,144 +0,0 @@
-'use client'
-import React, { useState, useEffect } from 'react'
-import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet'
-import MarkerIcon from '../../assets/marker_map_icon.png'
-import L, { LatLngExpression } from 'leaflet'
-import 'leaflet/dist/leaflet.css'
-import styles from './style.module.css'
-import { useMapContext } from '../../context/MapContext'
-import { Alert, Box, LinearProgress, Skeleton } from '@mui/material'
-import { LocationNode } from '../../shared/types/locationNodeType'
-
-export default function Map() {
- const [isClient, setIsClient] = useState(false)
- const { data, loading, error } = useMapContext()
-
- useEffect(() => {
- setIsClient(true)
- }, [])
-
- if (loading || !data) {
- return (
-
-
-
- )
- }
-
- if (error) {
- return (
-
-
- Error loading map data: {error?.message || 'Something went wrong'}
-
-
- )
- }
-
- const center: [number, number] = [25, 0]
-
- const customIcon = L.icon({
- iconUrl: MarkerIcon.src,
- iconSize: [36, 36],
- iconAnchor: [19, 36],
- popupAnchor: [0, -36]
- })
-
- const getRandomOffset = (): number => {
- const min = 0.0002
- const max = 0.0006
- const randomValue = Math.random() * (max - min) + min
- return Math.random() < 0.5 ? -randomValue : randomValue
- }
-
- const offsetCoordinates = (latitude: number, longitude: number): LatLngExpression => {
- const latOffset = getRandomOffset()
- const lngOffset = getRandomOffset()
- return [latitude + latOffset, longitude + lngOffset]
- }
-
- const groupedNodesByCity = data.reduce(
- (
- acc: Record
,
- node: LocationNode
- ) => {
- const { city, lat, lon, country, count } = node
-
- if (city) {
- if (!acc[city]) {
- acc[city] = { lat, lon, country, count }
- } else {
- acc[city].count += count
- }
- }
-
- return acc
- },
- {}
- )
-
- return (
- isClient && (
-
-
- {!loading &&
- !error &&
- Object.entries(groupedNodesByCity).map(
- ([city, { lat, lon, country, count }]) => {
- if (
- typeof lat !== 'number' ||
- typeof lon !== 'number' ||
- isNaN(lat) ||
- isNaN(lon)
- ) {
- console.warn(
- `Invalid coordinates for city: ${city}, lat: ${lat}, lon: ${lon}`
- )
- return null
- }
-
- return (
-
-
- City: {city}
-
- Country: {country}
-
- Total Nodes: {count}
-
-
-
- )
- }
- )}
- {loading && }
-
- )
- )
-}
diff --git a/src/components/Map/style.module.css b/src/components/Map/style.module.css
deleted file mode 100644
index 241b799d..00000000
--- a/src/components/Map/style.module.css
+++ /dev/null
@@ -1,18 +0,0 @@
-.popup{
- width: 420px;
-}
-
-.mapContainer {
- animation: fadeIn 0.5s ease-in;
-}
-
-@keyframes fadeIn {
- from {
- opacity: 0;
- transform: translateY(10px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
-}
\ No newline at end of file
diff --git a/src/components/Navigation/index.tsx b/src/components/Navigation/index.tsx
deleted file mode 100644
index d3bf76e5..00000000
--- a/src/components/Navigation/index.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-'use client'
-import Image from 'next/image'
-import logo from '../../assets/logo.svg'
-import styles from './style.module.css'
-// import { ConnectButton } from '@rainbow-me/rainbowkit'
-import Link from 'next/link'
-import { getRoutes } from '../../config'
-
-const NavBar = () => {
- const routes = getRoutes()
-
- return (
-
-
-
- Phase 1 of Ocean Nodes is complete. We're building towards Phase 2.
-
-
- Join us as an
Alpha GPU Node Tester and help build the
- decentralized GPU network of tomorrow.{' '}
-
- View details here
-
-
-
-
-
-
-
-
-
-
- {Object.values(routes).map((route) => (
-
- {route.name}
-
- ))}
-
- {/*
*/}
-
-
- )
-}
-
-export default NavBar
diff --git a/src/components/Navigation/navigation.module.css b/src/components/Navigation/navigation.module.css
new file mode 100644
index 00000000..b267a927
--- /dev/null
+++ b/src/components/Navigation/navigation.module.css
@@ -0,0 +1,249 @@
+.root {
+ position: sticky;
+ top: 0;
+ z-index: 100;
+}
+
+.loginButton {
+ font-weight: 500;
+}
+
+.container {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 24px;
+ padding: 22px 0;
+}
+
+.logoWrapper {
+ flex: 0 0 auto;
+ display: flex;
+ align-items: center;
+}
+
+.desktopNav {
+ display: flex;
+ gap: 10px;
+ font-size: 16px;
+ background: rgba(0, 155, 255, 0.1);
+ border: 1px solid rgba(0, 155, 255, 0.2);
+ padding: 16px 24px;
+ border-radius: 40px;
+ backdrop-filter: blur(10px);
+}
+
+.navLink {
+ padding: 8px 16px;
+ border-radius: 100px;
+ transition:
+ background-color 0.2s ease,
+ color 0.2s ease;
+}
+
+.navLink:hover {
+ background-color: rgba(0, 153, 255, 0.3);
+}
+
+.active {
+ background-color: #be00ff;
+ color: #ffffff;
+}
+
+.sideActions {
+ display: flex;
+ gap: 16px;
+ align-items: center;
+ justify-content: flex-end;
+}
+
+.actionIconLink {
+ display: inline-flex;
+ width: 38px;
+ height: 38px;
+ border-radius: 999px;
+ align-items: center;
+ justify-content: center;
+ background: rgba(190, 0, 255, 0.12);
+ transition: background-color 0.2s ease;
+}
+
+.actionIconLink:hover {
+ background: rgba(190, 0, 255, 0.3);
+}
+
+.loginButton {
+ min-width: max-content;
+}
+
+.menuToggle {
+ display: none;
+ position: relative;
+ width: 44px;
+ height: 44px;
+ border-radius: 50%;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ background: rgba(0, 0, 0, 0.3);
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ flex-direction: column;
+ cursor: pointer;
+ transition: border-color 0.2s ease;
+}
+
+.menuToggle span {
+ display: block;
+ width: 20px;
+ height: 2px;
+ background: #ffffff;
+ transition:
+ transform 0.3s ease,
+ opacity 0.3s ease;
+}
+
+.menuToggleOpen span:nth-child(1) {
+ transform: translateY(6px) rotate(45deg);
+}
+
+.menuToggleOpen span:nth-child(2) {
+ opacity: 0;
+}
+
+.menuToggleOpen span:nth-child(3) {
+ transform: translateY(-6px) rotate(-45deg);
+}
+
+.mobileMenu {
+ position: fixed;
+ top: 0;
+ right: 0;
+ width: min(360px, 100vw);
+ height: 100vh;
+ background: #030713;
+ padding: 30px 28px;
+ display: flex;
+ flex-direction: column;
+ gap: 28px;
+ transform: translateX(100%);
+ transition: transform 0.3s ease;
+ z-index: 95;
+ box-shadow: -12px 0 32px rgba(0, 0, 0, 0.4);
+ overflow-y: auto;
+}
+
+.mobileMenuOpen {
+ transform: translateX(0);
+}
+
+.mobileMenuHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.closeButton {
+ position: relative;
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ background: rgba(190, 0, 255, 0.2);
+ border: none;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.closeButton span {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 16px;
+ height: 2px;
+ background: #ffffff;
+ transform-origin: center;
+}
+
+.closeButton span:first-child {
+ transform: translate(-50%, -50%) rotate(45deg);
+}
+
+.closeButton span:last-child {
+ transform: translate(-50%, -50%) rotate(-45deg);
+}
+
+.mobileNavLinks {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.mobileNavLink {
+ display: block;
+ padding: 14px 18px;
+ border-radius: 16px;
+ border: 1px solid rgba(0, 153, 255, 0.16);
+ background: rgba(0, 153, 255, 0.08);
+ text-transform: uppercase;
+ font-size: 14px;
+ letter-spacing: 0.12em;
+ transition:
+ background-color 0.2s ease,
+ border-color 0.2s ease;
+}
+
+.mobileNavLink:hover {
+ background: rgba(190, 0, 255, 0.2);
+ border-color: rgba(190, 0, 255, 0.4);
+}
+
+.mobileActions {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ margin-top: auto;
+}
+
+.mobileActions .actionIconLink {
+ width: 44px;
+ height: 44px;
+}
+
+.mobileActions .loginButton {
+ width: 100%;
+ text-align: center;
+}
+
+.mobileBackdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.4);
+ opacity: 0;
+ visibility: hidden;
+ transition:
+ opacity 0.3s ease,
+ visibility 0.3s ease;
+ z-index: 90;
+}
+
+.mobileBackdropVisible {
+ visibility: visible;
+}
+
+@media (max-width: 1100px) {
+ .desktopNav {
+ font-size: 15px;
+ }
+}
+
+@media (max-width: 940px) {
+ .desktopNav,
+ .sideActions {
+ display: none;
+ }
+
+ .menuToggle {
+ display: inline-flex;
+ }
+}
diff --git a/src/components/Navigation/navigation.tsx b/src/components/Navigation/navigation.tsx
new file mode 100644
index 00000000..5c39b9f7
--- /dev/null
+++ b/src/components/Navigation/navigation.tsx
@@ -0,0 +1,147 @@
+import DiscordIcon from '@/assets/discord.svg';
+import Logo from '@/assets/logo.svg';
+import XIcon from '@/assets/x.svg';
+import ProfileButton from '@/components/Navigation/profile-button';
+import cx from 'classnames';
+import Link from 'next/link';
+import { useRouter } from 'next/router';
+import { useEffect, useRef, useState } from 'react';
+import config, { getRoutes } from '../../config';
+import Container from '../container/container';
+import styles from './navigation.module.css';
+
+const Navigation = () => {
+ const router = useRouter();
+ const routes = getRoutes();
+
+ const scrollPositionRef = useRef(0);
+
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+ const [isClient, setIsClient] = useState(false);
+
+ useEffect(() => {
+ setIsClient(true);
+ }, []);
+
+ useEffect(() => {
+ const html = document.documentElement;
+ const body = document.body;
+
+ if (isMenuOpen) {
+ scrollPositionRef.current = window.scrollY;
+ html.style.overflow = 'hidden';
+ body.style.overflow = 'hidden';
+ body.style.position = 'fixed';
+ body.style.top = `-${scrollPositionRef.current}px`;
+ body.style.width = '100%';
+ } else {
+ html.style.overflow = '';
+ body.style.overflow = '';
+ body.style.position = '';
+ body.style.top = '';
+ body.style.width = '';
+ window.scrollTo(0, scrollPositionRef.current);
+ }
+
+ return () => {
+ html.style.overflow = '';
+ body.style.overflow = '';
+ if (body.style.top) {
+ const scrollY = Math.abs(parseInt(body.style.top, 10));
+ body.style.position = '';
+ body.style.top = '';
+ body.style.width = '';
+ window.scrollTo(0, scrollY || scrollPositionRef.current);
+ } else {
+ body.style.position = '';
+ body.style.width = '';
+ }
+ };
+ }, [isMenuOpen]);
+
+ useEffect(() => {
+ setIsMenuOpen(false);
+ }, [router.pathname]);
+
+ const renderNavLinks = (className: string) =>
+ Object.values(routes).map((route) => (
+
+ {route.name}
+
+ ));
+
+ const Actions = ({ className }: { className: string }) => (
+
+ );
+
+ return (
+
+ );
+};
+
+export default Navigation;
diff --git a/src/components/Navigation/profile-button.tsx b/src/components/Navigation/profile-button.tsx
new file mode 100644
index 00000000..242f0ccc
--- /dev/null
+++ b/src/components/Navigation/profile-button.tsx
@@ -0,0 +1,131 @@
+import Avatar from '@/components/avatar/avatar';
+import { useProfileContext } from '@/context/profile-context';
+import { useOceanAccount } from '@/lib/use-ocean-account';
+import { formatWalletAddress } from '@/utils/formatters';
+import { useAuthModal, useLogout, useSignerStatus } from '@account-kit/react';
+import LogoutIcon from '@mui/icons-material/Logout';
+import PersonIcon from '@mui/icons-material/Person';
+import WalletIcon from '@mui/icons-material/Wallet';
+import { ListItemIcon, Menu, MenuItem } from '@mui/material';
+import { useRouter } from 'next/router';
+import { useEffect, useMemo, useState } from 'react';
+import Button from '../button/button';
+import styles from './navigation.module.css';
+
+const ProfileButton = () => {
+ const { openAuthModal } = useAuthModal();
+ const { logout, isLoggingOut } = useLogout();
+ const router = useRouter();
+ const { isAuthenticating, isInitializing } = useSignerStatus();
+
+ const { account } = useOceanAccount();
+
+ const { ensName, ensProfile } = useProfileContext();
+
+ const [anchorEl, setAnchorEl] = useState(null);
+ const [isClient, setIsClient] = useState(false);
+
+ useEffect(() => {
+ setIsClient(true);
+ }, []);
+
+ const handleOpenMenu = () => {
+ setAnchorEl(document.getElementById('profile-button'));
+ };
+
+ const handleCloseMenu = () => {
+ setAnchorEl(null);
+ };
+
+ const accountName = useMemo(() => {
+ if (account.status === 'connected' && account.address) {
+ if (ensName) {
+ return ensName;
+ }
+ if (account.address) {
+ return formatWalletAddress(account.address);
+ }
+ }
+ return 'Not connected';
+ }, [account, ensName]);
+
+ return isClient && account?.status === 'connected' ? (
+ <>
+ :
+ }
+ id="profile-button"
+ onClick={() => {
+ handleOpenMenu();
+ }}
+ >
+ {accountName}
+
+
+ >
+ ) : (
+
+ );
+};
+
+export default ProfileButton;
diff --git a/src/components/Navigation/style.module.css b/src/components/Navigation/style.module.css
deleted file mode 100644
index 45784fae..00000000
--- a/src/components/Navigation/style.module.css
+++ /dev/null
@@ -1,100 +0,0 @@
-.root {
- display: flex;
- flex-direction: column;
- gap: 16px;
-}
-
-.banner {
- background: #fff;
- border-radius: 20px;
- padding: 16px 32px;
-
- .heading {
- font-size: 18px;
- font-weight: 800;
- margin-bottom: 8px;
- }
-
- .link {
- color: #cf1fb1;
- text-decoration: underline;
- }
-}
-
-.navbarParent {
- top: 40px;
- z-index: 1000;
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 9px 37px 4px 37px;
- background-color: rgba(207, 31, 177, 0.04);
- border-radius: 20px;
- -webkit-backdrop-filter: blur(15px);
- backdrop-filter: blur(15px);
- width: 100%;
- margin: 0 auto;
- box-shadow: 0px 7px 23px 0px rgba(0, 0, 0, 0.05);
- max-width: 1160px;
- margin: 0 auto;
-}
-
-.logoWrapper {
- flex-shrink: 0;
-}
-
-.connectButtonWrapper {
- flex-shrink: 0;
-}
-
-.NavbarLinks {
- display: flex;
- flex-direction: row;
- align-items: center;
- gap: 24px;
-}
-
-.navLinks {
- display: flex;
- gap: 2rem;
-}
-
-.navLink {
- color: white;
- text-decoration: none;
- font-size: 20px;
- font-family: 'Sharp Sans', sans-serif;
- font-weight: 400;
- line-height: 26px;
- letter-spacing: 0em;
- text-align: left;
-}
-
-.navLink:hover {
- text-decoration: none;
-}
-
-@media screen and (max-width: 700px) {
- .navbarParent {
- width: 90vw;
- margin: 0 auto;
- }
-}
-
-@media (max-width: 768px) {
- .navbarParent {
- flex-direction: column;
- gap: 16px;
- padding: 16px;
- }
-
- .NavbarLinks {
- flex-direction: column;
- gap: 16px;
- margin-top: 16px;
- }
-
- .navLink {
- font-size: 16px;
- }
-}
diff --git a/src/components/NodeDetails/index.module.css b/src/components/NodeDetails/index.module.css
deleted file mode 100644
index 12cb19d8..00000000
--- a/src/components/NodeDetails/index.module.css
+++ /dev/null
@@ -1,57 +0,0 @@
-.root {
- width: calc(100% - 64px);
- padding: 32px 42px;
- margin-top: 24px;
- margin-left: 32px;
- margin-right: 32px;
- border-radius: 12px;
- display: flex;
- flex-direction: column;
- flex-wrap: wrap;
- gap: 14px;
- background-color: var(--background-secondary);
-}
-
-@media (max-width: 768px) {
- .root {
- width: calc(100% - 32px);
- margin-left: 16px;
- margin-right: 16px;
- padding: 16px;
- }
-
- .key, .value {
- width: 100%;
- }
-
- .item {
- flex-direction: column;
- align-items: flex-start;
- gap: 8px;
- }
-}
-
-.item {
- width: 100%;
- border-bottom: 1px solid var(--border-color);
- padding-bottom: 12px;
- display: flex;
- flex-direction: row;
- justify-content: start;
- align-items: center;
- gap: 24px;
-}
-
-.key {
- width: 200px;
- color: var(--color-secondary);
- font-weight: 700;
- text-transform: capitalize;
-}
-
-.value {
- width: calc(100% - 200px);
- color: var(--gray-500);
- font-weight: 400;
- word-break: break-word;
-}
diff --git a/src/components/NodeDetails/index.tsx b/src/components/NodeDetails/index.tsx
deleted file mode 100644
index 7fe69cfc..00000000
--- a/src/components/NodeDetails/index.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import React from 'react'
-import { ExpanderComponentProps } from 'react-data-table-component'
-import { NodeData } from '@Types/RowDataType'
-import styles from './index.module.css'
-
-
-const NodeDetails: React.FC> = ({ data }) => {
- const keyValuePairs = Object.keys(data).map((key) => {
- const value = data[key as keyof NodeData];
- return { key, value: typeof value === 'object' ? JSON.stringify(value) : value };
- });
-
- return (
-
- {keyValuePairs.map((item) => (
-
-
{item.key}
-
{String(item.value)}
-
- ))}
-
- );
-};
-
-export default NodeDetails;
diff --git a/src/components/NodePeers/index.tsx b/src/components/NodePeers/index.tsx
deleted file mode 100644
index f24eeacd..00000000
--- a/src/components/NodePeers/index.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-import React, { useEffect, useState } from 'react'
-import styles from './style.module.css'
-import Spinner from '../Spinner'
-import { truncateString } from '../../shared/utils/truncateString'
-import Copy from '../Copy'
-
-export default function NodePeers() {
- const [nodePeers, setNodePeers] = useState([])
- const [isLoadingNodePeers, setLoadingNodePeers] = useState(true)
-
- const fetchNodePeers = async () => {
- setLoadingNodePeers(true)
- try {
- const apiNodePeers = '/getOceanPeers'
- const res = await fetch(apiNodePeers, {
- headers: {
- Accept: 'application/json',
- 'Content-Type': 'application/json'
- },
- method: 'GET'
- })
- const data = await res.json()
- setNodePeers(data)
- } catch (error) {
- console.error('error', error)
- } finally {
- setLoadingNodePeers(false)
- }
- }
-
- useEffect(() => {
- fetchNodePeers()
-
- const intervalId = setInterval(() => {
- fetchNodePeers()
- }, 120000) // 2 minutes
-
- return () => clearInterval(intervalId)
- }, [])
-
- return (
-
-
Connected Nodes
- {isLoadingNodePeers && (
-
-
-
- )}
-
- {nodePeers.length > 0 ? (
- nodePeers.map((address) => (
-
- {truncateString(address, 12)}
-
- ))
- ) : (
-
There are no nodes connected
- )}
-
- )
-}
diff --git a/src/components/NodePeers/style.module.css b/src/components/NodePeers/style.module.css
deleted file mode 100644
index b77d3672..00000000
--- a/src/components/NodePeers/style.module.css
+++ /dev/null
@@ -1,55 +0,0 @@
-.title24 {
- color: #3D4551;
- font-family: 'Sharp Sans', Helvetica, Arial, sans-serif;
- font-size: 18px;
- font-style: normal;
- font-weight: 700;
- line-height: 140%; /* 33.6px */
-}
-
-.loaderContainer {
- position: absolute;
- width: 100%;
- height: 100%;
- background-color: rgba(51, 51, 51, 0.2);
- display: flex;
- flex-direction: row;
- justify-content: center;
- align-items: center;
- border-radius: 12px;
-}
-
-.nodes {
- display: flex;
- flex-direction: column;
- gap: 15px;
- position: relative;
-
- color: var(--Gray-Gray-500, #718096);
- font-family: Helvetica;
- font-size: 18px;
- font-style: normal;
- font-weight: 400;
- line-height: 140%; /* 25.2px */
-}
-
-.nodeAddress {
- display: flex;
- flex-direction: row;
- gap: 18px;
-}
-
-.nodeAddress:hover {
- color: #333;
- cursor: pointer;
-}
-
-.nodeAddress > h5 {
- color: #3D4551;
- font-family: Helvetica;
- font-size: 18px;
- font-style: normal;
- font-weight: 700;
- line-height: 150%; /* 30px */
- min-width: 55px;
-}
diff --git a/src/components/Pages/Countries/index.module.css b/src/components/Pages/Countries/index.module.css
deleted file mode 100644
index 71ce9328..00000000
--- a/src/components/Pages/Countries/index.module.css
+++ /dev/null
@@ -1,42 +0,0 @@
-.root {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 100px;
-}
-
-@media (max-width: 768px) {
- .root {
- gap: 50px;
- }
-}
-
-.title {
- font-size: 50px;
- font-weight: 700;
- line-height: 62px;
- color: white;
- margin-bottom: 16px;
-}
-
-@media (max-width: 768px) {
- .title {
- font-size: 36px;
- line-height: 44px;
- }
-}
-
-.description {
- font-size: 18px;
- font-weight: 400;
- line-height: 30px;
- color: white;
- margin-bottom: 40px;
-}
-
-@media (max-width: 768px) {
- .description {
- font-size: 16px;
- line-height: 24px;
- }
-}
diff --git a/src/components/Pages/Countries/index.tsx b/src/components/Pages/Countries/index.tsx
deleted file mode 100644
index 7b6b9f30..00000000
--- a/src/components/Pages/Countries/index.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import React from 'react'
-import styles from './index.module.css'
-import Table from '../../Table'
-import HeroSection from '../../HeroSection/HeroSection'
-import { TableTypeEnum } from '@/shared/enums/TableTypeEnum'
-import { useCountriesContext } from '@/context/CountriesContext'
-
-const CountriesPage: React.FC = () => {
- const {
- data,
- loading,
- currentPage,
- pageSize,
- totalItems,
- setCurrentPage,
- setPageSize
- } = useCountriesContext()
-
- return (
-
-
-
{
- setCurrentPage(page)
- setPageSize(size)
- }}
- />
-
- )
-}
-
-export default CountriesPage
diff --git a/src/components/Pages/History/index.module.css b/src/components/Pages/History/index.module.css
deleted file mode 100644
index 94b4dd6d..00000000
--- a/src/components/Pages/History/index.module.css
+++ /dev/null
@@ -1,130 +0,0 @@
-.root {
- display: flex;
- flex-direction: column;
- align-items: center;
- padding: 24px;
- width: 100%;
-}
-
-.searchBarCenter {
- display: flex;
- justify-content: center;
- width: 100%;
- margin: 32px 0;
-}
-
-.searchBarTop {
- display: flex;
- justify-content: center;
- padding: 20px;
- transition: all 0.3s ease-in-out;
- width: 100%;
-}
-
-.searchForm {
- width: 100%;
- max-width: 600px;
- display: flex;
- justify-content: center;
-}
-
-.resultsContainer {
- width: 100%;
- max-width: 1200px;
- margin: 0 auto;
- display: flex;
- flex-direction: column;
- align-items: center;
- background-color: white;
- border-radius: 8px;
- padding: 24px;
- box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
-}
-
-.resultsTitle {
- font-family: 'Sharp Sans', sans-serif;
- font-size: 20px;
- margin-bottom: 24px;
- text-align: center;
- width: 100%;
- color: #333;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.statsCardsContainer {
- width: 100%;
- margin: 0 auto;
- display: flex;
- flex-direction: row;
- align-items: center;
-}
-
-@media (max-width: 768px) {
- .searchBarCenter, .searchBarTop {
- padding: 0 15px;
- }
-
- .resultsContainer {
- padding: 0 15px 30px 15px;
- }
-}
-.root {
- width: 100%;
-}
-
-.dashboardContainer {
- max-width: 1280px;
- margin: 0 auto 50px;
- width: 100%;
-}
-
-.searchBarCenter {
- display: flex;
- justify-content: center;
- margin-bottom: 30px;
-}
-
-.searchForm {
- width: 100%;
- max-width: 600px;
-}
-
-.resultsContainer {
- max-width: 1280px;
- margin: 0 auto;
-}
-
-.dateRangeContainer {
- width: 100%;
- max-width: 1280px;
- margin: 0 auto 20px;
- padding: 0 16px;
- display: flex;
- justify-content: flex-end;
- position: relative;
- z-index: 1;
-}
-
-/* Ensure the date picker shows clearly */
-.dateRangeContainer > div {
- min-width: 300px;
- backdrop-filter: blur(20px);
- -webkit-backdrop-filter: blur(20px);
-}
-
-/* Responsive styles for date picker */
-@media (max-width: 768px) {
- .dateRangeContainer {
- padding: 0 12px;
- }
-
- .dateRangeContainer > div {
- min-width: 260px;
- }
-}
-
-.dashboardContainer {
- margin-top: 20px;
-}
diff --git a/src/components/Pages/History/index.tsx b/src/components/Pages/History/index.tsx
deleted file mode 100644
index 27e92cd7..00000000
--- a/src/components/Pages/History/index.tsx
+++ /dev/null
@@ -1,176 +0,0 @@
-import React, { useEffect, useState } from 'react'
-import { useRouter } from 'next/router'
-import { TextField, Box, InputAdornment, IconButton } from '@mui/material'
-import SearchIcon from '@mui/icons-material/Search'
-import ClearIcon from '@mui/icons-material/Clear'
-import styles from './index.module.css'
-import Table from '../../Table'
-import HeroSection from '../../HeroSection/HeroSection'
-import { TableTypeEnum } from '../../../shared/enums/TableTypeEnum'
-import { useHistoryContext } from '../../../context/HistoryContext'
-import { HistoryDashboard } from '../../Dashboard'
-import PeriodSelect, { DateRange } from '../../PeriodSelect'
-
-const DEBOUNCE_DELAY = 1000
-
-const HistoryPage: React.FC = () => {
- const router = useRouter()
- const [debounceTimeout, setDebounceTimeout] = useState(null)
-
- const {
- data,
- loading,
- currentPage,
- pageSize,
- totalItems,
- nodeId,
- setNodeId,
- setCurrentPage,
- setPageSize,
- dateRange,
- setDateRange,
- setIsSearching,
- availablePeriods,
- periodsLoading,
- isInitialising,
- isSearching
- } = useHistoryContext()
-
-
- useEffect(() => {
- const nodeIdFromUrl = router.query.id || router.query.nodeid
-
- if (typeof nodeIdFromUrl === 'string' && nodeIdFromUrl) {
- setNodeId(nodeIdFromUrl)
- if (!isSearching) {
- if (debounceTimeout) clearTimeout(debounceTimeout)
-
- const timeout = setTimeout(() => {
- setIsSearching(true)
- }, DEBOUNCE_DELAY)
-
- setDebounceTimeout(timeout)
- }
- } else if (!nodeIdFromUrl && nodeId) {
- setNodeId('')
- }
-
- return () => {
- if (debounceTimeout) clearTimeout(debounceTimeout)
- }
- }, [router.query])
-
- const handleSearch = () => {
- const trimmedNodeId = nodeId.trim()
- if (trimmedNodeId) {
- const currentQuery = { ...router.query }
- const newQuery: { [key: string]: string | string[] | undefined } = {
- ...currentQuery,
- id: trimmedNodeId
- }
- delete newQuery.nodeid
-
- router.push({
- pathname: router.pathname,
- query: newQuery
- })
- setIsSearching(true)
- }
- }
-
- const handleClear = () => {
- setNodeId('')
- const newQuery = { ...router.query }
- delete newQuery.id
- delete newQuery.nodeid
- router.push({
- pathname: router.pathname,
- query: newQuery
- })
- setIsSearching(false)
- }
-
-
- const handleDateRangeChange = (newRange: DateRange) => {
- if (newRange.startDate && newRange.endDate) {
- setDateRange(newRange)
- setCurrentPage(1)
- } else {
- console.log('[HistoryPage] Ignoring invalid date range change (missing dates)')
- }
- }
-
- return (
-
-
-
-
- setNodeId(e.target.value)}
- placeholder="Enter node ID..."
- onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
- variant="outlined"
- InputProps={{
- startAdornment: (
-
-
-
- ),
- endAdornment: nodeId && (
-
-
-
-
-
- ),
- sx: {
- borderRadius: '20px',
- backgroundColor: 'rgba(255, 255, 255, 0.9)',
- '&.Mui-focused': {
- boxShadow: '0 1px 6px rgba(32, 33, 36, 0.28)'
- },
- boxShadow: '0 1px 3px rgba(32, 33, 36, 0.28)',
- height: '40px',
- maxHeight: '40px'
- }
- }}
- />
-
-
-
-
- {nodeId && nodeId.trim() !== '' && (
- <>
-
-
-
-
-
{
- setCurrentPage(page)
- setPageSize(size)
- }}
- />
- >
- )}
-
- )
-}
-
-export default HistoryPage
diff --git a/src/components/Pages/Homepage/index.tsx b/src/components/Pages/Homepage/index.tsx
deleted file mode 100644
index e4cee000..00000000
--- a/src/components/Pages/Homepage/index.tsx
+++ /dev/null
@@ -1,113 +0,0 @@
-import dynamic from 'next/dynamic'
-import { useEffect } from 'react'
-
-import styles from './style.module.css'
-
-import PieChartCard from '@/components/PieChart/PieChart'
-import HeroSection from '@/components/HeroSection/HeroSection'
-import { useNodesContext } from '@/context/NodesContext'
-import TopCountriesChart from '@/components/TopCountriesChart/TopCountriesChart '
-import { NodesDashboard } from '@/components/Dashboard'
-
-const Map = dynamic(() => import('../../Map'), { ssr: false })
-
-interface SystemStats {
- cpuCounts: { [key: string]: number }
- operatingSystems: { [key: string]: number }
- cpuArchitectures: { [key: string]: number }
-}
-
-const brandColors = {
- primary: ['#7b1173', '#bd2881', '#d53288', '#e000cf', '#fe4796', '#ff4092'],
- other: '#f7f7f7'
-}
-
-interface ChartDataItem {
- name: string
- value: number
- color: string
- details?: string[]
-}
-
-const processChartData = (
- data: Record,
- maxSlices: number
-): ChartDataItem[] => {
- if (!data) return []
-
- const sortedEntries = Object.entries(data).sort(([, a], [, b]) => b - a)
-
- const mainEntries = sortedEntries.slice(0, maxSlices)
- const otherEntries = sortedEntries.slice(maxSlices)
- const otherCount = otherEntries.reduce((sum, [, count]) => sum + count, 0)
-
- const result = mainEntries.map(
- ([key, count], index): ChartDataItem => ({
- name: key,
- value: count,
- color: brandColors.primary[index]
- })
- )
-
- if (otherCount > 0) {
- result.push({
- name: 'Other',
- value: otherCount,
- color: brandColors.other,
- details: otherEntries.map(([key, count]) => `${key}: ${count} nodes`)
- })
- }
-
- return result
-}
-
-const processCpuData = (stats: SystemStats): ChartDataItem[] => {
- if (!stats?.cpuCounts) return []
- const data = processChartData(stats.cpuCounts, 5)
- return data.map((item) => ({
- ...item,
- name:
- item.name === 'Other'
- ? item.name
- : `${item.name} CPU${item.name !== '1' ? 's' : ''}`,
- details: item.details?.map((detail) => {
- const [count, nodes] = detail.split(':')
- return `${count} CPU${count !== '1' ? 's' : ''}:${nodes}`
- })
- }))
-}
-
-const processOsData = (stats: SystemStats): ChartDataItem[] => {
- if (!stats?.operatingSystems) return []
- return processChartData(stats.operatingSystems, 3)
-}
-
-const processCpuArchData = (stats: SystemStats): ChartDataItem[] => {
- if (!stats?.cpuArchitectures) return []
- const data = processChartData(stats.cpuArchitectures, 3)
-
- return data.map((item) => ({
- ...item,
- name: item.name.toUpperCase(),
- details: item.details?.map((detail) => detail.toUpperCase())
- }))
-}
-
-export default function HomePage() {
- const { systemStats } = useNodesContext()
-
- return (
-
- )
-}
diff --git a/src/components/Pages/Incentives/index.module.css b/src/components/Pages/Incentives/index.module.css
deleted file mode 100644
index 71ce9328..00000000
--- a/src/components/Pages/Incentives/index.module.css
+++ /dev/null
@@ -1,42 +0,0 @@
-.root {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 100px;
-}
-
-@media (max-width: 768px) {
- .root {
- gap: 50px;
- }
-}
-
-.title {
- font-size: 50px;
- font-weight: 700;
- line-height: 62px;
- color: white;
- margin-bottom: 16px;
-}
-
-@media (max-width: 768px) {
- .title {
- font-size: 36px;
- line-height: 44px;
- }
-}
-
-.description {
- font-size: 18px;
- font-weight: 400;
- line-height: 30px;
- color: white;
- margin-bottom: 40px;
-}
-
-@media (max-width: 768px) {
- .description {
- font-size: 16px;
- line-height: 24px;
- }
-}
diff --git a/src/components/Pages/Incentives/index.tsx b/src/components/Pages/Incentives/index.tsx
deleted file mode 100644
index 2f7e6190..00000000
--- a/src/components/Pages/Incentives/index.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import React from 'react'
-import styles from './index.module.css'
-
-// import Table from '../../Table'
-import HeroSection from '../../HeroSection/HeroSection'
-
-const IncentivesPage: React.FC = () => {
- return (
-
- )
-}
-
-export default IncentivesPage
diff --git a/src/components/Pages/NodeDashboard/AdminAccounts.tsx b/src/components/Pages/NodeDashboard/AdminAccounts.tsx
deleted file mode 100644
index f46d8df5..00000000
--- a/src/components/Pages/NodeDashboard/AdminAccounts.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import styles from './index.module.css'
-import { useAdminContext } from '@/context/AdminProvider'
-
-export default function AdminAccounts() {
- const { allAdmins } = useAdminContext()
-
- return (
-
-
Admin Accounts
-
- {allAdmins.map((admin, i) => {
- return (
-
- {admin}
-
- )
- })}
-
-
- )
-}
diff --git a/src/components/Pages/NodeDashboard/Indexer.tsx b/src/components/Pages/NodeDashboard/Indexer.tsx
deleted file mode 100644
index 9fe47ca5..00000000
--- a/src/components/Pages/NodeDashboard/Indexer.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import cs from 'classnames'
-import styles from './index.module.css'
-import IndexQueue from '../../IndexQueue'
-import { NodeDataType } from '@Types/dataTypes'
-import { Card, Grid } from '@mui/material'
-
-export default function Indexer({ data }: { data: NodeDataType | undefined }) {
- return (
-
-
INDEXER
-
- {data?.indexer.map((item) => {
- return (
-
-
- {item.network}
- ChainID: {item.chainId}
- BLOCK: {item.block}
-
-
- )
- })}
-
-
-
-
- )
-}
diff --git a/src/components/Pages/NodeDashboard/Menu.module.css b/src/components/Pages/NodeDashboard/Menu.module.css
deleted file mode 100644
index 5b6108f9..00000000
--- a/src/components/Pages/NodeDashboard/Menu.module.css
+++ /dev/null
@@ -1,28 +0,0 @@
-.root {
- border-radius: 12px;
- background: #FFF;
- max-width: 260px;
- display: flex;
- flex-direction: column;
- padding: 40px 28px;
- min-width: 260px;
-}
-
-.title {
- color: #3D4551;
- font-family: Helvetica;
- font-size: 20px;
- font-style: normal;
- font-weight: 700;
- line-height: 140%;
- margin-bottom: 47px;
-}
-
-@media screen and (max-width: 700px) {
- .root {
- max-width: none;
- width: 90vw;
- margin: 0 auto;
- padding: 20px;
- }
-}
diff --git a/src/components/Pages/NodeDashboard/Menu.tsx b/src/components/Pages/NodeDashboard/Menu.tsx
deleted file mode 100644
index 4c938bda..00000000
--- a/src/components/Pages/NodeDashboard/Menu.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import React from 'react'
-import AdminActions from '../../Admin'
-import styles from './Menu.module.css'
-
-export default function Menu() {
- return (
-
- )
-}
diff --git a/src/components/Pages/NodeDashboard/NodePlatform.tsx b/src/components/Pages/NodeDashboard/NodePlatform.tsx
deleted file mode 100644
index c6682272..00000000
--- a/src/components/Pages/NodeDashboard/NodePlatform.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import styles from './index.module.css'
-
-export default function NodePlatform({
- platformData
-}: {
- platformData: { key: string; value: string | number }[]
-}) {
- return (
-
-
PLATFORM
-
- {platformData.map((item) => {
- return (
-
-
- {item.key}:
-
-
{item.value}
-
- )
- })}
-
-
- )
-}
diff --git a/src/components/Pages/NodeDashboard/SupportedStorage.tsx b/src/components/Pages/NodeDashboard/SupportedStorage.tsx
deleted file mode 100644
index 9199f76e..00000000
--- a/src/components/Pages/NodeDashboard/SupportedStorage.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import styles from './index.module.css'
-import { SupportedStorageType } from '@Types/dataTypes'
-
-export default function SupportedStorage({
- data
-}: {
- data: SupportedStorageType | undefined
-}) {
- return (
-
-
SUPPORTED STORAGE
-
-
-
- arwave:
-
-
{data?.arwave.toString()}
-
-
-
- ipfs:
-
-
{data?.ipfs.toString()}
-
-
-
- url:
-
-
{data?.url.toString()}
-
-
-
- )
-}
diff --git a/src/components/Pages/NodeDashboard/index.module.css b/src/components/Pages/NodeDashboard/index.module.css
deleted file mode 100644
index d99c4c5b..00000000
--- a/src/components/Pages/NodeDashboard/index.module.css
+++ /dev/null
@@ -1,244 +0,0 @@
-.root {
- display: flex;
- flex-direction: row;
- gap: 28px;
- position: relative;
- min-height: 550px;
-}
-
-.bodyContainer {
- position: relative;
- width: 100%;
-}
-
-.body {
- padding: 40px 72px;
- border-radius: 12px;
- background: #0E001A;
- width: 100%;
-}
-
-.details {
- display: flex;
- flex-direction: row;
- width: 100%;
-}
-
-.columnP2P {
- /* padding: 32px; */
- border-bottom: 1.5px solid #eef1f5;
- border-right: 1.5px solid #eef1f5;
- width: 50%;
-}
-
-.columnHTTP {
- /* padding: 32px; */
- border-bottom: 1.5px solid #eef1f5;
- width: 50%;
-}
-
-.columnP2P > div {
- padding: 18px 18px 18px 0;
-}
-
-.columnHTTP > div {
- padding: 18px;
-}
-
-.nodes {
- display: flex;
- flex-direction: column;
- gap: 15px;
-
- color: var(--gray-500);
- font-family: 'Sharp Sans', Helvetica, Arial, sans-serif;
- font-size: 14px;
- font-style: normal;
- font-weight: 400;
- line-height: 140%; /* 25.2px */
-}
-
-.borderBottom {
- border-bottom: 1.5px solid #eef1f5;
-}
-
-.title29 {
- color: #3d4551;
- font-family: Helvetica;
- font-size: 20px;
- font-style: normal;
- font-weight: 700;
- line-height: 140%; /* 40.6px */
- margin-bottom: 38px;
-}
-
-.title24 {
- color: #3d4551;
- font-family: Helvetica;
- font-size: 18px;
- font-style: normal;
- font-weight: 700;
- line-height: 140%; /* 33.6px */
-}
-
-.nodeAddress {
- display: flex;
- flex-direction: row;
- gap: 18px;
-}
-
-.nodeAddress > h5 {
- color: var(--gray-500);
- font-family: Helvetica;
- font-size: 13px;
- font-style: normal;
- font-weight: 400;
- /* line-height: 150%; */
- /* min-width: 55px; */
-}
-
-.node {
- display: flex;
- flex-direction: row;
- gap: 18px;
-}
-
-.node:hover {
- color: #333;
- cursor: pointer;
-}
-
-.indexer {
- padding-bottom: 55px;
- padding-top: 55px;
-}
-
-.indexBlock {
- display: flex;
- flex-direction: column;
- gap: 9px;
- padding: 24px 28px;
- border-radius: 8px;
- border: 1px solid rgba(78, 203, 113, 0.7);
- border-top: 10px solid rgba(38, 194, 81, 0.7);
- min-width: 240px;
-
- color: #3d4551;
- font-family: Helvetica;
- font-size: 16px;
- font-style: normal;
- font-weight: 400;
- line-height: 140%; /* 22.4px */
-}
-
-.indexBlock h5 {
- margin-bottom: 18px;
- color: #3d4551;
- font-family: Helvetica;
- font-size: 18px;
- font-style: normal;
- font-weight: 700;
- line-height: 150%; /* 27px */
-}
-
-.delayed {
- border: 1px solid rgba(234, 89, 47, 0.9);
- border-top: 10px solid rgba(234, 89, 47, 0.9);
-}
-
-.provider {
- display: flex;
- flex-direction: column;
- gap: 10px;
-}
-
-.providerRow {
- display: flex;
- flex-direction: row;
- gap: 4px;
- font-weight: 500;
- color: var(--gray-500);
-}
-
-.providerTitle {
- min-width: 100px;
- font-weight: 700;
-}
-
-.loaderContainer {
- position: absolute;
- width: 100%;
- height: 100%;
- background-color: rgba(51, 51, 51, 0.2);
- display: flex;
- flex-direction: row;
- justify-content: center;
- align-items: center;
- border-radius: 12px;
-}
-
-.loader {
- width: 48px;
- height: 48px;
- border: 2px solid #fff;
- border-radius: 50%;
- display: inline-block;
- position: relative;
- box-sizing: border-box;
- animation: rotation 1s linear infinite;
-}
-.loader::after,
-.loader::before {
- content: '';
- box-sizing: border-box;
- position: absolute;
- left: 0;
- top: 0;
- background: #ff3d00;
- width: 6px;
- height: 6px;
- transform: translate(150%, 150%);
- border-radius: 50%;
-}
-.loader::before {
- left: auto;
- top: auto;
- right: 0;
- bottom: 0;
- transform: translate(-150%, -150%);
-}
-
-@keyframes rotation {
- 0% {
- transform: rotate(0deg);
- }
- 100% {
- transform: rotate(360deg);
- }
-}
-
-@media screen and (max-width: 700px) {
- .root {
- flex-direction: column;
- }
-
- .body {
- max-width: none;
- width: 90vw;
- margin: 0 auto;
- padding: 20px;
- }
-
- .details {
- flex-direction: column;
- }
-
- .columnHTTP {
- width: 100%;
- }
-
- .columnP2P {
- width: 100%;
- border-right: 0;
- }
-}
diff --git a/src/components/Pages/NodeDashboard/index.tsx b/src/components/Pages/NodeDashboard/index.tsx
deleted file mode 100644
index ee994003..00000000
--- a/src/components/Pages/NodeDashboard/index.tsx
+++ /dev/null
@@ -1,161 +0,0 @@
-import React, { useEffect, useState } from 'react'
-import cs from 'classnames'
-import styles from './index.module.css'
-import { truncateString } from '../../../shared/utils/truncateString'
-import { useAdminContext } from '@/context/AdminProvider'
-import AdminActions from '../../Admin'
-import Copy from '../../Copy'
-import { NodeDataType } from '@Types/dataTypes'
-import SupportedStorage from './SupportedStorage'
-import NodePlatform from './NodePlatform'
-import { useParams } from 'next/navigation'
-import { Data } from '../../Table/data'
-
-export default function Dashboard() {
- const params = useParams()
- // const { nodeId } = params
- console.log('🚀 ~ Dashboard ~ id:', params?.id)
- const [data, setData] = useState()
- const [, setLoading] = useState(true)
- const [, setIpAddress] = useState('')
- const { setAllAdmins, setNetworks } = useAdminContext()
-
- const filteredNodeData = Data.find((node) => node.nodeId === params?.id)
- console.log('🚀 ~ Dashboard ~ getNodeMockData:', filteredNodeData)
-
- useEffect(() => {
- setLoading(true)
- try {
- const apiUrl = '/directCommand'
- fetch(apiUrl, {
- headers: {
- Accept: 'application/json',
- 'Content-Type': 'application/json'
- },
- method: 'POST',
- body: JSON.stringify({
- command: 'status'
- })
- })
- .then((res) => res.json())
- .then((data) => {
- setData(data)
- setAllAdmins(data.allowedAdmins)
- setNetworks(data.indexer)
- setLoading(false)
- })
- } catch (error) {
- setLoading(false)
- console.error('error', error)
- }
- }, [])
-
- useEffect(() => {
- // Fetch the IP address
- fetch('https://api.ipify.org?format=json')
- .then((res) => res.json())
- .then((data) => {
- setIpAddress(data.ip)
- })
- .catch((error) => {
- console.error('Failed to fetch IP address:', error)
- })
- }, [])
-
- const nodeData = [
- {
- id: filteredNodeData?.nodeId,
- ip: filteredNodeData?.ipAddress,
- indexerData: data?.indexer
- }
- ]
-
- const arrayOfPlatformObjects: { key: string; value: string | number }[] = []
-
- filteredNodeData &&
- Object.keys(filteredNodeData?.platform).forEach((key) => {
- const obj = {
- key,
- // @ts-expect-error - error is shown here because the key is used as an index.
- value: filteredNodeData?.platform[key]
- }
-
- arrayOfPlatformObjects.push(obj)
- })
-
- const ConnectionDetails = () => {
- return (
-
-
NETWORK
-
-
-
-
- P2P - {filteredNodeData?.nodeDetails.P2P ? 'UP' : 'DOWN'}
-
-
-
NODE ID
- {nodeData.map((node) => {
- return (
-
-
-
{truncateString(node.id, 12)}
-
-
-
- )
- })}
-
-
-
Location
-
{filteredNodeData?.location}
-
City
-
{filteredNodeData?.nodeDetails.city}
-
Address
-
- {filteredNodeData?.ipAddress}
-
-
-
- {/*
*/}
-
-
-
- HTTP - {filteredNodeData?.nodeDetails.Http ? 'UP' : 'DOWN'}
-
- {/*
-
-
IP :
-
{filteredNodeData?.ipAddress}
-
-
-
*/}
-
-
-
-
- )
- }
-
- return (
-
-
-
-
- {/* {filteredNodeData ? (
-
-
-
- ) : ( */}
-
-
- {/*
*/}
-
- {/*
*/}
-
-
- {/* )} */}
-
-
- )
-}
diff --git a/src/components/Pages/Nodes/index.module.css b/src/components/Pages/Nodes/index.module.css
deleted file mode 100644
index 6ce33e05..00000000
--- a/src/components/Pages/Nodes/index.module.css
+++ /dev/null
@@ -1,56 +0,0 @@
-.root {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 100px;
-}
-
-.mainContainer {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 50px;
- width: 100%;
-}
-
-.dashboardContainer {
- max-width: 1280px;
- margin: 0 auto;
- width: 100%;
-}
-
-@media (max-width: 768px) {
- .root {
- gap: 50px;
- }
-}
-
-.title {
- font-size: 50px;
- font-weight: 700;
- line-height: 62px;
- color: white;
- margin-bottom: 16px;
-}
-
-@media (max-width: 768px) {
- .title {
- font-size: 36px;
- line-height: 44px;
- }
-}
-
-.description {
- font-size: 18px;
- font-weight: 400;
- line-height: 30px;
- color: white;
- margin-bottom: 40px;
-}
-
-@media (max-width: 768px) {
- .description {
- font-size: 16px;
- line-height: 24px;
- }
-}
diff --git a/src/components/Pages/Nodes/index.tsx b/src/components/Pages/Nodes/index.tsx
deleted file mode 100644
index 5d8478d9..00000000
--- a/src/components/Pages/Nodes/index.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import React from 'react'
-import styles from './index.module.css'
-import Table from '@/components/Table'
-import HeroSection from '@/components/HeroSection/HeroSection'
-import { TableTypeEnum } from '@/shared/enums/TableTypeEnum'
-import { useNodesContext } from '@/context/NodesContext'
-import { NodesDashboard } from '@/components/Dashboard'
-
-const NodesPage: React.FC = () => {
- const {
- data,
- loading,
- currentPage,
- pageSize,
- totalItems,
- setCurrentPage,
- setPageSize
- } = useNodesContext()
-
- return (
-
-
-
-
-
-
-
{
- setCurrentPage(page)
- setPageSize(size)
- }}
- />
-
-
- )
-}
-
-export default NodesPage
diff --git a/src/components/PeriodSelect/index.tsx b/src/components/PeriodSelect/index.tsx
deleted file mode 100644
index 6962a700..00000000
--- a/src/components/PeriodSelect/index.tsx
+++ /dev/null
@@ -1,121 +0,0 @@
-import React, { useState, useEffect, useMemo } from 'react'
-import {
- Select,
- MenuItem,
- FormControl,
- Box,
- IconButton,
- Typography,
- CircularProgress
-} from '@mui/material'
-import 'react-day-picker/dist/style.css'
-import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'
-import RestartAltIcon from '@mui/icons-material/RestartAlt'
-import { Dayjs } from 'dayjs'
-import styles from './styles.module.css'
-import { PeriodOption } from '@/services/historyService'
-import { useInitializePeriodState } from './useInitializePeriodState'
-import { usePeriodSelectionHandlers } from './usePeriodSelectionHandlers'
-
-export interface DateRange {
- startDate: Dayjs | null
- endDate: Dayjs | null
-}
-
-interface PeriodSelectProps {
- onChange: (range: DateRange) => void
- initialRange?: DateRange
- availablePeriods: PeriodOption[]
- periodsLoading?: boolean
- isContextInitialising?: boolean
-}
-
-const PeriodSelect: React.FC = ({
- onChange,
- initialRange,
- availablePeriods,
- periodsLoading = false,
- isContextInitialising = false
-}) => {
- const { selectedPeriod, setSelectedPeriod, setDayPickerRange } =
- useInitializePeriodState(initialRange, availablePeriods, onChange)
-
- const { handlePeriodChange, handleReset } = usePeriodSelectionHandlers(
- availablePeriods,
- setSelectedPeriod,
- setDayPickerRange,
- onChange
- )
-
- const [lastSelectedPeriod, setLastSelectedPeriod] = useState('')
-
- useEffect(() => {
- if (selectedPeriod) {
- setLastSelectedPeriod(selectedPeriod)
- }
- }, [selectedPeriod])
-
- const selectValue = selectedPeriod || lastSelectedPeriod
-
- const formatDateRangeText = useMemo(() => {
- const period = availablePeriods.find((p) => p.value === selectValue)
- if (period) {
- return `Round ${period.weekIdentifier} (${period.startDate.format('MMM D')} - ${period.endDate.format('MMM D, YYYY')})`
- }
- return 'Select Period'
- }, [selectValue, availablePeriods])
-
- return (
-
-
- {isContextInitialising || periodsLoading ? (
-
-
-
- Loading periods...
-
-
- ) : (
-
- )}
-
-
- )
-}
-
-export default PeriodSelect
diff --git a/src/components/PeriodSelect/styles.module.css b/src/components/PeriodSelect/styles.module.css
deleted file mode 100644
index 5fb00dbb..00000000
--- a/src/components/PeriodSelect/styles.module.css
+++ /dev/null
@@ -1,475 +0,0 @@
-.container {
- position: relative;
- display: flex;
- flex-direction: column;
- align-items: flex-end;
- width: fit-content;
-}
-
-.container :global(.MuiFormControl-root) {
- width: 100% !important;
-}
-
-.container :global(.MuiBox-root) {
- width: 100% !important;
-}
-
-.selectWrapper {
- position: relative;
- display: flex;
- align-items: center;
- width: 100%;
-}
-
-.select {
- width: 100%;
- min-width: 200px;
- height: 48px;
- font-family: 'Sharp Sans', sans-serif;
- font-size: 16px;
- line-height: 20px;
- font-weight: 500;
- color: white;
- background: linear-gradient(135deg, #0E001A 0%, #CF1FB1 100%);
- border-radius: 20px;
- padding: 8px 16px;
- padding-right: 48px;
- cursor: pointer;
- display: flex;
- align-items: center;
- position: relative;
-}
-
-.select :global(.MuiSelect-select) {
- color: white !important;
-}
-
-.dateText {
- font-family: 'Sharp Sans', sans-serif !important;
- font-size: 16px !important;
- line-height: 20px !important;
- font-weight: 500 !important;
- color: white !important;
- flex: 1;
-}
-
-.iconWrapper {
- position: absolute;
- left: 90%;
- top: 50%;
- transform: translateY(-50%);
- pointer-events: none;
- display: flex;
- align-items: center;
-}
-
-.selectIcon {
- color: white !important;
- font-size: 24px !important;
- transition: transform 0.2s ease;
-}
-
-.select[aria-expanded="true"] ~ .iconWrapper .selectIcon {
- transform: rotate(180deg);
-}
-
-.resetButton {
- position: absolute !important;
- right: 40px !important;
- color: white !important;
- padding: 4px !important;
-}
-
-.resetButton:hover {
- background-color: rgba(255, 255, 255, 0.1) !important;
-}
-
-.select :global(.MuiOutlinedInput-notchedOutline) {
- border: none !important;
-}
-
-.select:hover {
- cursor: pointer;
-}
-
-.select :global(.MuiSelect-icon) {
- display: none;
-}
-
-.select :global(.MuiInput-underline)::before {
- border-bottom: none !important;
-}
-
-.select :global(.MuiInput-underline)::after {
- border-bottom: none !important;
-}
-
-.select:global(.MuiInputBase-root.MuiInput-root.MuiInput-underline),
-.select:global(.MuiInputBase-root.MuiInput-root.MuiInput-underline)::before,
-.select:global(.MuiInputBase-root.MuiInput-root.MuiInput-underline)::after,
-.select:global(.MuiInputBase-root.MuiInput-root.MuiInput-underline.MuiInputBase-colorPrimary.MuiInputBase-formControl),
-.select:global(.MuiInputBase-root.MuiInput-root.MuiInput-underline.MuiInputBase-colorPrimary.MuiInputBase-formControl)::before,
-.select:global(.MuiInputBase-root.MuiInput-root.MuiInput-underline.MuiInputBase-colorPrimary.MuiInputBase-formControl)::after {
- border-bottom: none !important;
- border: none !important;
- box-shadow: none !important;
-}
-
-.menuPaper {
- margin-top: 8px;
- background: #0E001A;
- border-radius: 12px;
- box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.1);
-}
-
-.menuItem {
- font-family: 'Sharp Sans', sans-serif;
- font-size: 14px;
- line-height: 18px;
- font-weight: 500;
- padding: 12px 16px;
- color: white;
-}
-
-.menuItem:hover {
- background-color: rgba(207, 31, 177, 0.1) !important;
-}
-
-.menuItem.Mui-selected {
- background-color: rgba(207, 31, 177, 0.2) !important;
-}
-
-.datePickerContainer {
- position: absolute;
- top: calc(100% + 8px);
- right: 0;
- z-index: 1000;
- background: rgba(14, 0, 26, 0.5);
- backdrop-filter: blur(20px);
- -webkit-backdrop-filter: blur(20px);
- border-radius: 12px;
- padding: 16px;
- box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.1);
-}
-
-.datePickerContainer :global(.MuiPickersLayout-root) {
- background: transparent;
- color: white;
-}
-
-.datePickerContainer :global(.MuiPickersCalendarHeader-root) {
- color: white;
-}
-
-.datePickerContainer :global(.MuiPickersDay-root) {
- color: white;
-}
-
-.datePickerContainer :global(.MuiPickersDay-root.Mui-selected) {
- background-color: #CF1FB1;
-}
-
-.datePickerContainer :global(.MuiPickersDay-root:hover) {
- background-color: rgba(207, 31, 177, 0.2);
-}
-
-.datePickerContainer :global(.MuiDayCalendar-weekDayLabel) {
- color: rgba(255, 255, 255, 0.7);
-}
-
-.datePickerContainer :global(.MuiPickersLayout-root),
-.datePickerContainer :global(.MuiPickersLayout-contentWrapper) {
- background: transparent !important;
-}
-
-.datePickerContainer :global(.MuiTextField-root) {
- background: transparent !important;
-}
-
-.datePickerContainer :global(.MuiOutlinedInput-root) {
- background: transparent !important;
-}
-
-.customDateContainer {
- min-width: 200px;
- background: linear-gradient(135deg, #0E001A 0%, #CF1FB1 100%);
- border-radius: 20px;
- padding: 8px 16px;
- padding-right: 48px;
- position: relative;
- display: flex;
- align-items: center;
-}
-
-.datePickersContainer {
- display: flex;
- gap: 8px;
- width: 100%;
-}
-
-.inlineDatePicker {
- flex: 1;
-}
-
-.inlineDatePicker :global(.MuiInputBase-root) {
- height: 32px;
- font-family: 'Sharp Sans', sans-serif !important;
- font-size: 16px !important;
- line-height: 20px !important;
- font-weight: 500 !important;
- padding: 0;
-}
-
-.inlineDatePicker :global(.MuiOutlinedInput-notchedOutline) {
- border: none !important;
-}
-
-.datePickerPopover {
- background: linear-gradient(135deg, rgba(14, 0, 26, 0.95) 0%, rgba(14, 0, 26, 0.85) 100%);
- backdrop-filter: blur(20px);
- border: 1px solid rgba(255, 255, 255, 0.1);
- border-radius: 24px;
- margin-top: 8px;
- overflow: hidden;
- box-shadow:
- 0 4px 24px rgba(0, 0, 0, 0.4),
- inset 0 0 40px rgba(207, 31, 177, 0.1);
-}
-
-.dayPicker {
- background: transparent;
- color: white;
- padding: 24px;
- font-family: 'Sharp Sans', sans-serif;
-}
-
-/* Month header styling */
-.dayPicker :global(.rdp-caption) {
- position: relative;
- padding: 8px 0;
- margin-bottom: 16px;
-}
-
-.dayPicker :global(.rdp-caption_label) {
- font-size: 24px;
- font-weight: 500;
- color: white;
- margin: 0;
- padding: 8px 0;
- text-align: left;
-}
-
-/* Navigation buttons */
-.dayPicker :global(.rdp-nav) {
- position: absolute;
- right: 0;
- transform: translateY(-50%);
- display: flex;
- gap: 8px;
-}
-
-.dayPicker :global(.rdp-nav > button > svg) {
- fill: #CF1FB1;
-}
-
-.dayPicker :global(.rdp-nav_button) {
- width: 32px;
- height: 32px;
- padding: 0;
- border-radius: 50%;
- background: rgba(255, 255, 255, 0.1);
- color: white;
- display: flex;
- align-items: center;
- justify-content: center;
- transition: all 0.2s ease;
-}
-
-.dayPicker :global(.rdp-nav_button:disabled) {
- opacity: 0.5;
-}
-
-.dayPicker :global(.rdp-nav_button:hover) {
- background: rgba(255, 255, 255, 0.2);
-}
-
-.dayPicker :global(.rdp-nav_button svg) {
- width: 24px;
- height: 24px;
- fill: white;
-}
-
-/* Week day headers */
-.dayPicker :global(.rdp-head_cell) {
- font-weight: 500;
- font-size: 14px;
- color: rgba(255, 255, 255, 0.6);
- padding: 8px 0;
-}
-
-/* Calendar days */
-.dayPicker :global(.rdp-button) {
- width: 40px;
- height: 40px;
- font-size: 14px;
- color: white;
- transition: all 0.2s ease;
- position: relative;
- z-index: 1;
-}
-
-.dayPicker :global(.rdp-day_today:not(.rdp-day_selected)) {
- background: rgba(255, 255, 255, 0.1);
- font-weight: bold;
-}
-
-.dayPicker :global(.rdp-button:hover:not([disabled])) {
- background: rgba(207, 31, 177, 0.2);
-}
-
-/* Range selection wrapper */
-.dayPicker :global(.rdp-row) {
- position: relative;
-}
-
-/* Range selection background */
-.dayPicker :global(.rdp-row:has(.rdp-day_range_start)),
-.dayPicker :global(.rdp-row:has(.rdp-day_range_middle)),
-.dayPicker :global(.rdp-row:has(.rdp-day_range_end)) {
- background: linear-gradient(135deg, #0E001A 0%, #CF1FB1 100%);
- border-radius: 0;
-}
-
-/* First row with selection */
-.dayPicker :global(.rdp-row:has(.rdp-day_range_start)) {
- border-top-left-radius: 20px;
- border-bottom-left-radius: 20px;
-}
-
-/* Last row with selection */
-.dayPicker :global(.rdp-row:has(.rdp-day_range_end)) {
- border-top-right-radius: 20px;
- border-bottom-right-radius: 20px;
-}
-
-/* Selected days */
-.dayPicker :global(.rdp-day_selected),
-.dayPicker :global(.rdp-day_range_start),
-.dayPicker :global(.rdp-day_range_middle),
-.dayPicker :global(.rdp-day_range_end) {
- color: white;
- font-weight: bold;
- background: transparent !important;
-}
-
-/* Single day selection */
-.dayPicker :global(.rdp-day_selected:not(.rdp-day_range_start):not(.rdp-day_range_middle):not(.rdp-day_range_end)) {
- background: linear-gradient(135deg, #0E001A 0%, #CF1FB1 100%) !important;
- border-radius: 50%;
-}
-
-/* Disabled days */
-.dayPicker :global(.rdp-button[disabled]) {
- opacity: 0.3;
-}
-
-/* Table layout */
-.dayPicker :global(.rdp-table) {
- margin: 0;
- border-spacing: 0;
-}
-
-/* Selected range container */
-.dayPicker :global(.rdp-day_range_start),
-.dayPicker :global(.rdp-day_range_end),
-.dayPicker :global(.rdp-day_range_middle) {
- position: relative;
- background: transparent !important;
- color: white;
- font-weight: bold;
-}
-
-/* Continuous gradient background */
-.dayPicker :global(.rdp-day_range_start)::before,
-.dayPicker :global(.rdp-day_range_end)::before,
-.dayPicker :global(.rdp-day_range_middle)::before {
- content: '';
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: linear-gradient(135deg, #0E001A 0%, #CF1FB1 100%);
- z-index: -1;
-}
-
-/* Range start specific styles */
-.dayPicker :global(.rdp-day_range_start)::before {
- border-top-left-radius: 50%;
- border-bottom-left-radius: 50%;
-}
-
-/* Range end specific styles */
-.dayPicker :global(.rdp-day_range_end)::before {
- border-top-right-radius: 50%;
- border-bottom-right-radius: 50%;
-}
-
-/* Single selected day */
-.dayPicker :global(.rdp-day_selected:not(.rdp-day_range_start):not(.rdp-day_range_end):not(.rdp-day_range_middle)) {
- position: relative;
- background: transparent !important;
-}
-
-.dayPicker :global(.rdp-day_selected:not(.rdp-day_range_start):not(.rdp-day_range_end):not(.rdp-day_range_middle))::before {
- content: '';
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: linear-gradient(135deg, #0E001A 0%, #CF1FB1 100%);
- border-radius: 50%;
- z-index: -1;
-}
-
-/* Ensure text remains visible */
-.dayPicker :global(.rdp-button) {
- position: relative;
- z-index: 1;
-}
-/*
-.dayPicker :global(.rdp-today:not(.rdp-outside)) {
- background: linear-gradient(135deg, #0E001A 0%, #CF1FB1 100%);
-} */
-
-.dayPicker :global(.rdp-range_start) {
- border-radius: 0;
- background: linear-gradient(var(--rdp-gradient-direction), transparent 50%, #CF1FB190 50%);
-}
-
-.dayPicker :global(.rdp-range_end) {
- border-radius: 0;
- background: linear-gradient(var(--rdp-gradient-direction), #CF1FB190 50%, transparent 50%);
-}
-
-.dayPicker :global(.rdp-range_middle) {
- border-radius: 0;
- background: #CF1FB190!important;
-}
-
-.dayPicker :global(.rdp-range_start .rdp-day_button) {
- border: 0;
- background: linear-gradient(135deg, #0E001A 0%, #CF1FB1 100%);
-}
-
-.dayPicker :global(.rdp-range_end .rdp-day_button) {
- border: 0;
- background: linear-gradient(135deg, #0E001A 0%, #CF1FB1 100%);
-}
-
-.dayPicker :global(.rdp-today:not(.rdp-outside)) {
- color: #CF1FB1;
- font-weight: 800;
-}
-
diff --git a/src/components/PeriodSelect/useInitializePeriodState.ts b/src/components/PeriodSelect/useInitializePeriodState.ts
deleted file mode 100644
index 76d83d10..00000000
--- a/src/components/PeriodSelect/useInitializePeriodState.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-import { useState, useEffect } from 'react'
-import { DateRange as DayPickerRange } from 'react-day-picker'
-import { PeriodOption } from '@/services/historyService'
-import { DateRange } from './index'
-
-export const useInitializePeriodState = (
- initialRange?: DateRange,
- availablePeriods: PeriodOption[] = [],
- onChange?: (range: DateRange) => void
-) => {
- const [selectedPeriod, setSelectedPeriod] = useState(() => {
- if (initialRange?.startDate && availablePeriods.length > 0) {
- const matchingPeriod = availablePeriods.find((p) =>
- p.startDate.isSame(initialRange.startDate, 'day')
- )
- if (matchingPeriod) {
- return matchingPeriod.value
- }
- }
- return availablePeriods.length > 0 ? availablePeriods[0].value : ''
- })
-
- const [dayPickerRange, setDayPickerRange] = useState(() => {
- if (initialRange?.startDate && initialRange.endDate) {
- return { from: initialRange.startDate.toDate(), to: initialRange.endDate.toDate() }
- }
- if (
- availablePeriods.length > 0 &&
- availablePeriods[0].startDate &&
- availablePeriods[0].endDate
- ) {
- return {
- from: availablePeriods[0].startDate.toDate(),
- to: availablePeriods[0].endDate.toDate()
- }
- }
- return undefined
- })
-
- useEffect(() => {
- if (availablePeriods.length > 0 && onChange) {
- let activePeriodValue = ''
- let activeDayPickerRange: DayPickerRange | undefined = undefined
- let activeDateRange: DateRange | null = null
-
- if (initialRange?.startDate && initialRange.endDate) {
- const matchingPeriod = availablePeriods.find((p) =>
- p.startDate.isSame(initialRange.startDate, 'day')
- )
-
- if (matchingPeriod) {
- activePeriodValue = matchingPeriod.value
- activeDayPickerRange = {
- from: matchingPeriod.startDate.toDate(),
- to: matchingPeriod.endDate.toDate()
- }
- } else {
- const firstPeriod = availablePeriods[0]
- activePeriodValue = firstPeriod.value
- activeDayPickerRange = {
- from: firstPeriod.startDate.toDate(),
- to: firstPeriod.endDate.toDate()
- }
- activeDateRange = {
- startDate: firstPeriod.startDate,
- endDate: firstPeriod.endDate
- }
- }
- } else {
- const firstPeriod = availablePeriods[0]
- activePeriodValue = firstPeriod.value
- activeDayPickerRange = {
- from: firstPeriod.startDate.toDate(),
- to: firstPeriod.endDate.toDate()
- }
- activeDateRange = {
- startDate: firstPeriod.startDate,
- endDate: firstPeriod.endDate
- }
- }
-
- setSelectedPeriod(activePeriodValue)
- setDayPickerRange(activeDayPickerRange)
- if (activeDateRange) {
- onChange(activeDateRange)
- }
- }
- }, [
- initialRange?.startDate?.valueOf(),
- initialRange?.endDate?.valueOf(),
- availablePeriods,
- onChange
- ])
-
- return {
- selectedPeriod,
- setSelectedPeriod,
- dayPickerRange,
- setDayPickerRange
- }
-}
diff --git a/src/components/PeriodSelect/usePeriodSelectionHandlers.ts b/src/components/PeriodSelect/usePeriodSelectionHandlers.ts
deleted file mode 100644
index 35724820..00000000
--- a/src/components/PeriodSelect/usePeriodSelectionHandlers.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import { PeriodOption } from '@/services/historyService'
-import { DateRange } from './index'
-import { DateRange as DayPickerRange } from 'react-day-picker'
-
-type SetSelectedPeriodType = (value: string) => void
-type SetDayPickerRangeType = (range: DayPickerRange | undefined) => void
-type OnChangeType = (range: DateRange) => void
-
-export const usePeriodSelectionHandlers = (
- availablePeriods: PeriodOption[],
- setSelectedPeriod: SetSelectedPeriodType,
- setDayPickerRange: SetDayPickerRangeType,
- onChange: OnChangeType
-) => {
- const handlePeriodChange = (event: any) => {
- const value = event.target.value as string
- console.log(`[PeriodSelect] Period changed to: ${value}`)
- setSelectedPeriod(value)
-
- const selectedHistoricalPeriod = availablePeriods.find((p) => p.value === value)
- if (selectedHistoricalPeriod) {
- setDayPickerRange({
- from: selectedHistoricalPeriod.startDate.toDate(),
- to: selectedHistoricalPeriod.endDate.toDate()
- })
- onChange({
- startDate: selectedHistoricalPeriod.startDate,
- endDate: selectedHistoricalPeriod.endDate
- })
- }
- }
-
- const handleReset = () => {
- if (availablePeriods.length > 0) {
- const firstPeriod = availablePeriods[0]
- setSelectedPeriod(firstPeriod.value)
- setDayPickerRange({
- from: firstPeriod.startDate.toDate(),
- to: firstPeriod.endDate.toDate()
- })
- onChange({ startDate: firstPeriod.startDate, endDate: firstPeriod.endDate })
- }
- }
-
- return { handlePeriodChange, handleReset }
-}
diff --git a/src/components/PieChart/PieChart.tsx b/src/components/PieChart/PieChart.tsx
deleted file mode 100644
index da354bb5..00000000
--- a/src/components/PieChart/PieChart.tsx
+++ /dev/null
@@ -1,184 +0,0 @@
-import React, { useState, useMemo } from 'react'
-import { PieChart, Pie, Cell, ResponsiveContainer, Sector, Tooltip } from 'recharts'
-import styles from './PieChartCard.module.css'
-
-interface PieChartCardProps {
- data: {
- name: string
- value: number
- color: string
- details?: string[]
- }[]
- title: string
-}
-
-const PieChartCard: React.FC = ({ data, title }) => {
- const [activeIndex, setActiveIndex] = useState(undefined)
- const [lockedIndex, setLockedIndex] = useState(undefined)
- const [hoverText, setHoverText] = useState('Hover to see details')
-
- const totalValue = useMemo(
- () => data.reduce((sum, entry) => sum + entry.value, 0),
- [data]
- )
-
- const onPieEnter = (_: any, index: number) => {
- if (lockedIndex === undefined) {
- setActiveIndex(index)
- const percentage = ((data[index].value / totalValue) * 100).toFixed(2)
- setHoverText(`${data[index].name}: ${percentage}%`)
- }
- }
-
- const onPieLeave = () => {
- if (lockedIndex === undefined) {
- setActiveIndex(undefined)
- setHoverText('Hover to see details')
- }
- }
-
- const onPieClick = (_: any, index: number) => {
- if (lockedIndex === index) {
- setLockedIndex(undefined)
- setActiveIndex(undefined)
- setHoverText('Hover to see details')
- } else {
- setLockedIndex(index)
- setActiveIndex(index)
- const percentage = ((data[index].value / totalValue) * 100).toFixed(2)
- setHoverText(`${data[index].name}: ${percentage}%`)
- }
- }
-
- const renderActiveShape = (props: any) => {
- const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill } = props
- return (
-
-
-
-
-
-
-
-
-
-
-
-
- )
- }
-
- const CustomTooltip = ({ active, payload }: any) => {
- const isActive = active || lockedIndex !== undefined
- let item
- if (lockedIndex !== undefined) {
- item = data[lockedIndex]
- } else if (payload && payload.length > 0) {
- item = payload[0].payload
- }
- if (isActive && item) {
- const percentage = ((item.value / totalValue) * 100).toFixed(1)
- return (
-
-
{item.name}
-
- Total: {item.value} nodes ({percentage}%)
-
- {item.details && (
-
- {Array.isArray(item.details) &&
- item.details.map((detail: string, index: number) => (
-
- ))}
-
- )}
-
- )
- }
- return null
- }
-
- return (
-
-
{title}
-
-
-
- {data.map((entry, index) => (
- |
- ))}
-
- }
- position={{ y: 250 }}
- wrapperStyle={{
- transition: 'opacity 0.3s ease-in-out',
- opacity: activeIndex !== undefined || lockedIndex !== undefined ? 1 : 0,
- zIndex: 1000
- }}
- />
-
-
-
{hoverText}
-
- )
-}
-
-export default PieChartCard
diff --git a/src/components/Spinner/index.tsx b/src/components/Spinner/index.tsx
deleted file mode 100644
index 0ed78626..00000000
--- a/src/components/Spinner/index.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-import React from 'react'
-
-import styles from './style.module.css'
-
-export default function Spinner() {
- return
-}
diff --git a/src/components/Spinner/style.module.css b/src/components/Spinner/style.module.css
deleted file mode 100644
index c2d20073..00000000
--- a/src/components/Spinner/style.module.css
+++ /dev/null
@@ -1,41 +0,0 @@
-.loader {
- width: 48px;
- height: 48px;
- border: 2px solid #FFF;
- border-radius: 50%;
- display: inline-block;
- position: relative;
- box-sizing: border-box;
- animation: rotation 1s linear infinite;
-}
-
-.loader::after,
-.loader::before {
- content: '';
- box-sizing: border-box;
- position: absolute;
- left: 0;
- top: 0;
- background: #FF3D00;
- width: 6px;
- height: 6px;
- transform: translate(150%, 150%);
- border-radius: 50%;
-}
-
-.loader::before {
- left: auto;
- top: auto;
- right: 0;
- bottom: 0;
- transform: translate(-150%, -150%);
-}
-
-@keyframes rotation {
- 0% {
- transform: rotate(0deg);
- }
- 100% {
- transform: rotate(360deg);
- }
-}
diff --git a/src/components/Table/NodeDetails.tsx b/src/components/Table/NodeDetails.tsx
deleted file mode 100644
index 2898d4a9..00000000
--- a/src/components/Table/NodeDetails.tsx
+++ /dev/null
@@ -1,200 +0,0 @@
-import { FC } from 'react';
-import { Card, CardContent, Grid, IconButton, Typography, Box } from '@mui/material';
-import CloseIcon from '@mui/icons-material/Close';
-import { NodeData } from '../../shared/types/RowDataType';
-import { formatPlatform, formatSupportedStorage, formatUptime } from './utils'
-
-interface NodeDetailsProps {
- nodeData: NodeData;
- onClose: () => void;
-}
-
-const NodeDetails: FC = ({ nodeData, onClose }) => {
- return (
-
-
-
-
-
- Node Details
-
-
-
-
-
-
-
-
- Node ID: {nodeData.id}
-
-
-
-
- Address: {nodeData.address}
-
-
-
-
- Network:{' '}
- {nodeData.indexer?.map((idx) => idx.network).join(', ')}
-
-
-
-
- DNS / IP: {nodeData.ipAndDns?.dns || ''} /{' '}
- {nodeData.ipAndDns?.ip || ''}
-
-
-
-
- Port: {nodeData.ipAndDns?.port || ''}
-
-
-
-
- Location:{' '}
- {`${nodeData.location?.city || ''} ${nodeData.location?.country || ''}`}
-
-
-
-
- Eligible Week Uptime: {formatUptime(nodeData.uptime)}
-
-
-
-
- Supported Storage:{' '}
- {formatSupportedStorage(nodeData.supportedStorage)}
-
-
-
-
- Platform: {formatPlatform(nodeData.platform)}
-
-
-
-
- Public Key: {nodeData.publicKey}
-
-
-
-
- Version: {nodeData.version}
-
-
-
-
- Code Hash: {nodeData.codeHash}
-
-
-
-
- Allowed Admins: {nodeData.allowedAdmins?.join(', ')}
-
-
-
-
- Last Check:{' '}
- {new Date(nodeData.lastCheck)?.toLocaleString(undefined, {
- timeZoneName: 'short'
- })}
-
-
-
-
- Last Round Eligibility Check:{' '}
- {nodeData?.eligible?.toLocaleString()}
-
-
-
-
- Eligiblity Issue:{' '}
- {nodeData.eligibilityCauseStr?.toLocaleString()}
-
-
-
-
-
-
- )
-};
-
-export default NodeDetails;
diff --git a/src/components/Table/_styles.ts b/src/components/Table/_styles.ts
deleted file mode 100644
index c8e2984e..00000000
--- a/src/components/Table/_styles.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import { createTheme, TableStyles, Theme } from 'react-data-table-component'
-
-// https://github.com/jbetancur/react-data-table-component/blob/master/src/DataTable/themes.ts
-const theme: Partial = {
- text: {
- primary: 'var(-gray-gray-500)',
- secondary: 'var(--color-secondary)',
- disabled: 'var(--color-secondary)'
- },
- background: {
- default: '#fff'
- },
- divider: {
- default: 'var(--border-color)'
- }
-}
-
-createTheme('custom', theme)
-
-// https://github.com/jbetancur/react-data-table-component/blob/master/src/DataTable/styles.ts
-export const customStyles: TableStyles = {
- table: {
- style: {
- backgroundColor: 'white',
- borderRadius: '16px',
- overflow: 'hidden'
- }
- },
- head: {
- style: {
- backgroundColor: '#f8f9fa',
- color: '#6c757d',
- fontWeight: '500',
- textTransform: 'uppercase',
- fontSize: '12px'
- }
- },
- headCells: {
- style: {
- borderBottom: '1px solid #e9ecef',
- padding: '32px 87px'
- }
- },
- rows: {
- style: {
- fontSize: '14px',
- color: '#212529',
- fontFamily: "'Sharp Sans', sans-serif",
- fontWeight: 400,
- lineHeight: '21px',
- '&:not(:last-of-type)': {
- borderBottom: '1px solid #e9ecef'
- }
- }
- },
- cells: {
- style: {
- padding: '32px 87px',
- textAlign: 'left'
- }
- }
-}
diff --git a/src/components/Table/columns.tsx b/src/components/Table/columns.tsx
deleted file mode 100644
index 638da69e..00000000
--- a/src/components/Table/columns.tsx
+++ /dev/null
@@ -1,753 +0,0 @@
-import { GridColDef, GridFilterInputValue, GridRenderCellParams } from '@mui/x-data-grid'
-import { Button, Tooltip } from '@mui/material'
-import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
-import ReportIcon from '@mui/icons-material/Report'
-import { NodeData } from '@/shared/types/RowDataType'
-import { formatSupportedStorage, formatPlatform, formatUptimePercentage } from './utils'
-import styles from './index.module.css'
-import Link from 'next/link'
-import HistoryIcon from '@mui/icons-material/History'
-import InfoIcon from '@mui/icons-material/Info'
-
-const getEligibleCheckbox = (eligible: boolean): React.ReactElement => {
- return eligible ? (
-
- ) : (
-
- )
-}
-
-const UptimeCell: React.FC<{
- uptimeInSeconds: number
- totalUptime: number | null
-}> = ({ uptimeInSeconds, totalUptime }) => {
- if (totalUptime === null) {
- return Loading...
- }
-
- return {formatUptimePercentage(uptimeInSeconds, totalUptime)}
-}
-
-export const nodeColumns = (
- totalUptime: number | null,
- setSelectedNode: (node: NodeData) => void
-): GridColDef[] => [
- {
- field: 'index',
- headerName: 'Index',
- width: 70,
- align: 'center',
- headerAlign: 'center',
- sortable: false,
- filterable: false
- },
- {
- field: 'id',
- headerName: 'Node ID',
- flex: 1,
- minWidth: 300,
- sortable: false,
- filterable: true,
- filterOperators: [
- {
- label: 'contains',
- value: 'contains',
- getApplyFilterFn: (filterItem) => {
- return (params) => {
- if (!filterItem.value) return true
- return params.value?.toLowerCase().includes(filterItem.value.toLowerCase())
- }
- },
- InputComponent: GridFilterInputValue,
- InputComponentProps: { type: 'text' }
- }
- ]
- },
- {
- field: 'uptime',
- headerName: 'Weekly Uptime',
- sortable: true,
- flex: 1,
- minWidth: 150,
- filterable: true,
- headerClassName: styles.headerTitle,
- filterOperators: [
- {
- label: 'equals',
- value: 'eq',
- getApplyFilterFn: (filterItem) => {
- return (params) => {
- if (!filterItem.value) return true
- const filterValue = Number(filterItem.value) / 100
- const uptimePercentage = params.value / params.row.totalUptime
- return Math.abs(uptimePercentage - filterValue) <= 0.001
- }
- },
- InputComponent: GridFilterInputValue,
- InputComponentProps: {
- type: 'number',
- step: '0.01',
- min: '0',
- max: '100',
- placeholder: 'Enter percentage (0-100)',
- error: !totalUptime,
- helperText: !totalUptime ? 'Loading uptime data...' : undefined
- }
- },
- {
- label: 'greater than',
- value: 'gt',
- getApplyFilterFn: (filterItem) => {
- return (params) => {
- if (!filterItem.value) return true
- const filterValue = Number(filterItem.value) / 100
- const uptimePercentage = params.value / params.row.totalUptime
- return uptimePercentage > filterValue
- }
- },
- InputComponent: GridFilterInputValue,
- InputComponentProps: {
- type: 'number',
- step: '0.01',
- min: '0',
- max: '100',
- placeholder: 'Enter percentage (0-100)',
- error: !totalUptime,
- helperText: !totalUptime ? 'Loading uptime data...' : undefined
- }
- },
- {
- label: 'less than',
- value: 'lt',
- getApplyFilterFn: (filterItem) => {
- return (params) => {
- if (!filterItem.value) return true
- const filterValue = Number(filterItem.value) / 100
- const uptimePercentage = params.value / params.row.totalUptime
- return uptimePercentage < filterValue
- }
- },
- InputComponent: GridFilterInputValue,
- InputComponentProps: {
- type: 'number',
- step: '0.01',
- min: '0',
- max: '100',
- placeholder: 'Enter percentage (0-100)',
- error: !totalUptime,
- helperText: !totalUptime ? 'Loading uptime data...' : undefined
- }
- }
- ],
- renderCell: (params: GridRenderCellParams) => (
-
- ),
- renderHeader: () => (
-
- Weekly Uptime
-
- )
- },
- {
- field: 'dns',
- headerName: 'DNS / IP',
- flex: 1,
- minWidth: 200,
- sortable: false,
- filterable: false,
- renderCell: (params: GridRenderCellParams) => (
-
- {(params.row.ipAndDns?.dns || params.row.ipAndDns?.ip || '') +
- (params.row.ipAndDns?.port ? ':' + params.row.ipAndDns?.port : '')}
-
- )
- },
- {
- field: 'dnsFilter',
- headerName: 'DNS / IP',
- flex: 1,
- minWidth: 200,
- sortable: false,
- filterable: true,
- hideable: false,
- filterOperators: [
- {
- label: 'contains',
- value: 'contains',
- getApplyFilterFn: (filterItem) => {
- return (params) => {
- if (!filterItem.value) return true
- const dnsIpString =
- (params.row.ipAndDns?.dns || params.row.ipAndDns?.ip || '') +
- (params.row.ipAndDns?.port ? ':' + params.row.ipAndDns?.port : '')
- return dnsIpString.includes(filterItem.value)
- }
- },
- InputComponent: GridFilterInputValue,
- InputComponentProps: { type: 'text' }
- }
- ]
- },
- {
- field: 'location',
- headerName: 'Location',
- flex: 1,
- minWidth: 200,
- sortable: false,
- filterable: false,
- renderCell: (params: GridRenderCellParams) => (
-
- {`${params.row.location?.city || ''} ${params.row.location?.country || ''}`}
-
- )
- },
- {
- field: 'city',
- headerName: 'City',
- flex: 1,
- minWidth: 200,
- sortable: false,
- filterable: true,
- hideable: false,
- filterOperators: [
- {
- label: 'contains',
- value: 'contains',
- getApplyFilterFn: (filterItem) => {
- return (params) => {
- if (!filterItem.value) return true
- return (params.row.location?.city || '')
- .toLowerCase()
- .includes(filterItem.value.toLowerCase())
- }
- },
- InputComponent: GridFilterInputValue,
- InputComponentProps: { type: 'text' }
- }
- ]
- },
- {
- field: 'country',
- headerName: 'Country',
- flex: 1,
- minWidth: 200,
- sortable: false,
- filterable: true,
- hideable: false,
- filterOperators: [
- {
- label: 'contains',
- value: 'contains',
- getApplyFilterFn: (filterItem) => {
- return (params) => {
- if (!filterItem.value) return true
- return (params.row.location?.country || '')
- .toLowerCase()
- .includes(filterItem.value.toLowerCase())
- }
- },
- InputComponent: GridFilterInputValue,
- InputComponentProps: { type: 'text' }
- }
- ]
- },
- {
- field: 'address',
- headerName: 'Address',
- flex: 1,
- minWidth: 150,
- sortable: false,
- filterable: false
- },
- {
- field: 'eligible',
- headerName: 'Last Check Eligibility',
- flex: 1,
- width: 80,
- filterable: false,
- sortable: true,
- renderHeader: () => (
-
- Last Check Eligibility
-
- ),
- filterOperators: [
- {
- label: 'equals',
- value: 'eq',
- getApplyFilterFn: (filterItem) => {
- return (params) => {
- if (!filterItem.value) return true
- return params.value === (filterItem.value === 'true')
- }
- },
- InputComponent: GridFilterInputValue,
- InputComponentProps: {
- type: 'singleSelect',
- valueOptions: [
- { value: 'true', label: 'Eligible' },
- { value: 'false', label: 'Not Eligible' }
- ]
- }
- }
- ],
- renderCell: (params: GridRenderCellParams) => (
-
- {getEligibleCheckbox(params.row.eligible)}
-
- )
- },
- {
- field: 'eligibilityCauseStr',
- headerName: 'Eligibility Issue',
- flex: 1,
- width: 100,
- sortable: false,
- filterable: false,
- renderCell: (params: GridRenderCellParams) => (
- {params.row.eligibilityCauseStr || 'none'}
- )
- },
- {
- field: 'lastCheck',
- headerName: 'Last Check',
- flex: 1,
- minWidth: 140,
- filterable: true,
- renderCell: (params: GridRenderCellParams) => (
-
- {new Date(params?.row?.lastCheck)?.toLocaleString(undefined, {
- timeZoneName: 'short'
- })}
-
- ),
- filterOperators: [
- {
- label: 'equals',
- value: 'eq',
- getApplyFilterFn: (filterItem) => {
- return (params) => {
- if (!filterItem.value) return true
- const filterDate = new Date(filterItem.value).getTime()
- const cellDate = new Date(params.value).getTime()
- return cellDate === filterDate
- }
- },
- InputComponent: GridFilterInputValue,
- InputComponentProps: { type: 'datetime-local' }
- },
- {
- label: 'after',
- value: 'gt',
- getApplyFilterFn: (filterItem) => {
- return (params) => {
- if (!filterItem.value) return true
- const filterDate = new Date(filterItem.value).getTime()
- const cellDate = new Date(params.value).getTime()
- return cellDate > filterDate
- }
- },
- InputComponent: GridFilterInputValue,
- InputComponentProps: { type: 'datetime-local' }
- },
- {
- label: 'before',
- value: 'lt',
- getApplyFilterFn: (filterItem) => {
- return (params) => {
- if (!filterItem.value) return true
- const filterDate = new Date(filterItem.value).getTime()
- const cellDate = new Date(params.value).getTime()
- return cellDate < filterDate
- }
- },
- InputComponent: GridFilterInputValue,
- InputComponentProps: { type: 'datetime-local' }
- }
- ]
- },
- {
- field: 'network',
- headerName: 'Network',
- flex: 1,
- minWidth: 200,
- sortable: false,
- filterable: false,
- renderCell: (params: GridRenderCellParams) => {
- const networks = params.row.provider?.map((p) => p.network).join(', ') || ''
- return {networks}
- }
- },
- {
- field: 'actions',
- headerName: 'Actions',
- sortable: false,
- width: 130,
- align: 'center',
- headerAlign: 'center',
- renderCell: (params: GridRenderCellParams) => {
- const node = params.row
- return (
-
-
-
-
-
-
-
-
-
-
-
- )
- },
- cellClassName: styles.actionCell
- },
- {
- field: 'publicKey',
- headerName: 'Public Key',
- flex: 1,
- sortable: false,
- minWidth: 200,
- filterable: false
- },
- {
- field: 'version',
- headerName: 'Version',
- flex: 1,
- minWidth: 100,
- sortable: false,
- filterable: false
- },
- {
- field: 'http',
- headerName: 'HTTP Enabled',
- flex: 1,
- minWidth: 100,
- sortable: false,
- filterable: false
- },
- {
- field: 'p2p',
- headerName: 'P2P Enabled',
- flex: 1,
- minWidth: 100,
- sortable: false,
- filterable: false
- },
- {
- field: 'supportedStorage',
- headerName: 'Supported Storage',
- flex: 1,
- minWidth: 200,
- sortable: false,
- filterable: false,
- renderCell: (params: GridRenderCellParams) => (
- {formatSupportedStorage(params.row.supportedStorage)}
- )
- },
- {
- field: 'platform',
- headerName: 'Platform',
- flex: 1,
- minWidth: 200,
- sortable: false,
- filterable: false,
- renderCell: (params: GridRenderCellParams) => (
- {formatPlatform(params.row.platform)}
- )
- },
- {
- field: 'codeHash',
- headerName: 'Code Hash',
- flex: 1,
- minWidth: 200,
- sortable: false,
- filterable: false
- },
- {
- field: 'allowedAdmins',
- headerName: 'Allowed Admins',
- flex: 1,
- minWidth: 200,
- sortable: false,
- filterable: false
- }
-]
-
-export const countryColumns: GridColDef[] = [
- {
- field: 'index',
- headerName: 'Index',
- width: 70,
- align: 'center',
- headerAlign: 'center',
- sortable: false,
- filterable: false
- },
- {
- field: 'country',
- headerName: 'Country',
- flex: 1,
- minWidth: 200,
- align: 'left',
- headerAlign: 'left',
- sortable: true,
- filterable: true,
- filterOperators: [
- {
- label: 'contains',
- value: 'contains',
- getApplyFilterFn: (filterItem) => {
- return (params) => {
- if (!filterItem.value) return true
- return params.value?.toLowerCase().includes(filterItem.value.toLowerCase())
- }
- },
- InputComponent: GridFilterInputValue,
- InputComponentProps: { type: 'text' }
- },
- {
- label: 'equals',
- value: 'eq',
- getApplyFilterFn: (filterItem) => {
- return (params) => {
- return params.value === filterItem.value
- }
- },
- InputComponent: GridFilterInputValue,
- InputComponentProps: { type: 'text' }
- }
- ]
- },
- {
- field: 'totalNodes',
- headerName: 'Total Nodes',
- flex: 1,
- minWidth: 150,
- type: 'number',
- align: 'left',
- headerAlign: 'left',
- sortable: true,
- filterable: true,
- filterOperators: [
- {
- label: 'equals',
- value: 'eq',
- getApplyFilterFn: (filterItem) => {
- return (params) => {
- return params.value === Number(filterItem.value)
- }
- },
- InputComponent: GridFilterInputValue,
- InputComponentProps: { type: 'number' }
- },
- {
- label: 'greater than',
- value: 'gt',
- getApplyFilterFn: (filterItem) => {
- return (params) => {
- return params.value > Number(filterItem.value)
- }
- },
- InputComponent: GridFilterInputValue,
- InputComponentProps: { type: 'number' }
- },
- {
- label: 'less than',
- value: 'lt',
- getApplyFilterFn: (filterItem) => {
- return (params) => {
- return params.value < Number(filterItem.value)
- }
- },
- InputComponent: GridFilterInputValue,
- InputComponentProps: { type: 'number' }
- }
- ]
- },
- {
- field: 'citiesWithNodes',
- headerName: 'Cities with Nodes',
- flex: 1,
- minWidth: 200,
- type: 'number',
- align: 'left',
- headerAlign: 'left',
- sortable: true,
- filterable: true,
- filterOperators: [
- {
- label: 'equals',
- value: 'eq',
- getApplyFilterFn: (filterItem) => {
- return (params) => {
- return params.value === Number(filterItem.value)
- }
- },
- InputComponent: GridFilterInputValue,
- InputComponentProps: { type: 'number' }
- },
- {
- label: 'greater than',
- value: 'gt',
- getApplyFilterFn: (filterItem) => {
- return (params) => {
- return params.value > Number(filterItem.value)
- }
- },
- InputComponent: GridFilterInputValue,
- InputComponentProps: { type: 'number' }
- },
- {
- label: 'less than',
- value: 'lt',
- getApplyFilterFn: (filterItem) => {
- return (params) => {
- return params.value < Number(filterItem.value)
- }
- },
- InputComponent: GridFilterInputValue,
- InputComponentProps: { type: 'number' }
- }
- ]
- },
- {
- field: 'cityWithMostNodes',
- headerName: 'City with Most Nodes',
- flex: 1,
- minWidth: 200,
- align: 'left',
- headerAlign: 'left',
- sortable: true,
- filterable: true,
- valueGetter: (params: { row: any }) => {
- return params.row?.cityWithMostNodesCount || 0
- },
- filterOperators: [
- {
- label: 'equals',
- value: 'eq',
- getApplyFilterFn: (filterItem) => {
- return (params) => {
- return params.value === Number(filterItem.value)
- }
- },
- InputComponent: GridFilterInputValue,
- InputComponentProps: { type: 'number' }
- },
- {
- label: 'greater than',
- value: 'gt',
- getApplyFilterFn: (filterItem) => {
- return (params) => {
- return params.value > Number(filterItem.value)
- }
- },
- InputComponent: GridFilterInputValue,
- InputComponentProps: { type: 'number' }
- },
- {
- label: 'less than',
- value: 'lt',
- getApplyFilterFn: (filterItem) => {
- return (params) => {
- return params.value < Number(filterItem.value)
- }
- },
- InputComponent: GridFilterInputValue,
- InputComponentProps: { type: 'number' }
- }
- ],
- renderCell: (params: GridRenderCellParams) => (
-
- {params.row.cityWithMostNodes} ({params.row.cityWithMostNodesCount} nodes)
-
- )
- }
-]
-
-export const historyColumns: GridColDef[] = [
- {
- field: 'round',
- headerName: 'Round no.',
- flex: 0.5,
- minWidth: 100,
- align: 'left',
- headerAlign: 'left',
- renderCell: (params: GridRenderCellParams) => {
- return params?.row?.round ?? '-'
- }
- },
- {
- field: 'timestamp',
- headerName: 'Timestamp',
- flex: 1,
- minWidth: 180,
- align: 'left',
- headerAlign: 'left',
- renderCell: (params: GridRenderCellParams) => {
- if (params.value == null) {
- return '-'
- }
- try {
- const date = new Date(params.value)
- const hours = String(date.getUTCHours()).padStart(2, '0')
- const minutes = String(date.getUTCMinutes()).padStart(2, '0')
- const seconds = String(date.getUTCSeconds()).padStart(2, '0')
- return `${hours}:${minutes}:${seconds} UTC`
- } catch (e) {
- console.error('Error formatting timestamp:', e)
- return 'Invalid Date'
- }
- }
- },
- {
- field: 'errorCause',
- headerName: 'Reason for Issue',
- flex: 1,
- minWidth: 200,
- align: 'left',
- headerAlign: 'left',
- renderCell: (params: GridRenderCellParams) => {
- return params?.row?.errorCause || '-'
- }
- },
- {
- field: 'derivedStatus',
- headerName: 'Status',
- flex: 0.5,
- minWidth: 120,
- align: 'left',
- headerAlign: 'left',
- sortable: false,
- renderCell: (params: GridRenderCellParams) => {
- const hasError = !!params?.row?.errorCause
- const statusText = hasError ? 'Failed' : 'Success'
- const color = hasError ? '#FF4444' : '#4CAF50'
-
- return (
-
- )
- }
- }
-]
diff --git a/src/components/Table/data.ts b/src/components/Table/data.ts
deleted file mode 100644
index 6b1e975d..00000000
--- a/src/components/Table/data.ts
+++ /dev/null
@@ -1,604 +0,0 @@
-const Data = [
- {
- nodeId: 'R4Ht8DfKxX1LNZ3Y',
- network: 'Ethereum',
- ipAddress: '192.168.1.5',
- location: 'France',
- blockNumber: '601529',
- coordinates: [48.8566, 2.3522],
- uptime: '89%',
- nodeDetails: {
- port: 30306,
- last_seen: 'a few moments ago',
- enode: 'enode://qrst...7890',
- client_type: 'Geth',
- client_version: 'v1.9.25',
- os: 'linux-x64',
- country: 'France',
- city: 'Paris',
- P2P: true,
- Http: false
- },
- supportedStorage: {
- url: true,
- arwave: false,
- ipfs: false
- },
- platform: {
- cpus: 12,
- freemem: 579903488,
- totalmem: 17179869184,
- loadavg: [2.32373046875, 2.7783203125, 2.6728515625],
- arch: 'x64',
- machine: 'x86_64',
- platform: 'darwin',
- release: '22.6.0',
- osType: 'Darwin',
- osVersion:
- 'Darwin Kernel Version 22.6.0: Wed Jul 5 22:21:56 PDT 2023; root:xnu-8796.141.3~6/RELEASE_X86_64',
- node: 'v18.16.0'
- }
- },
- {
- nodeId: 'V8Jfh6RaXmH9LPK2',
- network: 'Polygon',
- ipAddress: '192.168.1.3',
- location: 'USA',
- blockNumber: '401529',
- coordinates: [40.7128, -74.006],
- uptime: '95%',
- nodeDetails: {
- port: 30304,
- last_seen: 'a few moments ago',
- enode: 'enode://ijkl...9012',
- client_type: 'Besu',
- client_version: 'v21.1.1',
- os: 'win-x64',
- country: 'USA',
- city: 'New York',
- P2P: true,
- Http: true
- },
- supportedStorage: {
- url: true,
- arwave: false,
- ipfs: false
- },
- platform: {
- cpus: 12,
- freemem: 579903488,
- totalmem: 17179869184,
- loadavg: [2.32373046875, 2.7783203125, 2.6728515625],
- arch: 'x64',
- machine: 'x86_64',
- platform: 'darwin',
- release: '22.6.0',
- osType: 'Darwin',
- osVersion:
- 'Darwin Kernel Version 22.6.0: Wed Jul 5 22:21:56 PDT 2023; root:xnu-8796.141.3~6/RELEASE_X86_64',
- node: 'v18.16.0'
- }
- },
- {
- nodeId: 'Q3Gs7BfJvW9DMN2X',
- network: 'Optimism',
- ipAddress: '192.168.1.4',
- location: 'UK',
- blockNumber: '501529',
- coordinates: [51.5074, -0.1278],
- uptime: '92%',
- nodeDetails: {
- port: 30305,
- last_seen: 'a few moments ago',
- enode: 'enode://mnop...3456',
- client_type: 'Parity',
- client_version: 'v2.7.2',
- os: 'linux-x64',
- country: 'UK',
- city: 'London',
- P2P: false,
- Http: true
- },
- supportedStorage: {
- url: false,
- arwave: true,
- ipfs: false
- },
- platform: {
- cpus: 12,
- freemem: 579903488,
- totalmem: 17179869184,
- loadavg: [2.32373046875, 2.7783203125, 2.6728515625],
- arch: 'x64',
- machine: 'x86_64',
- platform: 'darwin',
- release: '22.6.0',
- osType: 'Darwin',
- osVersion:
- 'Darwin Kernel Version 22.6.0: Wed Jul 5 22:21:56 PDT 2023; root:xnu-8796.141.3~6/RELEASE_X86_64',
- node: 'v18.16.0'
- }
- },
- {
- nodeId: 'B3Ls9CnTzQW8JKH4',
- network: 'Ethereum',
- ipAddress: '192.168.1.5',
- location: 'Brazil',
- blockNumber: '901529',
- coordinates: [-23.5558, -46.6396],
- uptime: '91%',
- nodeDetails: {
- port: 30309,
- last_seen: 'a few moments ago',
- enode: 'enode://yzab...5678',
- client_type: 'Geth',
- client_version: 'v1.9.25',
- os: 'linux-x64',
- country: 'Brazil',
- city: 'Sao Paulo',
- P2P: true,
- Http: false
- },
- supportedStorage: {
- url: false,
- arwave: true,
- ipfs: true
- },
- platform: {
- cpus: 12,
- freemem: 579903488,
- totalmem: 17179869184,
- loadavg: [2.32373046875, 2.7783203125, 2.6728515625],
- arch: 'x64',
- machine: 'x86_64',
- platform: 'darwin',
- release: '22.6.0',
- osType: 'Darwin',
- osVersion:
- 'Darwin Kernel Version 22.6.0: Wed Jul 5 22:21:56 PDT 2023; root:xnu-8796.141.3~6/RELEASE_X86_64',
- node: 'v18.16.0'
- }
- },
- {
- nodeId: 'C6Np5BtMwD7FYLR9',
- network: 'Polygon',
- ipAddress: '192.168.1.6',
- location: 'Australia',
- blockNumber: '801529',
- coordinates: [-33.8688, 151.2093],
- uptime: '93%',
- nodeDetails: {
- port: 30308,
- last_seen: 'a few moments ago',
- enode: 'enode://uvwx...1234',
- client_type: 'Besu',
- client_version: 'v21.1.1',
- os: 'linux-x64',
- country: 'Australia',
- city: 'Sydney',
- P2P: true,
- Http: true
- },
- supportedStorage: {
- url: false,
- arwave: false,
- ipfs: true
- },
- platform: {
- cpus: 12,
- freemem: 579903488,
- totalmem: 17179869184,
- loadavg: [2.32373046875, 2.7783203125, 2.6728515625],
- arch: 'x64',
- machine: 'x86_64',
- platform: 'darwin',
- release: '22.6.0',
- osType: 'Darwin',
- osVersion:
- 'Darwin Kernel Version 22.6.0: Wed Jul 5 22:21:56 PDT 2023; root:xnu-8796.141.3~6/RELEASE_X86_64',
- node: 'v18.16.0'
- }
- },
- {
- nodeId: 'D7Uf1QrWzF3AYZ5K',
- network: 'Ethereum',
- ipAddress: '192.168.1.7',
- location: 'Japan',
- blockNumber: '1001529',
- coordinates: [35.6764, 139.65],
- uptime: '96%',
- nodeDetails: {
- port: 30310,
- last_seen: 'a few moments ago',
- enode: 'enode://cdef...9012',
- client_type: 'Geth',
- client_version: 'v1.9.25',
- os: 'linux-x64',
- country: 'Japan',
- city: 'Tokyo',
- P2P: true,
- Http: false
- },
- supportedStorage: {
- url: true,
- arwave: false,
- ipfs: false
- },
- platform: {
- cpus: 12,
- freemem: 579903488,
- totalmem: 17179869184,
- loadavg: [2.32373046875, 2.7783203125, 2.6728515625],
- arch: 'x64',
- machine: 'x86_64',
- platform: 'darwin',
- release: '22.6.0',
- osType: 'Darwin',
- osVersion:
- 'Darwin Kernel Version 22.6.0: Wed Jul 5 22:21:56 PDT 2023; root:xnu-8796.141.3~6/RELEASE_X86_64',
- node: 'v18.16.0'
- }
- },
- {
- nodeId: 'E8Vg2RsXzG4BZ6L',
- network: 'Polygon',
- ipAddress: '192.168.1.8',
- location: 'India',
- blockNumber: '1101529',
- coordinates: [19.076, 72.8777],
- uptime: '92%',
- nodeDetails: {
- port: 30311,
- last_seen: 'a few moments ago',
- enode: 'enode://ghij...3456',
- client_type: 'Besu',
- client_version: 'v21.1.1',
- os: 'win-x64',
- country: 'India',
- city: 'Mumbai',
- P2P: true,
- Http: true
- },
- supportedStorage: {
- url: true,
- arwave: false,
- ipfs: false
- },
- platform: {
- cpus: 12,
- freemem: 579903488,
- totalmem: 17179869184,
- loadavg: [2.32373046875, 2.7783203125, 2.6728515625],
- arch: 'x64',
- machine: 'x86_64',
- platform: 'darwin',
- release: '22.6.0',
- osType: 'Darwin',
- osVersion:
- 'Darwin Kernel Version 22.6.0: Wed Jul 5 22:21:56 PDT 2023; root:xnu-8796.141.3~6/RELEASE_X86_64',
- node: 'v18.16.0'
- }
- },
- {
- nodeId: 'F9Wh3StYzH5CZ7M',
- network: 'Optimism',
- ipAddress: '192.168.1.9',
- location: 'China',
- blockNumber: '1201529',
- coordinates: [39.9042, 116.4074],
- uptime: '93%',
- nodeDetails: {
- port: 30312,
- last_seen: 'a few moments ago',
- enode: 'enode://ijkl...7890',
- client_type: 'Parity',
- client_version: 'v2.7.2',
- os: 'linux-x64',
- country: 'China',
- city: 'Beijing',
- P2P: true,
- Http: true
- },
- supportedStorage: {
- url: true,
- arwave: false,
- ipfs: false
- },
- platform: {
- cpus: 12,
- freemem: 579903488,
- totalmem: 17179869184,
- loadavg: [2.32373046875, 2.7783203125, 2.6728515625],
- arch: 'x64',
- machine: 'x86_64',
- platform: 'darwin',
- release: '22.6.0',
- osType: 'Darwin',
- osVersion:
- 'Darwin Kernel Version 22.6.0: Wed Jul 5 22:21:56 PDT 2023; root:xnu-8796.141.3~6/RELEASE_X86_64',
- node: 'v18.16.0'
- }
- },
- {
- nodeId: 'G0Xj4UvAzI6EZ8N',
- network: 'Ethereum',
- ipAddress: '192.168.1.10',
- location: 'South Korea',
- blockNumber: '1301529',
- coordinates: [37.5519, 126.9918],
- uptime: '94%',
- nodeDetails: {
- port: 30313,
- last_seen: 'a few moments ago',
- enode: 'enode://klmn...1234',
- client_type: 'Geth',
- client_version: 'v1.9.25',
- os: 'linux-x64',
- country: 'South Korea',
- city: 'Seoul',
- P2P: true,
- Http: false
- },
- supportedStorage: {
- url: false,
- arwave: true,
- ipfs: false
- },
- platform: {
- cpus: 12,
- freemem: 579903488,
- totalmem: 17179869184,
- loadavg: [2.32373046875, 2.7783203125, 2.6728515625],
- arch: 'x64',
- machine: 'x86_64',
- platform: 'darwin',
- release: '22.6.0',
- osType: 'Darwin',
- osVersion:
- 'Darwin Kernel Version 22.6.0: Wed Jul 5 22:21:56 PDT 2023; root:xnu-8796.141.3~6/RELEASE_X86_64',
- node: 'v18.16.0'
- }
- },
- {
- nodeId: 'H1Yk5WbBzJ7GZ9O',
- network: 'Polygon',
- ipAddress: '192.168.1.11',
- location: 'Canada',
- blockNumber: '1401529',
- coordinates: [43.651, -79.347],
- uptime: '95%',
- nodeDetails: {
- port: 30314,
- last_seen: 'a few moments ago',
- enode: 'enode://mnop...5678',
- client_type: 'Besu',
- client_version: 'v21.1.1',
- os: 'win-x64',
- country: 'Canada',
- city: 'Toronto',
- P2P: true,
- Http: true
- },
- supportedStorage: {
- url: false,
- arwave: false,
- ipfs: true
- },
- platform: {
- cpus: 12,
- freemem: 579903488,
- totalmem: 17179869184,
- loadavg: [2.32373046875, 2.7783203125, 2.6728515625],
- arch: 'x64',
- machine: 'x86_64',
- platform: 'darwin',
- release: '22.6.0',
- osType: 'Darwin',
- osVersion:
- 'Darwin Kernel Version 22.6.0: Wed Jul 5 22:21:56 PDT 2023; root:xnu-8796.141.3~6/RELEASE_X86_64',
- node: 'v18.16.0'
- }
- },
- {
- nodeId: 'N0Op9YaEzL1HZ1P',
- network: 'Ethereum',
- ipAddress: '192.168.1.15',
- location: 'South Africa',
- blockNumber: '1801529',
- uptime: '90%',
- coordinates: [-33.9249, 18.4241],
- nodeDetails: {
- port: 30316,
- last_seen: 'a few moments ago',
- enode: 'enode://abcd...5678',
- client_type: 'Geth',
- client_version: 'v1.9.25',
- os: 'linux-x64',
- country: 'South Africa',
- city: 'Cape Town',
- P2P: true,
- Http: false
- },
- supportedStorage: {
- url: true,
- arwave: false,
- ipfs: true
- },
- platform: {
- cpus: 12,
- freemem: 579903488,
- totalmem: 17179869184,
- loadavg: [2.32373046875, 2.7783203125, 2.6728515625],
- arch: 'x64',
- machine: 'x86_64',
- platform: 'darwin',
- release: '22.6.0',
- osType: 'Darwin',
- osVersion:
- 'Darwin Kernel Version 22.6.0: Wed Jul 5 22:21:56 PDT 2023; root:xnu-8796.141.3~6/RELEASE_X86_64',
- node: 'v18.16.0'
- }
- },
- {
- nodeId: 'P1Pq0YbFzM2IZ2Q',
- network: 'Polygon',
- ipAddress: '192.168.1.16',
- location: 'Argentina',
- blockNumber: '1901529',
- uptime: '92%',
- coordinates: [-34.6037, -58.3816],
- nodeDetails: {
- port: 30317,
- last_seen: 'a few moments ago',
- enode: 'enode://efgh...7890',
- client_type: 'Besu',
- client_version: 'v21.1.1',
- os: 'win-x64',
- country: 'Argentina',
- city: 'Buenos Aires',
- P2P: true,
- Http: true
- },
- supportedStorage: {
- url: false,
- arwave: true,
- ipfs: true
- },
- platform: {
- cpus: 12,
- freemem: 579903488,
- totalmem: 17179869184,
- loadavg: [2.32373046875, 2.7783203125, 2.6728515625],
- arch: 'x64',
- machine: 'x86_64',
- platform: 'darwin',
- release: '22.6.0',
- osType: 'Darwin',
- osVersion:
- 'Darwin Kernel Version 22.6.0: Wed Jul 5 22:21:56 PDT 2023; root:xnu-8796.141.3~6/RELEASE_X86_64',
- node: 'v18.16.0'
- }
- },
- {
- nodeId: 'Q2Rq1ZcGzN3JZ3R',
- network: 'Optimism',
- ipAddress: '192.168.1.17',
- location: 'New Zealand',
- blockNumber: '2001529',
- uptime: '94%',
- coordinates: [-36.8485, 174.7633],
- nodeDetails: {
- port: 30318,
- last_seen: 'a few moments ago',
- enode: 'enode://ijkl...9012',
- client_type: 'Parity',
- client_version: 'v2.7.2',
- os: 'linux-x64',
- country: 'New Zealand',
- city: 'Auckland',
- P2P: true,
- Http: false
- },
- supportedStorage: {
- url: true,
- arwave: false,
- ipfs: true
- },
- platform: {
- cpus: 12,
- freemem: 579903488,
- totalmem: 17179869184,
- loadavg: [2.32373046875, 2.7783203125, 2.6728515625],
- arch: 'x64',
- machine: 'x86_64',
- platform: 'darwin',
- release: '22.6.0',
- osType: 'Darwin',
- osVersion:
- 'Darwin Kernel Version 22.6.0: Wed Jul 5 22:21:56 PDT 2023; root:xnu-8796.141.3~6/RELEASE_X86_64',
- node: 'v18.16.0'
- }
- },
- {
- nodeId: 'V7Wu6EgLzS8OD8W',
- network: 'Polygon',
- ipAddress: '192.168.1.22',
- location: 'Singapore',
- blockNumber: '2501529',
- uptime: '93%',
- coordinates: [1.3521, 103.8198],
- nodeDetails: {
- port: 30323,
- last_seen: 'a few moments ago',
- enode: 'enode://efgh...5678',
- client_type: 'Besu',
- client_version: 'v21.1.1',
- os: 'win-x64',
- country: 'Singapore',
- city: 'Singapore',
- P2P: false,
- Http: true
- },
- supportedStorage: {
- url: false,
- arwave: true,
- ipfs: true
- },
- platform: {
- cpus: 12,
- freemem: 579903488,
- totalmem: 17179869184,
- loadavg: [2.32373046875, 2.7783203125, 2.6728515625],
- arch: 'x64',
- machine: 'x86_64',
- platform: 'darwin',
- release: '22.6.0',
- osType: 'Darwin',
- osVersion:
- 'Darwin Kernel Version 22.6.0: Wed Jul 5 22:21:56 PDT 2023; root:xnu-8796.141.3~6/RELEASE_X86_64',
- node: 'v18.16.0'
- }
- },
- {
- nodeId: 'W8Xv7FhMzT9PE9X',
- network: 'Optimism',
- ipAddress: '192.168.1.23',
- location: 'Romania',
- blockNumber: '2601529',
- uptime: '94%',
- coordinates: [44.4268, 26.1025],
- nodeDetails: {
- port: 30324,
- last_seen: 'a few moments ago',
- enode: 'enode://ijkl...9012',
- client_type: 'Parity',
- client_version: 'v2.7.2',
- os: 'linux-x64',
- country: 'Romania',
- city: 'Bucharest',
- P2P: true,
- Http: false
- },
- supportedStorage: {
- url: true,
- arwave: false,
- ipfs: true
- },
- platform: {
- cpus: 12,
- freemem: 579903488,
- totalmem: 17179869184,
- loadavg: [2.32373046875, 2.7783203125, 2.6728515625],
- arch: 'x64',
- machine: 'x86_64',
- platform: 'darwin',
- release: '22.6.0',
- osType: 'Darwin',
- osVersion:
- 'Darwin Kernel Version 22.6.0: Wed Jul 5 22:21:56 PDT 2023; root:xnu-8796.141.3~6/RELEASE_X86_64',
- node: 'v18.16.0'
- }
- }
-]
-
-export { Data }
diff --git a/src/components/Table/hooks/useTable.ts b/src/components/Table/hooks/useTable.ts
deleted file mode 100644
index 017741b8..00000000
--- a/src/components/Table/hooks/useTable.ts
+++ /dev/null
@@ -1,255 +0,0 @@
-import { useCallback, useMemo, useRef, useState } from 'react'
-import { GridFilterModel, GridSortModel } from '@mui/x-data-grid'
-import { useNodesContext } from '@/context/NodesContext'
-import { useCountriesContext } from '@/context/CountriesContext'
-import { useHistoryContext } from '@/context/HistoryContext'
-import { TableTypeEnum } from '../../../shared/enums/TableTypeEnum'
-import { NodeData } from '../../../shared/types/RowDataType'
-
-export const useTable = (tableType: TableTypeEnum) => {
- const {
- data: nodesData,
- loading: nodesLoading,
- currentPage: nodesCurrentPage,
- pageSize: nodesPageSize,
- totalItems: nodesTotalItems,
- setCurrentPage: setNodesCurrentPage,
- setPageSize: setNodesPageSize,
- setFilter: setNodesFilter,
- totalUptime,
- searchTerm,
- setSearchTerm
- } = useNodesContext()
-
- const {
- data: countryData,
- loading: countriesLoading,
- currentPage: countriesCurrentPage,
- pageSize: countriesPageSize,
- totalItems: countriesTotalItems,
- setCurrentPage: setCountriesCurrentPage,
- setPageSize: setCountriesPageSize,
- setFilter: setCountriesFilter
- } = useCountriesContext()
-
- const {
- data: historyData,
- loading: historyLoading,
- currentPage: historyCurrentPage,
- pageSize: historyPageSize,
- totalItems: historyTotalItems,
- setCurrentPage: setHistoryCurrentPage,
- setPageSize: setHistoryPageSize,
- nodeId,
- setNodeId
- } = useHistoryContext()
-
- const [selectedNode, setSelectedNode] = useState(null)
- // const [searchTerm, setSearchTerm] = useState('')
- const [searchTermCountry, setSearchTermCountry] = useState('')
- const searchTimeout = useRef(null)
-
- const data = useMemo(() => {
- switch (tableType) {
- case TableTypeEnum.NODES:
- return nodesData
- case TableTypeEnum.COUNTRIES:
- return countryData
- case TableTypeEnum.HISTORY:
- return historyData
- default:
- return []
- }
- }, [tableType, nodesData, countryData, historyData])
-
- const loading = useMemo(() => {
- switch (tableType) {
- case TableTypeEnum.NODES:
- return nodesLoading
- case TableTypeEnum.COUNTRIES:
- return countriesLoading
- case TableTypeEnum.HISTORY:
- return historyLoading
- default:
- return false
- }
- }, [tableType, nodesLoading, countriesLoading, historyLoading])
-
- const currentPage = useMemo(() => {
- switch (tableType) {
- case TableTypeEnum.NODES:
- return nodesCurrentPage
- case TableTypeEnum.COUNTRIES:
- return countriesCurrentPage
- case TableTypeEnum.HISTORY:
- return historyCurrentPage
- default:
- return 1
- }
- }, [tableType, nodesCurrentPage, countriesCurrentPage, historyCurrentPage])
-
- const pageSize = useMemo(() => {
- switch (tableType) {
- case TableTypeEnum.NODES:
- return nodesPageSize
- case TableTypeEnum.COUNTRIES:
- return countriesPageSize
- case TableTypeEnum.HISTORY:
- return historyPageSize
- default:
- return 10
- }
- }, [tableType, nodesPageSize, countriesPageSize, historyPageSize])
-
- const totalItems = useMemo(() => {
- switch (tableType) {
- case TableTypeEnum.NODES:
- return nodesTotalItems
- case TableTypeEnum.COUNTRIES:
- return countriesTotalItems
- case TableTypeEnum.HISTORY:
- return historyTotalItems
- default:
- return 0
- }
- }, [tableType, nodesTotalItems, countriesTotalItems, historyTotalItems])
-
- const handlePaginationChange = useCallback(
- (model: { page: number; pageSize: number }) => {
- switch (tableType) {
- case TableTypeEnum.NODES:
- setNodesCurrentPage(model.page + 1)
- setNodesPageSize(model.pageSize)
- break
- case TableTypeEnum.COUNTRIES:
- setCountriesCurrentPage(model.page + 1)
- setCountriesPageSize(model.pageSize)
- break
- case TableTypeEnum.HISTORY:
- setHistoryCurrentPage(model.page + 1)
- setHistoryPageSize(model.pageSize)
- break
- }
- },
- [
- tableType,
- setNodesCurrentPage,
- setNodesPageSize,
- setCountriesCurrentPage,
- setCountriesPageSize,
- setHistoryCurrentPage,
- setHistoryPageSize
- ]
- )
-
- const handleSortModelChange = useCallback(
- (model: GridSortModel) => {
- if (model.length > 0) {
- const { field, sort } = model[0]
- const filterModel: GridFilterModel = {
- items: [
- {
- id: 1,
- field,
- operator: 'sort',
- value: sort
- }
- ]
- }
-
- switch (tableType) {
- case TableTypeEnum.NODES:
- setNodesFilter(filterModel)
- break
- case TableTypeEnum.COUNTRIES:
- setCountriesFilter(filterModel)
- break
- }
- }
- },
- [tableType, setNodesFilter, setCountriesFilter]
- )
-
- const handleFilterChange = useCallback(
- (model: GridFilterModel) => {
- switch (tableType) {
- case TableTypeEnum.NODES:
- setNodesFilter(model)
- break
- case TableTypeEnum.COUNTRIES:
- setCountriesFilter(model)
- break
- case TableTypeEnum.HISTORY:
- if (model.items?.[0]?.field === 'id') {
- setNodeId(model.items[0].value as string)
- }
- break
- }
- },
- [tableType, setNodesFilter, setCountriesFilter, setNodeId]
- )
-
- const handleSearchChange = useCallback(
- (term: string) => {
- // const filterModel: GridFilterModel = {
- // items: [{ field: 'name', operator: 'contains', value: term }]
- // }
- console.log(
- `[useTable] handleSearchChange called with term: "${term}" for tableType: ${tableType}`
- )
- if (tableType === TableTypeEnum.COUNTRIES) {
- setSearchTermCountry(term)
- if (searchTimeout.current) {
- clearTimeout(searchTimeout.current)
- }
- // searchTimeout.current = setTimeout(() => {
- // setCountriesFilter(filterModel)
- // }, 500)
- } else {
- setSearchTerm(term)
- if (searchTimeout.current) {
- clearTimeout(searchTimeout.current)
- }
- // searchTimeout.current = setTimeout(() => {
- // setNodesFilter(filterModel)
- // }, 500)
- }
- },
- [tableType, setCountriesFilter, setSearchTerm, setNodesFilter]
- )
-
- const handleReset = useCallback(() => {
- const emptyFilter: GridFilterModel = { items: [] }
-
- if (tableType === TableTypeEnum.COUNTRIES) {
- setSearchTermCountry('')
- setCountriesFilter(emptyFilter)
- } else {
- setSearchTerm('')
- setNodesFilter(emptyFilter)
- }
-
- if (searchTimeout.current) {
- clearTimeout(searchTimeout.current)
- }
- }, [tableType, setCountriesFilter, setSearchTerm, setNodesFilter])
-
- return {
- data,
- loading,
- currentPage,
- pageSize,
- totalItems,
- selectedNode,
- setSelectedNode,
- searchTerm,
- searchTermCountry,
- totalUptime,
- nodeId,
- handlePaginationChange,
- handleSortModelChange,
- handleFilterChange,
- handleSearchChange,
- handleReset
- }
-}
diff --git a/src/components/Table/index.module.css b/src/components/Table/index.module.css
deleted file mode 100644
index 2fe62548..00000000
--- a/src/components/Table/index.module.css
+++ /dev/null
@@ -1,164 +0,0 @@
-.root {
- width: 100%;
- height: calc(100vh - 64px);
- margin: 0 auto;
- border-radius: 15px;
- background: var(--white);
- box-shadow: 0px 3.5px 5.5px 0px rgba(0, 0, 0, 0.02);
- padding: 32px;
- display: flex;
- flex-direction: column;
- overflow: hidden;
-}
-
-.search{
- background: var(--white);
- color: var(--gray-700);
- border: none;
- margin-bottom: '10px';
- padding: '5px'
-}
-
-.title {
- color: var(--gray-700);
- font-family: 'Sharp Sans', Helvetica, Arial, sans-serif;
- font-size: 18px;
- font-style: normal;
- font-weight: 700;
- line-height: 140%;
-}
-
-.headerTitle {
- font-family: 'Sharp Sans', sans-serif;
- font-size: 14px;
- font-weight: 600;
- line-height: 21px;
- color: #6c757d;
- text-transform: uppercase;
-}
-
-.dropdownTriggerBox {
- width: 100%;
- display: flex;
- flex-direction: row;
- justify-content: center;
- align-items: center;
-}
-
-.dropdown {
- background-color: transparent;
- border: 0;
- outline: none;
-}
-
-.download {
- background-color: transparent;
- border: none;
- color: inherit;
- font: inherit;
- padding: 0;
- margin: 0;
- cursor: pointer;
- outline: none;
- text-decoration: none;
-}
-
-.download:hover,
-.download:focus {
- outline: none;
- background-color: rgba(0, 0, 0, 0.1);
-}
-
-.actionButtons {
- display: flex;
- gap: 8px;
- justify-content: center;
- align-items: center;
- width: 100%;
- height: 100%;
-}
-
-.actionButton {
- min-width: 36px !important;
- width: 36px;
- height: 36px;
- padding: 6px;
- border-radius: 50% !important;
- color: #CF1FB1 !important;
- border-color: #CF1FB1 !important;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.actionButton:hover {
- background-color: rgba(207, 31, 177, 0.04) !important;
-}
-
-.actionButton svg {
- font-size: 18px;
-}
-
-.actionCell {
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 0;
-}
-
-.statusContainer {
- display: flex;
- align-items: center;
- gap: 8px;
- justify-content: center;
-}
-
-.statusDot {
- width: 10px;
- height: 10px;
- border-radius: 50%;
- display: inline-block;
-}
-
-/* History table specific styles */
-.historyTable {
- border-collapse: separate;
- border-spacing: 0;
- width: 100%;
-}
-
-.historyTable th {
- background-color: #f8f9fa;
- color: #6c757d;
- font-weight: 600;
- text-transform: uppercase;
- padding: 16px;
- border-bottom: 1px solid #e9ecef;
-}
-
-.historyTable td {
- padding: 16px;
- border-bottom: 1px solid #e9ecef;
- color: #212529;
-}
-
-.historyTable tr:last-child td {
- border-bottom: none;
-}
-
-/* Status colors */
-.statusDot {
- display: inline-block;
- width: 8px;
- height: 8px;
- border-radius: 50%;
- margin-right: 8px;
-}
-
-.statusText {
- font-weight: 500;
-}
-
-.fixedWidth {
- max-width: 1240px;
-}
diff --git a/src/components/Table/index.tsx b/src/components/Table/index.tsx
deleted file mode 100644
index a7bb705b..00000000
--- a/src/components/Table/index.tsx
+++ /dev/null
@@ -1,294 +0,0 @@
-import React, { JSXElementConstructor, useMemo, useEffect } from 'react'
-import {
- DataGrid,
- GridColDef,
- GridToolbarProps,
- GridValidRowModel,
- useGridApiRef,
- GridSortModel,
- GridFilterModel
-} from '@mui/x-data-grid'
-import { useTable } from './hooks/useTable'
-import { TableTypeEnum } from '../../shared/enums/TableTypeEnum'
-import { nodeColumns, countryColumns, historyColumns } from './columns'
-import { styled } from '@mui/material/styles'
-
-import styles from './index.module.css'
-
-import NodeDetails from './NodeDetails'
-import CustomToolbar from '../Toolbar'
-import CustomPagination from './CustomPagination'
-
-const StyledDataGrid = styled(DataGrid)(({ theme }) => ({
- '& .MuiDataGrid-toolbarContainer': {
- display: 'flex',
- gap: '50px',
- '& .MuiButton-root': {
- fontFamily: 'Sharp Sans, sans-serif',
- fontSize: '14px',
- fontWeight: 400,
- lineHeight: '21px',
- textAlign: 'left',
- color: '#000000',
- '& .MuiSvgIcon-root': {
- color: '#CF1FB1'
- }
- },
- '& .MuiBadge-badge': {
- backgroundColor: '#CF1FB1'
- }
- },
- '& .MuiDataGrid-columnHeaders': {
- backgroundColor: '#f8f9fa',
- borderBottom: '1px solid #e9ecef'
- },
- '& .MuiDataGrid-columnHeaderTitle': {
- fontFamily: "'Sharp Sans', sans-serif",
- fontSize: '14px',
- fontWeight: 600,
- lineHeight: '21px',
- textAlign: 'left',
- color: '#6c757d',
- textTransform: 'uppercase',
- whiteSpace: 'nowrap',
- overflow: 'hidden',
- textOverflow: 'ellipsis'
- },
- '& .MuiDataGrid-cell': {
- fontFamily: "'Sharp Sans', sans-serif",
- fontSize: '14px',
- fontWeight: 400,
- whiteSpace: 'nowrap',
- overflow: 'hidden',
- textOverflow: 'ellipsis'
- },
- '& .MuiDataGrid-columnSeparator': {
- visibility: 'visible',
- color: '#E0E0E0'
- },
- '& .MuiDataGrid-columnHeader:hover .MuiDataGrid-columnSeparator': {
- visibility: 'visible',
- color: '#BDBDBD'
- },
- '& .MuiDataGrid-columnHeader:hover': {
- '& .MuiDataGrid-columnSeparator': {
- visibility: 'visible'
- }
- },
- '& .MuiDataGrid-cellContent': {
- whiteSpace: 'nowrap',
- overflow: 'hidden',
- textOverflow: 'ellipsis'
- }
-}))
-
-interface TableProps {
- data?: any[]
- loading?: boolean
- currentPage?: number
- pageSize?: number
- totalItems?: number
- nodeId?: string
- tableType: TableTypeEnum
- onPaginationChange?: (page: number, pageSize: number) => void
- onSortModelChange?: (model: GridSortModel) => void
- onFilterChange?: (model: GridFilterModel) => void
-}
-
-export const Table: React.FC = ({
- data: propsData,
- loading: propsLoading,
- currentPage: propCurrentPage,
- pageSize: propPageSize,
- totalItems: propTotalItems,
- nodeId,
- tableType,
- onPaginationChange,
- onSortModelChange,
- onFilterChange
-}) => {
- const {
- data: hookData,
- loading: hookLoading,
- currentPage: hookCurrentPage,
- pageSize: hookPageSize,
- totalItems: hookTotalItems,
- selectedNode,
- setSelectedNode,
- searchTerm,
- searchTermCountry,
- totalUptime,
- handlePaginationChange,
- handleSortModelChange,
- handleFilterChange,
- handleSearchChange,
- handleReset
- } = useTable(tableType)
-
- const apiRef = useGridApiRef()
-
- // Add a ref to track previous address
- const prevNodeAddressRef = React.useRef('')
-
- // Use props data if provided, otherwise use hook data
- const data = propsData || hookData
- const loading = propsLoading || hookLoading
- const currentPage = propCurrentPage || hookCurrentPage
- const pageSize = propPageSize || hookPageSize
- const totalItems = propTotalItems || hookTotalItems
-
- const columns = useMemo(() => {
- switch (tableType) {
- case TableTypeEnum.NODES:
- return nodeColumns(totalUptime, setSelectedNode)
- case TableTypeEnum.COUNTRIES:
- return countryColumns
- case TableTypeEnum.HISTORY:
- return historyColumns
- default:
- return []
- }
- }, [tableType, totalUptime, setSelectedNode])
-
- useEffect(() => {
- if (nodeId && onFilterChange) {
- onFilterChange({
- items: [
- {
- id: 1,
- field: 'id',
- operator: 'equals',
- value: nodeId
- }
- ]
- })
- }
- }, [nodeId, onFilterChange])
-
- const handlePaginationModelChange = (model: { page: number; pageSize: number }) => {
- if (onPaginationChange) {
- onPaginationChange(model.page + 1, model.pageSize)
- } else {
- handlePaginationChange(model)
- }
- }
-
- return (
-
-
- []}
- slots={{
- toolbar: CustomToolbar as JSXElementConstructor
- }}
- slotProps={{
- toolbar: {
- searchTerm:
- tableType === TableTypeEnum.COUNTRIES ? searchTermCountry : searchTerm,
- onSearchChange: handleSearchChange,
- onReset: handleReset,
- tableType: tableType,
- apiRef: apiRef.current,
- totalUptime: totalUptime
- }
- }}
- initialState={{
- columns: {
- columnVisibilityModel:
- tableType === TableTypeEnum.NODES
- ? {
- network: false,
- publicKey: false,
- version: false,
- http: false,
- p2p: false,
- supportedStorage: false,
- platform: false,
- codeHash: false,
- allowedAdmins: false,
- dnsFilter: false,
- city: false,
- country: false
- }
- : {}
- },
- pagination: {
- paginationModel: {
- pageSize: pageSize,
- page: currentPage - 1
- }
- },
- density: 'comfortable'
- }}
- pagination
- disableColumnMenu
- pageSizeOptions={[10, 25, 50, 100]}
- paginationModel={{
- page: currentPage - 1,
- pageSize: pageSize
- }}
- onPaginationModelChange={handlePaginationModelChange}
- loading={loading}
- disableRowSelectionOnClick
- getRowId={(row) =>
- tableType === TableTypeEnum.HISTORY ? row.timestamp : row.id
- }
- paginationMode="server"
- sortingMode="server"
- filterMode="server"
- onSortModelChange={handleSortModelChange}
- onFilterModelChange={handleFilterChange}
- rowCount={totalItems}
- autoHeight={false}
- hideFooter={true}
- processRowUpdate={(
- newRow: GridValidRowModel,
- oldRow: GridValidRowModel
- ): GridValidRowModel => {
- const processCell = (value: unknown) => {
- if (typeof value === 'object' && value !== null) {
- if ('dns' in value || 'ip' in value) {
- const dnsIpObj = value as { dns?: string; ip?: string; port?: string }
- return `${dnsIpObj.dns || dnsIpObj.ip}${dnsIpObj.port ? ':' + dnsIpObj.port : ''}`
- }
-
- if ('city' in value || 'country' in value) {
- const locationObj = value as { city?: string; country?: string }
- return `${locationObj.city} ${locationObj.country}`
- }
- }
- return value
- }
-
- return Object.fromEntries(
- Object.entries(newRow).map(([key, value]) => [key, processCell(value)])
- ) as GridValidRowModel
- }}
- apiRef={apiRef}
- />
-
-
onPaginationChange(page, pageSize)
- : (page: number) => handlePaginationChange({ page: page - 1, pageSize })
- }
- onPageSizeChange={
- onPaginationChange
- ? (size: number) => onPaginationChange(currentPage, size)
- : (size: number) =>
- handlePaginationChange({ page: currentPage - 1, pageSize: size })
- }
- />
- {selectedNode && (
- setSelectedNode(null)} />
- )}
-
- )
-}
-
-export default Table
diff --git a/src/components/Table/tableConfig.ts b/src/components/Table/tableConfig.ts
deleted file mode 100644
index 6a0a7c2d..00000000
--- a/src/components/Table/tableConfig.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import { TableTypeEnum } from '../../shared/enums/TableTypeEnum'
-import { GridFilterOperator } from '@mui/x-data-grid'
-
-export const SORT_FIELDS = {
- [TableTypeEnum.COUNTRIES]: [
- 'totalNodes',
- 'citiesWithNodes',
- 'cityWithMostNodesCount'
- ] as const,
- [TableTypeEnum.NODES]: ['uptime', 'eligible', 'lastCheck'] as const
-} as const
-
-type CountrySortFields = (typeof SORT_FIELDS)[TableTypeEnum.COUNTRIES][number]
-type NodeSortFields = (typeof SORT_FIELDS)[TableTypeEnum.NODES][number]
-
-export type TableSortFields = {
- [TableTypeEnum.COUNTRIES]: CountrySortFields
- [TableTypeEnum.NODES]: NodeSortFields
-}
-
-export const TABLE_CONFIG = {
- DEBOUNCE_DELAY: 1000,
- PAGE_SIZE_OPTIONS: [10, 25, 50, 100],
- DEFAULT_DENSITY: 'comfortable' as const,
- DEFAULT_PAGE_SIZE: 10,
-
- HIDDEN_COLUMNS: {
- [TableTypeEnum.NODES]: {
- network: false,
- publicKey: false,
- version: false,
- http: false,
- p2p: false,
- supportedStorage: false,
- platform: false,
- codeHash: false,
- allowedAdmins: false,
- dnsFilter: false,
- city: false,
- country: false
- },
- [TableTypeEnum.COUNTRIES]: {}
- },
-
- SORT_FIELDS,
-
- FILTER_OPERATORS: {
- CONTAINS: 'contains',
- EQUALS: 'eq',
- GREATER_THAN: 'gt',
- LESS_THAN: 'lt'
- } as const,
-
- GRID_STYLE: {
- HEIGHT: 'calc(100vh - 200px)',
- WIDTH: '100%'
- }
-} as const
-
-export type FilterOperatorType =
- (typeof TABLE_CONFIG.FILTER_OPERATORS)[keyof typeof TABLE_CONFIG.FILTER_OPERATORS]
diff --git a/src/components/Table/utils.ts b/src/components/Table/utils.ts
deleted file mode 100644
index e1dc45d5..00000000
--- a/src/components/Table/utils.ts
+++ /dev/null
@@ -1,182 +0,0 @@
-import { GridApi } from '@mui/x-data-grid'
-import { TableTypeEnum } from '../../shared/enums/TableTypeEnum'
-
-type NodeData = {
- id: string
- weeklyUptime: number
- ipAndDns?: {
- dns?: string
- ip?: string
- port?: string
- }
- location?: {
- city?: string
- country?: string
- }
- address: string
- eligible: boolean
- eligibilityCauseStr?: string
- lastCheck: string
- network: string
- version: string
- httpEnabled: boolean
- p2pEnabled: boolean
- supportedStorage: any
- platform: any
- codeHash: string
- allowedAdmins: string[]
- indexer?: Array<{ network: string }>
- provider?: Array<{ network: string }>
-}
-
-interface CountryData {
- id: string
- country: string
- totalNodes: number
- citiesWithNodes: number
-}
-
-export const getAllNetworks = (indexers: NodeData['indexer']): string => {
- return indexers?.map((indexer) => indexer.network).join(', ') || ''
-}
-
-export const formatSupportedStorage = (
- supportedStorage: NodeData['supportedStorage']
-): string => {
- const storageTypes = []
-
- if (supportedStorage?.url) storageTypes.push('URL')
- if (supportedStorage?.arwave) storageTypes.push('Arweave')
- if (supportedStorage?.ipfs) storageTypes.push('IPFS')
-
- return storageTypes.join(', ')
-}
-
-export const formatPlatform = (platform: NodeData['platform']): string => {
- if (platform) {
- const { cpus, arch, machine, platform: platformName, osType, node } = platform
- return `CPUs: ${cpus}, Architecture: ${arch}, Machine: ${machine}, Platform: ${platformName}, OS Type: ${osType}, Node.js: ${node}`
- }
- return ''
-}
-
-export const formatUptime = (uptimeInSeconds: number): string => {
- const days = Math.floor(uptimeInSeconds / (3600 * 24))
- const hours = Math.floor((uptimeInSeconds % (3600 * 24)) / 3600)
- const minutes = Math.floor((uptimeInSeconds % 3600) / 60)
-
- const dayStr = days > 0 ? `${days} day${days > 1 ? 's' : ''} ` : ''
- const hourStr = hours > 0 ? `${hours} hour${hours > 1 ? 's' : ''} ` : ''
- const minuteStr = minutes > 0 ? `${minutes} minute${minutes > 1 ? 's' : ''}` : ''
-
- return `${dayStr}${hourStr}${minuteStr}`.trim()
-}
-
-export const formatUptimePercentage = (
- uptimeInSeconds: number,
- totalUptime: number | null | undefined
-): string => {
- const defaultTotalUptime = 7 * 24 * 60 * 60
-
- const actualTotalUptime = totalUptime || defaultTotalUptime
-
- const uptimePercentage = (uptimeInSeconds / actualTotalUptime) * 100
- const percentage = uptimePercentage > 100 ? 100 : uptimePercentage
- return `${percentage.toFixed(2)}%`
-}
-
-export const exportToCsv = (
- apiRef: GridApi,
- tableType: TableTypeEnum,
- totalUptime: number | null
-) => {
- if (!apiRef) return
-
- const columns = apiRef.getAllColumns().filter((col) => {
- if (tableType === TableTypeEnum.NODES) {
- return col.field !== 'viewMore' && col.field !== 'location'
- }
- return true
- })
-
- const rows = apiRef.getRowModels()
-
- const formattedRows = Array.from(rows.values()).map((row) => {
- const formattedRow: Record = {}
-
- columns.forEach((column) => {
- const field = column.field
- const value = row[field]
-
- if (tableType === TableTypeEnum.COUNTRIES) {
- if (field === 'cityWithMostNodes') {
- const cityName = row.cityWithMostNodes || ''
- const nodeCount = row.cityWithMostNodesCount || 0
- formattedRow[column.headerName || field] = `${cityName} (${nodeCount})`
- } else {
- formattedRow[column.headerName || field] = String(value || '')
- }
- } else {
- if (field === 'uptime') {
- formattedRow[column.headerName || field] = formatUptimePercentage(
- value,
- totalUptime
- )
- } else if (field === 'network') {
- const networks =
- row.provider?.map((p: { network: string }) => p.network).join(', ') || ''
- formattedRow[column.headerName || field] = networks
- } else if (field === 'dnsFilter') {
- const ipAndDns = row.ipAndDns as { dns?: string; ip?: string; port?: string }
- formattedRow[column.headerName || field] =
- `${ipAndDns?.dns || ''} ${ipAndDns?.ip || ''} ${ipAndDns?.port ? ':' + ipAndDns?.port : ''}`.trim()
- } else if (field === 'city') {
- formattedRow[column.headerName || field] = row.location?.city || ''
- } else if (field === 'country') {
- formattedRow[column.headerName || field] = row.location?.country || ''
- } else if (field === 'platform') {
- formattedRow[column.headerName || field] = formatPlatform(value)
- } else if (field === 'supportedStorage') {
- formattedRow[column.headerName || field] = formatSupportedStorage(value)
- } else if (field === 'indexer') {
- formattedRow[column.headerName || field] = getAllNetworks(value)
- } else if (field === 'lastCheck') {
- formattedRow[column.headerName || field] = new Date(value).toLocaleString()
- } else if (typeof value === 'boolean') {
- formattedRow[column.headerName || field] = value ? 'Yes' : 'No'
- } else if (Array.isArray(value)) {
- formattedRow[column.headerName || field] = value.join(', ')
- } else if (field === 'eligibilityCauseStr') {
- formattedRow[column.headerName || field] = value || 'none'
- } else {
- formattedRow[column.headerName || field] = String(value || '')
- }
- }
- })
- return formattedRow
- })
-
- const headers = Object.keys(formattedRows[0])
- const csvRows = [
- headers.join(','),
- ...formattedRows.map((row) =>
- headers
- .map((header) => {
- const value = row[header]
- return value.includes(',') || value.includes('"')
- ? `"${value.replace(/"/g, '""')}"`
- : value
- })
- .join(',')
- )
- ].join('\n')
-
- const blob = new Blob(['\ufeff' + csvRows], { type: 'text/csv;charset=utf-8;' })
- const link = document.createElement('a')
- link.href = URL.createObjectURL(blob)
- link.download = `${tableType}_export_${new Date().toISOString()}.csv`
- document.body.appendChild(link)
- link.click()
- document.body.removeChild(link)
- URL.revokeObjectURL(link.href)
-}
diff --git a/src/components/Toolbar/index.tsx b/src/components/Toolbar/index.tsx
deleted file mode 100644
index cb5e56ff..00000000
--- a/src/components/Toolbar/index.tsx
+++ /dev/null
@@ -1,119 +0,0 @@
-import React from 'react'
-import {
- GridToolbarContainer,
- GridToolbarExport,
- GridToolbarColumnsButton,
- GridToolbarFilterButton,
- GridToolbarDensitySelector,
- GridToolbarProps,
- GridApi
-} from '@mui/x-data-grid'
-import { TextField, IconButton, styled, Button } from '@mui/material'
-import SearchIcon from '@mui/icons-material/Search'
-import ClearIcon from '@mui/icons-material/Clear'
-import style from './style.module.css'
-import { exportToCsv } from '../Table/utils'
-import FileDownloadIcon from '@mui/icons-material/FileDownload'
-import { TableTypeEnum } from '../../shared/enums/TableTypeEnum'
-const StyledTextField = styled(TextField)({
- '& .MuiOutlinedInput-root': {
- backgroundColor: '#CF1FB11A',
- borderRadius: '20px',
- '& fieldset': {
- borderColor: 'transparent'
- },
- '&:hover fieldset': {
- borderColor: 'transparent'
- },
- '&.Mui-focused fieldset': {
- borderColor: 'transparent'
- }
- },
- '& .MuiInputBase-input': {
- fontFamily: 'Sharp Sans, sans-serif',
- fontSize: '14px',
- fontWeight: 400,
- lineHeight: '21px',
- textAlign: 'left'
- }
-})
-
-interface CustomToolbarProps extends GridToolbarProps {
- searchTerm: string
- onSearchChange: (value: string) => void
- onSearch: () => void
- onReset: () => void
- tableType: TableTypeEnum
- apiRef?: GridApi
- totalUptime: number | null
-}
-
-const CustomToolbar: React.FC = ({
- searchTerm,
- onSearchChange,
- onSearch,
- onReset,
- apiRef,
- tableType,
- totalUptime
-}) => {
- const handleExport = () => {
- console.log('Export clicked')
- console.log('apiRef available:', !!apiRef)
- if (apiRef) {
- exportToCsv(apiRef, tableType, totalUptime)
- }
- }
-
- const handleKeyPress = (event: React.KeyboardEvent) => {
- if (event.key === 'Enter' && searchTerm) {
- event.preventDefault()
- onSearchChange(searchTerm)
- }
- }
-
- return (
-
-
-
-
-
- }
- onClick={handleExport}
- size="small"
- >
- Export
-
-
-
-
- onSearchChange(e.target.value)}
- onKeyPress={handleKeyPress}
- placeholder="Search..."
- variant="outlined"
- size="small"
- InputProps={{
- endAdornment: (
- <>
-
-
-
- {searchTerm && (
-
-
-
- )}
- >
- )
- }}
- />
-
-
- )
-}
-
-export default CustomToolbar
diff --git a/src/components/TopCountriesChart/TopCountriesChart .tsx b/src/components/TopCountriesChart/TopCountriesChart .tsx
deleted file mode 100644
index a0389e4a..00000000
--- a/src/components/TopCountriesChart/TopCountriesChart .tsx
+++ /dev/null
@@ -1,94 +0,0 @@
-import React from 'react'
-import Link from 'next/link'
-import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, CartesianGrid } from 'recharts'
-import styles from './TopCountriesChart.module.css'
-import { getRoutes } from '../../config'
-import { useCountriesContext } from '@/context/CountriesContext'
-import { CountryStatsType } from '@/shared/types/dataTypes'
-
-const TopCountriesChart: React.FC = () => {
- const routes = getRoutes()
- const { data: countryStats } = useCountriesContext()
-
- const topCountries = countryStats.slice(0, 5).map((stat: CountryStatsType) => ({
- country: stat.country,
- nodes: stat.totalNodes
- }))
-
- const maxNodes = Math.max(...topCountries.map((country) => country.nodes))
-
- const tickInterval = maxNodes > 50000 ? 5000 : 1000
- const roundedMax = Math.ceil(maxNodes / tickInterval) * tickInterval
- const tickValues = Array.from({ length: 6 }, (_, i) => Math.round((roundedMax / 5) * i))
-
- return (
-
-
Top 5 countries by Ocean Nodes
-
-
-
- }
- domain={[0, roundedMax]}
- ticks={tickValues}
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
- VIEW ALL
-
-
-
- )
-}
-
-const CustomXAxisTick = (props: any) => {
- const { x, y, payload } = props
- return (
-
-
- {payload.value}
-
-
-
- )
-}
-
-export default TopCountriesChart
diff --git a/src/components/TopCountriesChart/TopCountriesChart.module.css b/src/components/TopCountriesChart/TopCountriesChart.module.css
deleted file mode 100644
index 5418741d..00000000
--- a/src/components/TopCountriesChart/TopCountriesChart.module.css
+++ /dev/null
@@ -1,112 +0,0 @@
-.root {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 16px;
- text-align: center;
- width: 100%;
-}
-
-.title {
- font-size: 50px;
- font-weight: 700;
- line-height: 62px;
- color: white;
- margin-bottom: 0;
-}
-
-.description {
- font-size: 18px;
- font-weight: 400;
- line-height: 30px;
- color: white;
- margin-bottom: 24px;
-}
-
-.container {
- background-color: #FFFFFF;
- border-radius: 16px;
- padding: 24px;
- position: relative;
- width: 100%;
- position: relative;
-}
-
-.containerChart {
- background-color: white;
- border-radius: 16px;
- padding: 24px;
- color: #FFFFFF;
- margin-top: 24px;
-}
-
-.container h2 {
- font-size: 24px;
- color: #FFFFFF;
- margin-bottom: 8px;
-}
-
-.container p {
- font-size: 14px;
- color: #A0AEC0;
- margin-bottom: 24px;
-}
-
-.viewAll {
- background: none;
- border: none;
- color: #3E60F7;
- cursor: pointer;
- font-size: 14px;
- position: absolute;
- top: 24px;
- right: 24px;
-}
-
-.dividerContainer {
- position: absolute;
- top: 41px;
- bottom: 20px;
- left: 184px;
- right: 20px;
- display: flex;
- flex-direction: column;
- justify-content: space-between;
- pointer-events: none;
- width: 80%;
- height: 54%;
-}
-
-.dividerLine {
- height: 1px;
- background-color: #E0E0E0;
- width: 100%;
-}
-
-@media (max-width: 768px) {
- .root {
- padding: 0.5rem;
- }
-
- .title {
- font-size: 2rem;
- line-height: 1.2;
- }
-
- .description {
- font-size: 0.875rem;
- line-height: 1.4;
- margin-bottom: 1rem;
- }
-
- .container {
- padding: 1rem;
- }
-
- .viewAll {
- top: 0.5rem;
- right: 0.5rem;
- font-size: 0.75rem;
- }
-}
diff --git a/src/components/TotalEligibleCard/TotalEligibleCard.module.css b/src/components/TotalEligibleCard/TotalEligibleCard.module.css
deleted file mode 100644
index 35d8820b..00000000
--- a/src/components/TotalEligibleCard/TotalEligibleCard.module.css
+++ /dev/null
@@ -1,55 +0,0 @@
-.card {
- background-color: white;
- border-radius: 16px;
- padding: 24px;
- color: #FFFFFF;
- width: 100%;
- max-width: 400px;
- display: flex;
- flex-direction: column;
- align-items: center;
- min-height: 238px;
-}
-
-.header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 20px;
- width: 100%;
-}
-
-.header h3 {
- font-size: 18px;
- color: #0E001A;
-}
-
-.viewAll {
- background: none;
- border: none;
- color: #3E60F7;
- cursor: pointer;
- font-size: 14px;
-}
-
-.total {
- font-size: 80px;
- font-weight: bold;
- color: #CF1FB1;
- text-align: center;
- display: flex;
- justify-content: center;
- align-items: center;
- height: 100%;
-}
-
-@media (max-width: 768px) {
- .card {
- padding: 16px;
- }
-
- .total {
- font-size: 60px;
- min-height: 200px;
- }
-}
diff --git a/src/components/TotalEligibleCard/TotalEligibleCard.tsx b/src/components/TotalEligibleCard/TotalEligibleCard.tsx
deleted file mode 100644
index 46280a40..00000000
--- a/src/components/TotalEligibleCard/TotalEligibleCard.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import React from 'react'
-import Link from 'next/link'
-import { getRoutes } from '../../config'
-import styles from './TotalEligibleCard.module.css'
-
-interface TotalEligibleCardProps {
- total: string
-}
-
-const TotalEligibleCard: React.FC = ({ total }) => {
- const routes = getRoutes()
-
- return (
-
-
-
Total Eligible
-
- VIEW ALL
-
-
-
{total}
-
- )
-}
-
-export default TotalEligibleCard
diff --git a/src/components/avatar/avatar.module.css b/src/components/avatar/avatar.module.css
new file mode 100644
index 00000000..2973d089
--- /dev/null
+++ b/src/components/avatar/avatar.module.css
@@ -0,0 +1,6 @@
+.avatar {
+ border-radius: 50%;
+ overflow: hidden;
+ display: inline-block;
+ vertical-align: middle;
+}
diff --git a/src/components/avatar/avatar.tsx b/src/components/avatar/avatar.tsx
new file mode 100644
index 00000000..dd6660bb
--- /dev/null
+++ b/src/components/avatar/avatar.tsx
@@ -0,0 +1,39 @@
+import { toDataUrl } from 'myetherwallet-blockies';
+
+import Image from 'next/image';
+import styles from './avatar.module.css';
+
+export interface AvatarProps {
+ accountId: string;
+ className?: string;
+ size?: 'sm' | 'md' | 'lg' | number;
+ src?: string;
+}
+
+const Avatar = ({ accountId, className, size = 'md', src }: AvatarProps) => {
+ const getSize = () => {
+ switch (size) {
+ case 'sm':
+ return 18;
+ case 'md':
+ return 32;
+ case 'lg':
+ return 64;
+ default:
+ return size;
+ }
+ };
+
+ return (
+
+ );
+};
+
+export default Avatar;
diff --git a/src/components/button/button.module.css b/src/components/button/button.module.css
new file mode 100644
index 00000000..cfd0bea1
--- /dev/null
+++ b/src/components/button/button.module.css
@@ -0,0 +1,78 @@
+.root {
+ align-items: center;
+ background: none;
+ border: none;
+ border-radius: 100px;
+ cursor: pointer;
+ display: inline-flex;
+ font-family: var(--font-inter);
+ font-weight: 600;
+ justify-content: center;
+ text-align: center;
+ white-space: nowrap;
+
+ &:disabled {
+ cursor: not-allowed;
+ opacity: 0.5;
+ }
+}
+
+.size-sm {
+ font-size: 14px;
+ gap: 2px;
+ line-height: 18px;
+ padding: 4px 12px;
+}
+.size-md {
+ font-size: 16px;
+ gap: 4px;
+ line-height: 20px;
+ padding: 6px 18px;
+}
+.size-lg {
+ font-size: 20px;
+ gap: 8px;
+ line-height: 24px;
+ padding: 12px 32px;
+}
+
+.color-accent1 {
+ &.variant-filled {
+ background: var(--accent1);
+ color: var(--text-primary);
+ }
+ &.variant-outlined {
+ box-shadow: inset 0 0 0 2px var(--accent1);
+ color: var(--accent1);
+ }
+}
+.color-accent2 {
+ &.variant-filled {
+ background: var(--accent2);
+ color: var(--text-primary);
+ }
+ &.variant-outlined {
+ box-shadow: inset 0 0 0 2px var(--accent2);
+ color: var(--accent2);
+ }
+}
+.color-error {
+ &.variant-filled {
+ background: var(--error);
+ color: var(--text-primary);
+ }
+ &.variant-outlined {
+ box-shadow: inset 0 0 0 2px var(--error);
+ color: var(--error);
+ }
+}
+.color-primary {
+ &.variant-filled {
+ background: var(--text-primary);
+ color: #000;
+ }
+ &.variant-outlined {
+ box-shadow: inset 0 0 0 2px var(--text-primary);
+ color: var(--text-primary);
+ }
+}
diff --git a/src/components/button/button.tsx b/src/components/button/button.tsx
new file mode 100644
index 00000000..bc9f8936
--- /dev/null
+++ b/src/components/button/button.tsx
@@ -0,0 +1,90 @@
+import { CircularProgress } from '@mui/material';
+import classNames from 'classnames';
+import Link from 'next/link';
+import { MouseEventHandler, ReactNode, useState } from 'react';
+import styles from './button.module.css';
+
+export type ButtonProps = {
+ autoLoading?: boolean;
+ children?: ReactNode;
+ className?: string;
+ color?: 'accent1' | 'accent2' | 'error' | 'primary';
+ contentAfter?: React.ReactNode;
+ contentBefore?: React.ReactNode;
+ disabled?: boolean;
+ href?: string;
+ id?: string;
+ loading?: boolean;
+ onClick?: MouseEventHandler;
+ target?: '_blank' | '_self';
+ size?: 'sm' | 'md' | 'lg';
+ type?: 'button' | 'submit' | 'reset';
+ variant?: 'filled' | 'outlined';
+};
+
+const Button = ({
+ autoLoading,
+ children,
+ className,
+ color = 'primary',
+ contentAfter,
+ contentBefore,
+ disabled,
+ href,
+ id,
+ loading,
+ onClick,
+ target,
+ size = 'md',
+ type = 'button',
+ variant = 'filled',
+}: ButtonProps) => {
+ const [innerLoading, setInnerLoading] = useState(false);
+
+ const classes = classNames(
+ styles.root,
+ styles[`color-${color}`],
+ styles[`size-${size}`],
+ styles[`variant-${variant}`],
+ className
+ );
+
+ const isLoading = loading || innerLoading;
+ const isDisabled = disabled || isLoading;
+
+ const spinner = isLoading ? : null;
+
+ const handleClick = async (event: React.MouseEvent) => {
+ if (onClick) {
+ if (autoLoading) {
+ setInnerLoading(true);
+ await onClick(event);
+ setInnerLoading(false);
+ } else {
+ onClick(event);
+ }
+ }
+ };
+
+ if (href) {
+ return (
+
+ {spinner}
+ {contentBefore}
+ {children}
+ {contentAfter}
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+export default Button;
diff --git a/src/components/button/copy-button.tsx b/src/components/button/copy-button.tsx
new file mode 100644
index 00000000..fec1fb0a
--- /dev/null
+++ b/src/components/button/copy-button.tsx
@@ -0,0 +1,34 @@
+import Button, { ButtonProps } from '@/components/button/button';
+import CheckIcon from '@mui/icons-material/Check';
+import ContentCopyIcon from '@mui/icons-material/ContentCopy';
+import { useState } from 'react';
+
+type CopyButtonProps = Pick & {
+ contentToCopy: string;
+};
+
+const CopyButton = ({ color = 'accent1', contentToCopy, size = 'sm', variant = 'outlined' }: CopyButtonProps) => {
+ const [copied, setCopied] = useState(false);
+
+ const handleClick = () => {
+ navigator.clipboard.writeText(contentToCopy);
+ setCopied(true);
+ setTimeout(() => {
+ setCopied(false);
+ }, 2000);
+ };
+
+ return (
+ : }
+ onClick={handleClick}
+ size={size}
+ variant={variant}
+ >
+ {copied ? 'Copied!' : 'Copy'}
+
+ );
+};
+
+export default CopyButton;
diff --git a/src/components/button/download-logs-button.tsx b/src/components/button/download-logs-button.tsx
new file mode 100644
index 00000000..9806d614
--- /dev/null
+++ b/src/components/button/download-logs-button.tsx
@@ -0,0 +1,84 @@
+import Button from '@/components/button/button';
+import { useP2P } from '@/contexts/P2PContext';
+import { useOceanAccount } from '@/lib/use-ocean-account';
+import { ComputeJob } from '@/types/jobs';
+import { generateAuthTokenWithSmartAccount } from '@/utils/generateAuthToken';
+import { useSignMessage, useSmartAccountClient } from '@account-kit/react';
+import DownloadIcon from '@mui/icons-material/Download';
+import JSZip from 'jszip';
+import { useState } from 'react';
+import { toast } from 'react-toastify';
+
+interface DownloadLogsButtonProps {
+ job: ComputeJob;
+}
+
+export const DownloadLogsButton = ({ job }: DownloadLogsButtonProps) => {
+ const { getComputeResult, isReady } = useP2P();
+ const [isDownloading, setIsDownloading] = useState(false);
+ const { client } = useSmartAccountClient({ type: 'LightAccount' });
+ const { signMessageAsync } = useSignMessage({
+ client,
+ });
+ const { account } = useOceanAccount();
+
+ const handleDownload = async () => {
+ if (!isReady || isDownloading || !account?.address) return;
+
+ try {
+ const jobId = job.environment.split('-')[0] + '-' + job.jobId;
+
+ const authToken = await generateAuthTokenWithSmartAccount(job.peerId, account.address, signMessageAsync);
+
+ const logFiles = job.results.filter((result: any) => result.filename.includes('.log'));
+ const logPromises = logFiles.map((logFile: any) =>
+ getComputeResult(job.peerId, jobId, logFile.index, authToken, account.address)
+ );
+
+ const downloadedLogs = await Promise.all(logPromises);
+ setIsDownloading(true);
+
+ const zip = new JSZip();
+
+ downloadedLogs.forEach((logData, index) => {
+ if (logData instanceof Uint8Array) {
+ if (logData.byteLength !== 0) {
+ zip.file(logFiles[index].filename, logData);
+ }
+ }
+ });
+
+ const zipBlob = await zip.generateAsync({ type: 'blob' });
+
+ const url = URL.createObjectURL(zipBlob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = `logs-${job.jobId}.zip`;
+
+ document.body.appendChild(link);
+ link.click();
+
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+ } catch (e) {
+ const errorMessage = e instanceof Error ? e.message : 'Failed to download logs';
+ toast.error(errorMessage);
+ } finally {
+ setIsDownloading(false);
+ }
+ };
+
+ return (
+ }
+ disabled={!isReady}
+ onClick={handleDownload}
+ size="md"
+ variant="outlined"
+ >
+ Logs
+
+ );
+};
diff --git a/src/components/button/download-result-button.tsx b/src/components/button/download-result-button.tsx
new file mode 100644
index 00000000..a13953b8
--- /dev/null
+++ b/src/components/button/download-result-button.tsx
@@ -0,0 +1,74 @@
+import Button from '@/components/button/button';
+import { useP2P } from '@/contexts/P2PContext';
+import { useOceanAccount } from '@/lib/use-ocean-account';
+import { ComputeJob } from '@/types/jobs';
+import { generateAuthTokenWithSmartAccount } from '@/utils/generateAuthToken';
+import { useSignMessage, useSmartAccountClient } from '@account-kit/react';
+import DownloadIcon from '@mui/icons-material/Download';
+import { useState } from 'react';
+import { toast } from 'react-toastify';
+
+interface DownloadResultButtonProps {
+ job: ComputeJob;
+}
+
+export const DownloadResultButton = ({ job }: DownloadResultButtonProps) => {
+ const { getComputeResult, isReady } = useP2P();
+ const [isDownloading, setIsDownloading] = useState(false);
+ const { client } = useSmartAccountClient({ type: 'LightAccount' });
+ const { signMessageAsync } = useSignMessage({
+ client,
+ });
+ const { account } = useOceanAccount();
+
+ const handleDownload = async () => {
+ if (!isReady || isDownloading || !account?.address) return;
+
+ try {
+ const jobId = job.environment.split('-')[0] + '-' + job.jobId;
+ setIsDownloading(true);
+ const archive = job.results.find((result: any) => result.filename.includes('.tar'));
+
+ const authToken = await generateAuthTokenWithSmartAccount(job.peerId, account.address, signMessageAsync);
+
+ const result = await getComputeResult(job.peerId, jobId, archive?.index, authToken, account.address);
+ if (result instanceof Uint8Array) {
+ if (result.byteLength === 0) {
+ console.log('Received empty response (0 bytes). Skipping download.');
+ return;
+ }
+
+ const blob = new Blob([result as any], { type: 'application/octet-stream' });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = `outputs-${job.jobId}.tar`;
+
+ document.body.appendChild(link);
+ link.click();
+
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+ }
+ } catch (e) {
+ const errorMessage = e instanceof Error ? e.message : 'Failed to download result';
+ toast.error(errorMessage);
+ } finally {
+ setIsDownloading(false);
+ }
+ };
+
+ return (
+ }
+ disabled={!isReady}
+ onClick={handleDownload}
+ size="md"
+ variant="outlined"
+ >
+ Results
+
+ );
+};
diff --git a/src/components/button/info-button.tsx b/src/components/button/info-button.tsx
new file mode 100644
index 00000000..0f6305a0
--- /dev/null
+++ b/src/components/button/info-button.tsx
@@ -0,0 +1,26 @@
+import { useNodesContext } from '@/context/nodes-context';
+import { Node } from '@/types/nodes';
+import { useRouter } from 'next/router';
+import Button from './button';
+
+type InfoButtonProps = {
+ node: Node;
+};
+
+const InfoButton = ({ node }: InfoButtonProps) => {
+ const { setSelectedNode } = useNodesContext();
+ const router = useRouter();
+
+ const handleClick = () => {
+ setSelectedNode(node);
+ router.push(`/nodes/${node.id || node.nodeId}`);
+ };
+
+ return (
+
+ );
+};
+
+export default InfoButton;
diff --git a/src/components/button/job-info-button.tsx b/src/components/button/job-info-button.tsx
new file mode 100644
index 00000000..d26111b5
--- /dev/null
+++ b/src/components/button/job-info-button.tsx
@@ -0,0 +1,26 @@
+import { JobInfoModal } from '@/components/modal/job-info-modal';
+import { ComputeJob } from '@/types/jobs';
+import { useState } from 'react';
+import Button from './button';
+
+interface JobInfoButtonProps {
+ job: ComputeJob;
+}
+
+export const JobInfoButton = ({ job }: JobInfoButtonProps) => {
+ const [open, setOpen] = useState(false);
+
+ const handleOpen = () => setOpen(true);
+ const handleClose = () => setOpen(false);
+
+ return (
+ <>
+
+
+ >
+ );
+};
+
+export default JobInfoButton;
diff --git a/src/components/card/card.module.css b/src/components/card/card.module.css
new file mode 100644
index 00000000..d44132bc
--- /dev/null
+++ b/src/components/card/card.module.css
@@ -0,0 +1,111 @@
+/* Directions */
+.direction-column {
+ display: flex;
+ flex-direction: column;
+}
+.direction-row {
+ display: flex;
+ flex-direction: row;
+}
+
+/* Paddings */
+.padding-sm {
+ padding: 24px;
+}
+.padding-md {
+ padding: 32px;
+}
+.padding-lg {
+ padding: 48px;
+}
+
+/* X paddings */
+.paddingX-sm {
+ padding-left: 24px;
+ padding-right: 24px;
+}
+.paddingX-md {
+ padding-left: 32px;
+ padding-right: 32px;
+}
+.paddingX-lg {
+ padding-left: 48px;
+ padding-right: 48px;
+}
+
+/* Y paddings */
+.paddingY-sm {
+ padding-top: 24px;
+ padding-bottom: 24px;
+}
+.paddingY-md {
+ padding-top: 32px;
+ padding-bottom: 32px;
+}
+.paddingY-lg {
+ padding-top: 48px;
+ padding-bottom: 48px;
+}
+
+/* Radii */
+.radius-sm {
+ border-radius: 16px;
+}
+.radius-md {
+ border-radius: 24px;
+}
+.radius-lg {
+ border-radius: 32px;
+}
+
+/* Spacings */
+.spacing-sm {
+ gap: 16px;
+}
+.spacing-md {
+ gap: 24px;
+}
+.spacing-lg {
+ gap: 32px;
+}
+
+/* Variants */
+.variant-glass {
+ background-color: var(--background-glass);
+ border: 1px solid var(--border-glass);
+}
+.variant-glass-shaded {
+ background-color: var(--background-glass);
+ box-shadow: var(--inner-shadow-glass);
+ backdrop-filter: var(--backdrop-filter-glass);
+}
+.variant-glass-outline {
+ border: 1px solid var(--border-glass);
+}
+.variant-success {
+ background: var(--success-darker);
+ border: 2px solid var(--success);
+ box-shadow: 0 8px 80px -20px var(--success);
+}
+.variant-success-outline {
+ border: 2px solid var(--success);
+}
+.variant-warning {
+ background: var(--warning-darker);
+ border: 2px solid var(--warning);
+ box-shadow: 0 8px 80px -20px var(--warning);
+}
+.variant-warning-outline {
+ border: 2px solid var(--warning);
+}
+.variant-error {
+ background: var(--error-darker);
+ border: 2px solid var(--error);
+ box-shadow: 0 8px 80px -20px var(--error);
+}
+.variant-error-outline {
+ border: 2px solid var(--error);
+}
+.variant-accent1-outline {
+ border: 2px solid var(--accent1);
+}
diff --git a/src/components/card/card.tsx b/src/components/card/card.tsx
new file mode 100644
index 00000000..a2581340
--- /dev/null
+++ b/src/components/card/card.tsx
@@ -0,0 +1,50 @@
+import cx from 'classnames';
+import { ReactNode } from 'react';
+import styles from './card.module.css';
+
+type Size = 'sm' | 'md' | 'lg';
+type Variant =
+ | 'glass'
+ | 'glass-shaded'
+ | 'glass-outline'
+ | 'success'
+ | 'success-outline'
+ | 'warning'
+ | 'warning-outline'
+ | 'error'
+ | 'error-outline'
+ | 'accent1-outline';
+
+type CardProps = {
+ children: ReactNode;
+ className?: string;
+ direction?: 'row' | 'column';
+ padding?: Size;
+ paddingX?: Size;
+ paddingY?: Size;
+ radius?: Size;
+ spacing?: Size;
+ variant?: Variant;
+};
+
+const Card = ({ children, className, direction, padding, paddingX, paddingY, radius, spacing, variant }: CardProps) => (
+
+ {children}
+
+);
+
+export default Card;
diff --git a/src/components/chart/chart-type.ts b/src/components/chart/chart-type.ts
new file mode 100644
index 00000000..5fc29aa7
--- /dev/null
+++ b/src/components/chart/chart-type.ts
@@ -0,0 +1,7 @@
+export enum ChartTypeEnum {
+ CPU_ARCH_DISTRIBUTION = 'cpu-arch-distribution',
+ CPU_CORES_DISTRIBUTION = 'cpu-cores-distribution',
+ JOBS_PER_EPOCH = 'jobs-per-epoch',
+ OS_DISTRIBUTION = 'os-distribution',
+ REVENUE_PER_EPOCH = 'revenue-per-epoch',
+}
diff --git a/src/components/chart/gauge.module.css b/src/components/chart/gauge.module.css
new file mode 100644
index 00000000..94bdc5fc
--- /dev/null
+++ b/src/components/chart/gauge.module.css
@@ -0,0 +1,54 @@
+.root {
+ align-items: center;
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+
+ .heading {
+ text-align: center;
+ }
+
+ .chartWrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ height: 100%;
+
+ .chart {
+ height: 110px;
+ position: relative;
+ width: 220px;
+
+ .valueWrapper {
+ align-items: center;
+ bottom: -16px;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ left: 0;
+ position: absolute;
+ text-align: center;
+ width: 100%;
+
+ .value {
+ font-size: 30px;
+ font-weight: bold;
+ line-height: 1;
+ }
+
+ .label {
+ font-size: 12px;
+ color: var(--text-secondary);
+ }
+ }
+ }
+
+ .footer {
+ color: var(--text-secondary);
+ display: flex;
+ font-size: 14px;
+ gap: 16px;
+ justify-content: space-between;
+ }
+ }
+}
diff --git a/src/components/chart/gauge.tsx b/src/components/chart/gauge.tsx
new file mode 100644
index 00000000..6543a938
--- /dev/null
+++ b/src/components/chart/gauge.tsx
@@ -0,0 +1,70 @@
+import { formatNumber } from '@/utils/formatters';
+import { useMemo } from 'react';
+import { Cell, Pie, PieChart, ResponsiveContainer } from 'recharts';
+import styles from './gauge.module.css';
+
+type GaugeProps = {
+ label?: string;
+ max: number;
+ min: number;
+ title: string;
+ value: number;
+ valueSuffix?: string;
+};
+
+const Gauge = ({ label, max, min, title, value, valueSuffix }: GaugeProps) => {
+ const slices = useMemo(() => {
+ const offsetValue = value - min;
+ const offsetMax = max - offsetValue;
+ return [
+ { value: offsetValue, color: 'var(--accent1)' },
+ { value: offsetMax, color: 'var(--background-glass)' },
+ ];
+ }, [max, min, value]);
+
+ return (
+
+
{title}
+
+
+
+
+
+ {slices.map((entry, index) => (
+ |
+ ))}
+
+
+
+
+
+ {formatNumber(value)}
+ {valueSuffix}
+
+ {label ?
{label}
: null}
+
+
+
+
+ {formatNumber(min)}
+ {valueSuffix}
+
+
+ {formatNumber(max)}
+ {valueSuffix}
+
+
+
+
+ );
+};
+
+export default Gauge;
diff --git a/src/components/chart/h-bar-chart.module.css b/src/components/chart/h-bar-chart.module.css
new file mode 100644
index 00000000..3450049f
--- /dev/null
+++ b/src/components/chart/h-bar-chart.module.css
@@ -0,0 +1,18 @@
+.root {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 16px;
+ text-align: center;
+ width: 100%;
+}
+
+.container {
+ background-color: #ffffff;
+ border-radius: 16px;
+ padding: 24px;
+ position: relative;
+ width: 100%;
+ position: relative;
+}
diff --git a/src/components/chart/h-bar-chart.tsx b/src/components/chart/h-bar-chart.tsx
new file mode 100644
index 00000000..dd882e8d
--- /dev/null
+++ b/src/components/chart/h-bar-chart.tsx
@@ -0,0 +1,39 @@
+import { Bar, BarChart, CartesianGrid, ResponsiveContainer, XAxis, YAxis } from 'recharts';
+import styles from './h-bar-chart.module.css';
+
+type HBarChartProps = {
+ axisKey: string;
+ barKey: string;
+ data: any[];
+};
+
+const HBarChart = ({ axisKey, barKey, data }: HBarChartProps) => (
+
+
+
+
+
+
+
+
+
+
+);
+
+export default HBarChart;
diff --git a/src/components/PieChart/PieChartCard.module.css b/src/components/chart/pie-chart.module.css
similarity index 63%
rename from src/components/PieChart/PieChartCard.module.css
rename to src/components/chart/pie-chart.module.css
index 0f05d101..927a82e4 100644
--- a/src/components/PieChart/PieChartCard.module.css
+++ b/src/components/chart/pie-chart.module.css
@@ -1,25 +1,14 @@
-.card {
- background-color: transparent;
- border-radius: 16px;
- padding: 24px;
- color: #FFFFFF;
+.root {
+ color: var(--text-primary);
text-align: center;
width: 100%;
max-width: 300px;
transition: all 0.3s ease-in-out;
}
-.card h3 {
- font-size: 18px;
- margin-bottom: 20px;
- font-weight: 500;
- line-height: 24px;
- margin-bottom: 0;
-}
-
.tapToSee {
font-size: 14px;
- color: #A0AEC0;
+ color: var(--text-secondary);
margin-top: 0;
font-weight: 400;
line-height: 18px;
diff --git a/src/components/chart/pie-chart.tsx b/src/components/chart/pie-chart.tsx
new file mode 100644
index 00000000..bb1dd805
--- /dev/null
+++ b/src/components/chart/pie-chart.tsx
@@ -0,0 +1,136 @@
+import { ChartTypeEnum } from '@/components/chart/chart-type';
+import { useCustomTooltip } from '@/components/chart/use-custom-tooltip';
+import React, { useMemo, useState } from 'react';
+import {
+ Cell,
+ Pie,
+ PieChart as RechartsPieChart,
+ Tooltip as RechartsTooltip,
+ ResponsiveContainer,
+ Sector,
+} from 'recharts';
+import styles from './pie-chart.module.css';
+
+type PieChartItem = {
+ color: string;
+ details?: string[];
+ name: string;
+ value: number;
+};
+
+type PieChartCardProps = {
+ chartType?: ChartTypeEnum;
+ data: PieChartItem[];
+ title: string;
+};
+
+const PieChart: React.FC = ({ chartType, data, title }) => {
+ const { handleMouseMove, handleMouseLeave, CustomRechartsTooltipComponent, renderTooltipPortal } = useCustomTooltip({
+ chartType,
+ labelKey: 'name',
+ });
+
+ const [activeIndex, setActiveIndex] = useState(undefined);
+ const [lockedIndex, setLockedIndex] = useState(undefined);
+ const [hoverText, setHoverText] = useState('Hover to see details');
+
+ const totalValue = useMemo(() => data.reduce((sum, entry) => sum + entry.value, 0), [data]);
+
+ const onPieEnter = (_: any, index: number) => {
+ if (lockedIndex === undefined) {
+ setActiveIndex(index);
+ const percentage = ((data[index].value / totalValue) * 100).toFixed(2);
+ setHoverText(`${data[index].name}: ${percentage}%`);
+ }
+ };
+
+ const onPieLeave = () => {
+ if (lockedIndex === undefined) {
+ setActiveIndex(undefined);
+ setHoverText('Hover to see details');
+ }
+ };
+
+ const onPieClick = (_: any, index: number) => {
+ if (lockedIndex === index) {
+ setLockedIndex(undefined);
+ setActiveIndex(undefined);
+ setHoverText('Hover to see details');
+ } else {
+ setLockedIndex(index);
+ setActiveIndex(index);
+ const percentage = ((data[index].value / totalValue) * 100).toFixed(2);
+ setHoverText(`${data[index].name}: ${percentage}%`);
+ }
+ };
+
+ const renderActiveShape = (props: any) => {
+ const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill } = props;
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ };
+
+ return (
+
+
{title}
+
+
+
+ {data.map((entry, index) => (
+ |
+ ))}
+
+ } cursor={false} />
+
+
+
{hoverText}
+ {renderTooltipPortal()}
+
+ );
+};
+
+export default PieChart;
diff --git a/src/components/chart/use-custom-tooltip.tsx b/src/components/chart/use-custom-tooltip.tsx
new file mode 100644
index 00000000..f79f1686
--- /dev/null
+++ b/src/components/chart/use-custom-tooltip.tsx
@@ -0,0 +1,184 @@
+import { ChartTypeEnum } from '@/components/chart/chart-type';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import ReactDOM from 'react-dom';
+
+type TooltipInfo = {
+ show: boolean;
+ x: number;
+ y: number;
+ data: any;
+};
+
+type UseCustomTooltipProps = {
+ chartType?: ChartTypeEnum;
+ labelKey: string;
+};
+
+const RechartsTooltipContent = ({ active, payload, label, cardIdRef, setTooltipInfo, mousePositionRef }: any) => {
+ const prevActiveRef = useRef(active);
+ const prevPayloadRef = useRef(payload);
+
+ useEffect(() => {
+ if (
+ prevActiveRef.current !== active ||
+ (active && JSON.stringify(prevPayloadRef.current) !== JSON.stringify(payload))
+ ) {
+ prevActiveRef.current = active;
+ prevPayloadRef.current = payload;
+
+ if (active && payload && payload.length) {
+ (window as any).__activeTooltipCard = cardIdRef.current;
+ const data = payload[0];
+
+ queueMicrotask(() => {
+ if ((window as any).__activeTooltipCard === cardIdRef.current) {
+ setTooltipInfo({
+ show: true,
+ x: mousePositionRef.current.x,
+ y: mousePositionRef.current.y,
+ data: {
+ value: data.value,
+ payload: data.payload,
+ },
+ });
+ }
+ });
+ } else {
+ if ((window as any).__activeTooltipCard === cardIdRef.current) {
+ (window as any).__activeTooltipCard = null;
+ queueMicrotask(() => {
+ setTooltipInfo((prev: any) => ({ ...prev, show: false }));
+ });
+ }
+ }
+ }
+ }, [active, payload, label, cardIdRef, setTooltipInfo, mousePositionRef]);
+
+ return null;
+};
+
+export const useCustomTooltip = ({ chartType, labelKey }: UseCustomTooltipProps) => {
+ const [tooltipInfo, setTooltipInfo] = useState({
+ show: false,
+ x: 0,
+ y: 0,
+ data: null,
+ });
+
+ const mousePositionRef = useRef({ x: 0, y: 0 });
+ const cardIdRef = useRef(`tooltip-card-${Math.random().toString(36).substring(2, 9)}`);
+
+ const handleMouseMove = useCallback((e: React.MouseEvent) => {
+ mousePositionRef.current = { x: e.clientX, y: e.clientY };
+ }, []);
+
+ const handleMouseLeave = useCallback(() => {
+ if ((window as any).__activeTooltipCard === cardIdRef.current) {
+ (window as any).__activeTooltipCard = null;
+ setTooltipInfo((prev) => ({ ...prev, show: false }));
+ }
+ }, []);
+
+ const CustomRechartsTooltipComponent = useCallback(
+ (props: any) => (
+
+ ),
+ [cardIdRef, setTooltipInfo, mousePositionRef]
+ );
+
+ const renderTooltipPortal = useCallback(() => {
+ if (!tooltipInfo.show || !tooltipInfo.data) return null;
+
+ const payload = tooltipInfo.data.payload;
+ const value = tooltipInfo.data.value;
+ const label = payload[labelKey];
+
+ let tooltipContent: React.ReactNode;
+
+ switch (chartType) {
+ case ChartTypeEnum.CPU_ARCH_DISTRIBUTION:
+ case ChartTypeEnum.CPU_CORES_DISTRIBUTION:
+ case ChartTypeEnum.OS_DISTRIBUTION: {
+ tooltipContent = (
+
+
{label}
+
Total: {value} nodes
+ {payload.details && (
+
+ {Array.isArray(payload.details)
+ ? payload.details.map((detail: string, index: number) => (
+
+ ))
+ : null}
+
+ )}
+
+ );
+ break;
+ }
+ case ChartTypeEnum.JOBS_PER_EPOCH: {
+ tooltipContent = (
+
+ Epoch {label}: {Number(value).toLocaleString()} jobs
+
+ );
+ break;
+ }
+ case ChartTypeEnum.REVENUE_PER_EPOCH: {
+ tooltipContent = (
+
+ Epoch {label}: USDC {Number(value).toLocaleString()}
+
+ );
+ break;
+ }
+ default: {
+ tooltipContent = Value: {Number(value).toLocaleString()}
;
+ }
+ }
+
+ return ReactDOM.createPortal(
+
+ {tooltipContent}
+
,
+ document.body
+ );
+ }, [tooltipInfo, chartType, labelKey /*cardTitle*/]);
+
+ return {
+ handleMouseMove,
+ handleMouseLeave,
+ CustomRechartsTooltipComponent,
+ renderTooltipPortal,
+ };
+};
diff --git a/src/components/chart/v-bar-chart.module.css b/src/components/chart/v-bar-chart.module.css
new file mode 100644
index 00000000..7e163790
--- /dev/null
+++ b/src/components/chart/v-bar-chart.module.css
@@ -0,0 +1,36 @@
+.chartWrapper {
+ align-content: start;
+ align-items: center;
+ display: grid;
+ grid-template-rows: auto 110px auto;
+ gap: 8px;
+}
+
+.chartFooter {
+ align-items: center;
+ display: flex;
+ gap: 16px;
+ justify-content: space-between;
+ padding: 0 4px;
+
+ .label {
+ color: var(--text-secondary);
+ font-size: 14px;
+ }
+
+ .currency {
+ color: var(--text-secondary);
+ font-size: 12px;
+ font-weight: 400;
+ }
+
+ .amount {
+ font-size: 20px;
+ font-weight: 600;
+ }
+}
+
+.heading {
+ margin-bottom: 16px;
+ text-align: center;
+}
diff --git a/src/components/chart/v-bar-chart.tsx b/src/components/chart/v-bar-chart.tsx
new file mode 100644
index 00000000..d3a2b0e0
--- /dev/null
+++ b/src/components/chart/v-bar-chart.tsx
@@ -0,0 +1,55 @@
+import { ChartTypeEnum } from '@/components/chart/chart-type';
+import { useCustomTooltip } from '@/components/chart/use-custom-tooltip';
+import { Bar, BarChart as RechartsBarChart, Tooltip as RechartsTooltip, ResponsiveContainer, XAxis } from 'recharts';
+import styles from './v-bar-chart.module.css';
+
+type VBarChartProps = {
+ axisKey: string;
+ barKey: string;
+ chartType?: ChartTypeEnum;
+ data: any[];
+ footer?: {
+ amount: string;
+ currency?: string;
+ label: string;
+ };
+ title: string;
+};
+
+const VBarChart = ({ axisKey, barKey, chartType, data, footer, title }: VBarChartProps) => {
+ const { handleMouseMove, handleMouseLeave, CustomRechartsTooltipComponent, renderTooltipPortal } = useCustomTooltip({
+ chartType,
+ labelKey: axisKey,
+ });
+
+ return (
+
+
{title}
+
+
+
+
+
+ } cursor={false} />
+
+
+ {renderTooltipPortal()}
+
+ {footer ? (
+
+
{footer.label}
+
+ {footer.currency && {footer.currency} }
+ {footer.amount}
+
+
+ ) : null}
+
+ );
+};
+
+export default VBarChart;
diff --git a/src/components/code-block/code-block.module.css b/src/components/code-block/code-block.module.css
new file mode 100644
index 00000000..dad2b9c9
--- /dev/null
+++ b/src/components/code-block/code-block.module.css
@@ -0,0 +1,12 @@
+.root {
+ align-items: start;
+ background: var(--background-glass);
+ border-radius: 16px;
+ display: flex;
+ font-family: monospace;
+ gap: 16px;
+ justify-content: space-between;
+ line-height: 2;
+ padding: 8px 8px 8px 16px;
+ white-space: pre-wrap;
+}
diff --git a/src/components/code-block/code-block.tsx b/src/components/code-block/code-block.tsx
new file mode 100644
index 00000000..4f47426a
--- /dev/null
+++ b/src/components/code-block/code-block.tsx
@@ -0,0 +1,19 @@
+import CopyButton from '@/components/button/copy-button';
+import styles from './code-block.module.css';
+
+type CodeBlockProps = {
+ code: string;
+};
+
+export const CodeBlock = ({ code }: CodeBlockProps) => {
+ const handleCopy = () => {
+ navigator.clipboard.writeText(code);
+ };
+
+ return (
+
+ );
+};
diff --git a/src/components/container/container.module.css b/src/components/container/container.module.css
new file mode 100644
index 00000000..c4f37622
--- /dev/null
+++ b/src/components/container/container.module.css
@@ -0,0 +1,5 @@
+.root {
+ width: calc(100% - 40px);
+ max-width: 1160px;
+ margin: 0 auto;
+}
diff --git a/src/components/container/container.tsx b/src/components/container/container.tsx
new file mode 100644
index 00000000..a678a6cd
--- /dev/null
+++ b/src/components/container/container.tsx
@@ -0,0 +1,9 @@
+import cx from 'classnames';
+import { ReactNode } from 'react';
+import styles from './container.module.css';
+
+const Container = ({ children, className }: { children: ReactNode; className?: string }) => {
+ return {children}
;
+};
+
+export default Container;
diff --git a/src/components/environment-card/environment-card.module.css b/src/components/environment-card/environment-card.module.css
new file mode 100644
index 00000000..075cd131
--- /dev/null
+++ b/src/components/environment-card/environment-card.module.css
@@ -0,0 +1,117 @@
+.gridWrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+
+ .grid {
+ display: flex;
+ flex-direction: column;
+ gap: 16px 32px;
+
+ @media (min-width: 768px) {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+
+ &.compact {
+ grid-template-columns: repeat(3, 1fr);
+ }
+ }
+
+ @media (min-width: 1100px) {
+ &.specsWithoutGpus,
+ &.gpuSpecs {
+ grid-template-columns: repeat(3, 1fr);
+ }
+ }
+ }
+}
+
+.compactGrid {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ gap: 16px 32px;
+
+ .label {
+ font-size: 12px;
+ }
+}
+
+.link {
+ color: var(--accent1);
+
+ &:hover {
+ text-decoration: underline;
+ }
+}
+
+.label {
+ align-items: center;
+ color: var(--text-secondary);
+ display: flex;
+ font-size: 14px;
+
+ &.heading,
+ .heading {
+ color: var(--text-primary);
+ font-size: 14px;
+ font-weight: 600;
+ }
+
+ &.em,
+ .em {
+ color: var(--text-primary);
+ font-weight: 700;
+ }
+
+ .icon {
+ color: var(--accent1);
+ margin-right: 4px;
+ }
+}
+
+.balance {
+ align-items: center;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ gap: 16px;
+
+ .link {
+ color: var(--accent1);
+ cursor: pointer;
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+}
+
+.footer {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+
+ .buttons {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+
+ .select {
+ min-width: 200px;
+ }
+ }
+
+ @media (min-width: 768px) {
+ align-items: center;
+ flex-direction: row;
+ flex-wrap: wrap;
+ justify-content: space-between;
+
+ .buttons {
+ flex-direction: row;
+ flex-wrap: wrap;
+ }
+ }
+}
diff --git a/src/components/environment-card/environment-card.tsx b/src/components/environment-card/environment-card.tsx
new file mode 100644
index 00000000..f755823c
--- /dev/null
+++ b/src/components/environment-card/environment-card.tsx
@@ -0,0 +1,365 @@
+import Button from '@/components/button/button';
+import Card from '@/components/card/card';
+import GpuLabel from '@/components/gpu-label/gpu-label';
+import useEnvResources from '@/components/hooks/use-env-resources';
+import ProgressBar from '@/components/progress-bar/progress-bar';
+import { USDC_TOKEN_ADDRESS } from '@/constants/tokens';
+import { useRunJobContext } from '@/context/run-job-context';
+import useTokenSymbol from '@/lib/token-symbol';
+import { ComputeEnvironment, EnvNodeInfo } from '@/types/environments';
+import { formatNumber } from '@/utils/formatters';
+import DnsIcon from '@mui/icons-material/Dns';
+import MemoryIcon from '@mui/icons-material/Memory';
+import PlayArrowIcon from '@mui/icons-material/PlayArrow';
+import SdStorageIcon from '@mui/icons-material/SdStorage';
+import classNames from 'classnames';
+import Link from 'next/link';
+import { useRouter } from 'next/router';
+import { useMemo } from 'react';
+import styles from './environment-card.module.css';
+
+type EnvironmentCardProps = {
+ compact?: boolean;
+ environment: ComputeEnvironment;
+ nodeInfo: EnvNodeInfo;
+ showNodeName?: boolean;
+};
+
+const EnvironmentCard = ({ compact, environment, nodeInfo, showNodeName }: EnvironmentCardProps) => {
+ const router = useRouter();
+
+ const { selectEnv, selectToken } = useRunJobContext();
+
+ // const [selectedTokenAddress, setSelectedTokenAddress] = useState(getEnvSupportedTokens(environment)[0]);
+ const selectedTokenAddress = USDC_TOKEN_ADDRESS;
+ const tokenSymbol = useTokenSymbol(selectedTokenAddress);
+
+ const { cpu, cpuFee, disk, diskFee, gpus, gpuFees, ram, ramFee } = useEnvResources({
+ environment,
+ freeCompute: false,
+ tokenAddress: selectedTokenAddress,
+ });
+
+ const startingFee = useMemo(() => {
+ const minGpuFee = Object.values(gpuFees).reduce((min, fee) => (fee < min ? fee : min), Infinity);
+ return (cpuFee ?? 0) + (ramFee ?? 0) + (diskFee ?? 0) + (minGpuFee === Infinity ? 0 : minGpuFee);
+ }, [cpuFee, diskFee, gpuFees, ramFee]);
+
+ const minJobDurationHours = (environment.minJobDuration ?? 0) / 60 / 60;
+ const maxJobDurationHours = (environment.maxJobDuration ?? 0) / 60 / 60;
+
+ const selectEnvironment = () => {
+ selectEnv({
+ environment,
+ freeCompute: false,
+ nodeInfo,
+ });
+ selectToken(selectedTokenAddress, tokenSymbol);
+ router.push('/run-job/resources');
+ };
+
+ const selectFreeCompute = () => {
+ selectEnv({
+ environment,
+ freeCompute: true,
+ nodeInfo,
+ });
+ selectToken(selectedTokenAddress, tokenSymbol);
+ router.push('/run-job/resources');
+ };
+
+ const getCpuProgressBar = () => {
+ if (!cpu) {
+ return null;
+ }
+ const max = cpu.max ?? 0;
+ const inUse = cpu.inUse ?? 0;
+ const available = max - inUse;
+ const fee = cpuFee ?? 0;
+ if (compact) {
+ return (
+
+
+
+ {cpu?.description}
+
+
+ {fee} {tokenSymbol}/min
+
+
+
+ {available}/{max}
+
+ available
+
+
+ );
+ }
+ const percentage = (100 * inUse) / max;
+ return (
+
+ CPU - {cpu?.description}
+
+ }
+ topRightContent={
+
+ {max} total
+
+ }
+ bottomLeftContent={
+
+ {fee} {tokenSymbol}/min
+
+ }
+ bottomRightContent={
+
+ {inUse} used
+
+ }
+ />
+ );
+ };
+
+ const getGpuProgressBars = () => {
+ const mergedGpus = gpus.reduce(
+ (merged, gpuToCheck) => {
+ const existingGpu = merged.find(
+ (gpu) => gpu.description === gpuToCheck.description && gpuFees[gpu.id] === gpuFees[gpuToCheck.id]
+ );
+ if (existingGpu) {
+ existingGpu.inUse = (existingGpu.inUse ?? 0) + (gpuToCheck.inUse ?? 0);
+ existingGpu.max += gpuToCheck.max;
+ } else {
+ merged.push({ ...gpuToCheck });
+ }
+ return merged;
+ },
+ [] as typeof gpus
+ );
+ return mergedGpus.map((gpu) => {
+ const max = gpu.max ?? 0;
+ const inUse = gpu.inUse ?? 0;
+ const available = max - inUse;
+ const fee = gpuFees[gpu.id] ?? 0;
+ if (compact) {
+ return (
+
+
+
+ {fee} {tokenSymbol}/min
+
+
+
+ {available}/{max}
+
+ available
+
+
+ );
+ }
+ const percentage = (100 * inUse) / max;
+ return (
+ }
+ topRightContent={
+
+ {max} total
+
+ }
+ bottomLeftContent={
+
+ {fee} {tokenSymbol}/min
+
+ }
+ bottomRightContent={
+
+ {inUse} used
+
+ }
+ />
+ );
+ });
+ };
+
+ const getRamProgressBar = () => {
+ if (!ram) {
+ return null;
+ }
+ const max = ram.max ?? 0;
+ const inUse = ram.inUse ?? 0;
+ const available = max - inUse;
+ const fee = ramFee ?? 0;
+ if (compact) {
+ return (
+
+
+
+ GB RAM capacity
+
+
+ {fee} {tokenSymbol}/min
+
+
+
+ {available}/{max}
+
+ available
+
+
+ );
+ }
+ const percentage = (100 * inUse) / max;
+ return (
+
+ RAM capacity
+
+ }
+ topRightContent={
+
+ {max} GB total
+
+ }
+ bottomLeftContent={
+
+ {fee} {tokenSymbol}/min
+
+ }
+ bottomRightContent={
+
+ {inUse} GB used
+
+ }
+ />
+ );
+ };
+
+ const getDiskProgressBar = () => {
+ if (!disk) {
+ return null;
+ }
+ const max = disk.max ?? 0;
+ const inUse = disk.inUse ?? 0;
+ const available = max - inUse;
+ const fee = diskFee ?? 0;
+ if (compact) {
+ return (
+
+
+
+ GB Disk space
+
+
+ {fee} {tokenSymbol}/min
+
+
+
+ {available}/{max}
+
+ available
+
+
+ );
+ }
+ const percentage = (100 * inUse) / max;
+ return (
+
+ Disk space
+
+ }
+ topRightContent={
+
+ {max} GB total
+
+ }
+ bottomLeftContent={
+
+ {fee} {tokenSymbol}/min
+
+ }
+ bottomRightContent={
+
+ {inUse} GB used
+
+ }
+ />
+ );
+ };
+
+ return (
+
+
+ {compact ? (
+
+ {getGpuProgressBars()}
+ {getCpuProgressBar()}
+ {getRamProgressBar()}
+ {getDiskProgressBar()}
+
+ ) : gpus.length === 1 ? (
+ <>
+
Specs
+
+ {getGpuProgressBars()}
+ {getCpuProgressBar()}
+ {getRamProgressBar()}
+ {getDiskProgressBar()}
+
+ >
+ ) : (
+ <>
+
GPUs
+
{getGpuProgressBars()}
+
Other specs
+
+ {getCpuProgressBar()}
+ {getRamProgressBar()}
+ {getDiskProgressBar()}
+
+ >
+ )}
+
+
+
+
+ Job duration:
+
+ {formatNumber(minJobDurationHours)} - {formatNumber(maxJobDurationHours)}
+
+ hours
+
+ {showNodeName ? (
+
+ Node:{' '}
+
+ {nodeInfo.friendlyName ?? nodeInfo.id}
+
+
+ ) : null}
+
+
+ {environment.free ? (
+
+ ) : null}
+ } onClick={selectEnvironment}>
+ From {startingFee} {tokenSymbol}/min
+
+
+
+
+ );
+};
+
+export default EnvironmentCard;
diff --git a/src/components/gpu-label/gpu-label.module.css b/src/components/gpu-label/gpu-label.module.css
new file mode 100644
index 00000000..b080b259
--- /dev/null
+++ b/src/components/gpu-label/gpu-label.module.css
@@ -0,0 +1,9 @@
+.root {
+ align-items: center;
+ display: inline-flex;
+ gap: 8px;
+
+ .icon {
+ width: auto;
+ }
+}
diff --git a/src/components/gpu-label/gpu-label.tsx b/src/components/gpu-label/gpu-label.tsx
new file mode 100644
index 00000000..708d86c9
--- /dev/null
+++ b/src/components/gpu-label/gpu-label.tsx
@@ -0,0 +1,46 @@
+import AmdLogo from '@/assets/icons/gpu-manufacturers/amd.svg';
+import IntelLogo from '@/assets/icons/gpu-manufacturers/intel.svg';
+import NvidiaLogo from '@/assets/icons/gpu-manufacturers/nvidia.svg';
+import classNames from 'classnames';
+import styles from './gpu-label.module.css';
+
+type GpuLabelProps = {
+ className?: string;
+ gpu?: string;
+ iconHeight?: number;
+};
+
+const GpuLabel = ({ className, gpu, iconHeight = 14 }: GpuLabelProps) => {
+ if (!gpu) {
+ return null;
+ }
+
+ const getLogo = () => {
+ const lowercaseGpu = gpu.toLowerCase();
+
+ const iconProps = {
+ className: styles.icon,
+ style: { height: `${iconHeight}px` },
+ };
+
+ if (lowercaseGpu.startsWith('nvidia')) {
+ return ;
+ }
+ if (lowercaseGpu.startsWith('amd')) {
+ return ;
+ }
+ if (lowercaseGpu.startsWith('intel')) {
+ return ;
+ }
+ return null;
+ };
+
+ return (
+
+ {getLogo()}
+ {gpu}
+
+ );
+};
+
+export default GpuLabel;
diff --git a/src/components/homepage/docs-cta-section.module.css b/src/components/homepage/docs-cta-section.module.css
new file mode 100644
index 00000000..fc198568
--- /dev/null
+++ b/src/components/homepage/docs-cta-section.module.css
@@ -0,0 +1,188 @@
+@keyframes float {
+ from {
+ transform: translate3d(0, 0, 0);
+ }
+
+ 50% {
+ transform: translate3d(0, -6px, 0);
+ }
+
+ to {
+ transform: translate3d(0, 0, 0);
+ }
+}
+
+.root {
+ position: relative;
+ padding: 140px 0 120px;
+ background: #f8fbff;
+ color: #021e42;
+ overflow: hidden;
+}
+
+.root::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: url('/build-the-future.svg') no-repeat left center;
+ background-size: min(680px, 60%);
+ pointer-events: none;
+}
+
+.root::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ /* background: linear-gradient(115deg, rgba(255, 255, 255, 0.94), rgba(255, 255, 255, 0.65) 50%, rgba(255, 255, 255, 0)); */
+ pointer-events: none;
+}
+
+.container {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ gap: 40px;
+}
+
+.title {
+ font-family: var(--font-orbitron), sans-serif;
+ font-weight: 700;
+ font-style: normal;
+ font-size: clamp(42px, 5vw, 56px);
+ line-height: clamp(46px, 5.4vw, 60px);
+ letter-spacing: 0.02em;
+ text-transform: capitalize;
+ max-width: 780px;
+ margin: 0;
+}
+
+.docsButton {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ padding: 18px 46px;
+ border-radius: 999px;
+ background: #009bff;
+ color: #ffffff;
+ font-family: var(--font-orbitron), sans-serif;
+ font-weight: 700;
+ font-size: 20px;
+ line-height: 22px;
+ letter-spacing: 0;
+ text-transform: capitalize;
+ vertical-align: middle;
+ box-shadow: 0 18px 40px rgba(0, 114, 255, 0.35);
+ transition:
+ transform 0.2s ease,
+ box-shadow 0.2s ease;
+}
+
+.docsButton:hover {
+ transform: translateY(-3px);
+ box-shadow: 0 22px 48px rgba(0, 114, 255, 0.45);
+}
+
+.docsButton:active {
+ transform: translateY(0);
+}
+
+.buttonIcon {
+ font-size: 22px;
+ line-height: 1;
+ animation: float 2.6s ease-in-out infinite;
+}
+
+.socialLinks {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: center;
+ gap: 28px;
+}
+
+.socialLink {
+ display: inline-flex;
+ gap: 12px;
+ align-items: center;
+ color: #be00ff;
+ font-family: var(--font-inter), sans-serif;
+ font-style: normal;
+ font-weight: 700;
+ font-size: 20px;
+ line-height: 22px;
+ text-transform: capitalize;
+ transition:
+ transform 0.2s ease,
+ color 0.2s ease;
+}
+
+.socialLink:hover {
+ transform: translateY(-4px);
+ color: #f066ff;
+}
+
+.socialLinkIcon {
+ width: 28px;
+ height: 28px;
+ display: inline-block;
+ background-color: currentColor;
+}
+
+.discordIcon {
+ -webkit-mask: url('/icons/discord.svg') no-repeat center/contain;
+ mask: url('/icons/discord.svg') no-repeat center/contain;
+}
+
+.xIcon {
+ -webkit-mask: url('/icons/x.svg') no-repeat center/contain;
+ mask: url('/icons/x.svg') no-repeat center/contain;
+}
+
+@media (max-width: 900px) {
+ .root {
+ padding: 110px 0 90px;
+ }
+
+ .root::before {
+ background-position: top left;
+ background-size: 320px;
+ opacity: 0.65;
+ }
+
+ .container {
+ gap: 32px;
+ }
+
+ .docsButton {
+ padding: 16px 36px;
+ }
+}
+
+@media (max-width: 600px) {
+ .root::before {
+ background-position: top -40px left -40px;
+ background-size: 280px;
+ }
+
+ .root::after {
+ background: linear-gradient(
+ 125deg,
+ rgba(255, 255, 255, 0.96),
+ rgba(255, 255, 255, 0.72) 45%,
+ rgba(255, 255, 255, 0)
+ );
+ }
+
+ .docsButton {
+ width: 100%;
+ max-width: 320px;
+ }
+
+ .socialLinks {
+ gap: 20px;
+ }
+}
diff --git a/src/components/homepage/docs-cta-section.tsx b/src/components/homepage/docs-cta-section.tsx
new file mode 100644
index 00000000..1bd57742
--- /dev/null
+++ b/src/components/homepage/docs-cta-section.tsx
@@ -0,0 +1,32 @@
+import config, { getRoutes } from '@/config';
+import Link from 'next/link';
+import Container from '../container/container';
+import styles from './docs-cta-section.module.css';
+
+const DocsCtaSection = () => {
+ const routes = getRoutes();
+
+ return (
+
+
+ Build The Future Of AI With Decentralized Compute
+
+ Explore Docs
+ â–¸
+
+
+
+ Join Discord
+
+
+
+ Follow On
+
+
+
+
+
+ );
+};
+
+export default DocsCtaSection;
diff --git a/src/components/homepage/features.module.css b/src/components/homepage/features.module.css
new file mode 100644
index 00000000..30bd6048
--- /dev/null
+++ b/src/components/homepage/features.module.css
@@ -0,0 +1,117 @@
+.root {
+ background-color: #040a12;
+ padding: 60px 50px;
+ position: relative;
+ overflow: hidden;
+}
+
+.root::before,
+.root::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ background-repeat: no-repeat;
+ background-size: 70%;
+ opacity: 0.45;
+}
+
+.root::before {
+ background-image: url('/circuit-left.svg');
+ background-position: left 0;
+}
+
+.root::after {
+ background-image: url('/circuit-right.svg');
+ background-position: right 60%;
+}
+
+.featuresWrapper {
+ margin-top: 50px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 20px;
+}
+
+.featureItem {
+ box-sizing: border-box;
+ background-color: rgba(0, 153, 255, 0.16);
+ padding: 24px;
+ border-radius: 40px;
+ border: 1px solid rgba(0, 153, 255, 0.16);
+ backdrop-filter: blur(10px);
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.featureItemBlue {
+ background-color: #009bff;
+ border-color: #009bff;
+}
+
+.featuresWrapper > *:nth-child(4n + 1) {
+ flex: 0 0 calc(66% - 20px);
+}
+
+.featuresWrapper > *:nth-child(4n + 2),
+.featuresWrapper > *:nth-child(4n + 3) {
+ flex: 0 0 calc(34% - 20px);
+}
+
+.featuresWrapper > *:nth-child(4n + 4) {
+ flex: 0 0 calc(66% - 20px);
+}
+
+.featureTextWrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+@media (max-width: 1100px) {
+ .root {
+ padding: 50px 40px;
+ }
+
+ .featuresWrapper > *:nth-child(4n + 1),
+ .featuresWrapper > *:nth-child(4n + 2),
+ .featuresWrapper > *:nth-child(4n + 3),
+ .featuresWrapper > *:nth-child(4n + 4) {
+ flex: 1 1 calc(50% - 20px);
+ }
+}
+
+@media (max-width: 768px) {
+ .root {
+ padding: 40px 24px;
+ }
+
+ .featureItem {
+ border-radius: 28px;
+ }
+
+ .featuresWrapper {
+ gap: 16px;
+ }
+
+ .featuresWrapper > *:nth-child(4n + 1),
+ .featuresWrapper > *:nth-child(4n + 2),
+ .featuresWrapper > *:nth-child(4n + 3),
+ .featuresWrapper > *:nth-child(4n + 4) {
+ flex: 1 1 100%;
+ }
+}
+
+@media (max-width: 480px) {
+ .root {
+ padding: 32px 18px;
+ }
+
+ .featureItem {
+ padding: 20px;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 14px;
+ }
+}
diff --git a/src/components/homepage/features.tsx b/src/components/homepage/features.tsx
new file mode 100644
index 00000000..c4db4630
--- /dev/null
+++ b/src/components/homepage/features.tsx
@@ -0,0 +1,86 @@
+import BoxIcon from '@/assets/icons/box.svg';
+import CreditCardIcon from '@/assets/icons/credit-card.svg';
+import GlobeIcon from '@/assets/icons/globe.svg';
+import LockIcon from '@/assets/icons/lock.svg';
+import PlayIcon from '@/assets/icons/play.svg';
+import ShieldIcon from '@/assets/icons/shield.svg';
+import SliderIcon from '@/assets/icons/slider.svg';
+import UsersIcon from '@/assets/icons/users.svg';
+import cx from 'classnames';
+import Container from '../container/container';
+import SectionTitle from '../section-title/section-title';
+import styles from './features.module.css';
+
+const features: {
+ title: string;
+ description: string;
+ icon: JSX.Element;
+ isBlue?: boolean;
+}[] = [
+ {
+ title: 'Pay-as-you-go',
+ description:
+ 'Only pay for the compute you use. You are not billed while idle and you are not locked into a fixed instance.',
+ icon: ,
+ },
+ {
+ title: 'Ease of use',
+ description: 'Launch from VS Code. Clear logs and status in one place.',
+ icon: ,
+ isBlue: true,
+ },
+ {
+ title: 'Container-Based',
+ description: 'Bring your own container or use templates. Reproducible runs and consistent results.',
+ icon: ,
+ isBlue: true,
+ },
+ {
+ title: 'Maximum flexibility',
+ description: 'You are not constrained to preset CPU/GPU/RAM bundles. Choose the resources you need.',
+ icon: ,
+ },
+ {
+ title: 'Security',
+ description: 'Isolated execution, signed attestations, on-chain provenance for jobs when applicable.',
+ icon: ,
+ },
+ {
+ title: 'Inclusive by design',
+ description: 'We support both high-end rigs and smaller operators. Everyone can participate.',
+ icon: ,
+ isBlue: true,
+ },
+ {
+ title: 'Privacy-first jobs',
+ description: 'Run algorithms where data lives. Raw data stays private and never leaves its source.',
+ icon: ,
+ isBlue: true,
+ },
+ {
+ title: 'Global GPU/CPU pool',
+ description: 'Access diverse hardware across the network to match your budget and performance needs.',
+ icon: ,
+ },
+];
+
+export default function FeaturesSection() {
+ return (
+
+
+
+
+ {features.map((item) => (
+
+
{item.icon}
+
+
{item.title}
+
{item.description}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/homepage/footer-section.module.css b/src/components/homepage/footer-section.module.css
new file mode 100644
index 00000000..5dcd4875
--- /dev/null
+++ b/src/components/homepage/footer-section.module.css
@@ -0,0 +1,201 @@
+.root {
+ position: relative;
+ padding: 110px 0 60px;
+ background-color: #030713;
+ color: #e7f1ff;
+ overflow: hidden;
+}
+
+.background {
+ position: absolute;
+ inset: 0;
+ background: url('/footer.svg') no-repeat right 40px top 30px;
+ background-size: min(620px, 65%);
+ opacity: 0.45;
+ pointer-events: none;
+}
+
+.container {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: 60px;
+ z-index: 1;
+}
+
+.upperRow {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 48px;
+ justify-content: space-between;
+ align-items: flex-start;
+}
+
+.brandColumn {
+ flex: 1 1 320px;
+ max-width: 520px;
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.brandHeading {
+ display: flex;
+ align-items: center;
+ gap: 20px;
+}
+
+.brandTitleGroup {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.brandTitle {
+ font-size: 24px;
+ font-family: var(--font-orbitron), var(--font-inter), sans-serif;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.brandSubtitle {
+ font-size: 14px;
+ text-transform: uppercase;
+ color: rgba(231, 241, 255, 0.55);
+ letter-spacing: 0.18em;
+}
+
+.description {
+ font-size: 15px;
+ line-height: 1.7;
+ color: rgba(231, 241, 255, 0.75);
+ max-width: 400px;
+}
+
+.copy {
+ font-size: 13px;
+ color: rgba(231, 241, 255, 0.6);
+}
+
+.copy a {
+ color: #4fb2ff;
+}
+
+.pagesColumn {
+ flex: 0 0 220px;
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ text-transform: uppercase;
+}
+
+.columnTitle {
+ font-size: 16px;
+ font-family: var(--font-orbitron), var(--font-inter), sans-serif;
+ letter-spacing: 0.18em;
+}
+
+.pagesList {
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 0;
+ margin: 0;
+}
+
+.pageLink {
+ color: rgba(231, 241, 255, 0.75);
+ font-size: 15px;
+ font-family: var(--font-inter), sans-serif;
+ letter-spacing: 0.08em;
+ transition:
+ color 0.2s ease,
+ transform 0.2s ease;
+}
+
+.pageLink:hover {
+ color: #ffffff;
+ transform: translateX(4px);
+}
+
+.lowerRow {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 20px;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 13px;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: rgba(231, 241, 255, 0.6);
+}
+
+.socialLinks,
+.legalLinks {
+ display: flex;
+ gap: 24px;
+ flex-wrap: wrap;
+}
+
+.socialLinks {
+ flex: 1 1 320px;
+}
+
+.legalLinks {
+ flex: 0 0 220px;
+ justify-content: flex-end;
+ text-align: right;
+}
+
+.socialLinks a,
+.legalLink {
+ color: inherit;
+ transition: color 0.2s ease;
+}
+
+.socialLinks a:hover,
+.legalLink:hover {
+ color: #ffffff;
+}
+
+@media (max-width: 960px) {
+ .root {
+ padding: 90px 0 40px;
+ }
+
+ .container {
+ gap: 48px;
+ }
+
+ .brandHeading {
+ align-items: flex-start;
+ }
+}
+
+@media (max-width: 640px) {
+ .upperRow {
+ flex-direction: column;
+ gap: 36px;
+ }
+
+ .pagesColumn {
+ flex: 1 1 auto;
+ }
+
+ .lowerRow {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 16px;
+ }
+
+ .socialLinks {
+ flex: unset;
+ }
+
+ .legalLinks {
+ flex: unset;
+ justify-content: flex-start;
+ text-align: left;
+ }
+}
diff --git a/src/components/homepage/footer-section.tsx b/src/components/homepage/footer-section.tsx
new file mode 100644
index 00000000..fc16e3c3
--- /dev/null
+++ b/src/components/homepage/footer-section.tsx
@@ -0,0 +1,84 @@
+import Logo from '@/assets/logo.svg';
+import Container from '@/components/container/container';
+import config, { getLinks, getRoutes } from '@/config';
+import Link from 'next/link';
+import styles from './footer-section.module.css';
+
+const FooterSection = () => {
+ const currentYear = new Date().getFullYear();
+ const links = getLinks();
+ const routes = getRoutes();
+
+ const pageKeys = ['runJob', 'stats', 'docs', 'leaderboard', 'runNode'] as const;
+
+ return (
+
+
+
+
+
+
+
+ Keep your data, jobs, and infrastructure secure while tapping into a global network of decentralized
+ compute.
+
+
+ © {currentYear} All Rights Reserved. Powered by{' '}
+
+ Ocean Network
+
+ .
+
+
+
+
Pages
+
+ {pageKeys.map((key) => {
+ const route = routes[key];
+ if (!route) {
+ return null;
+ }
+
+ return (
+ -
+
+ {route.name}
+
+
+ );
+ })}
+
+
+
+
+
+
+ );
+};
+
+export default FooterSection;
diff --git a/src/components/homepage/hero-section.module.css b/src/components/homepage/hero-section.module.css
new file mode 100644
index 00000000..123f7867
--- /dev/null
+++ b/src/components/homepage/hero-section.module.css
@@ -0,0 +1,128 @@
+.root {
+ position: relative;
+ --nav-overlap: 120px;
+ margin-top: calc(var(--nav-overlap) * -1);
+ padding: calc(140px + var(--nav-overlap)) 0 120px;
+ overflow: hidden;
+}
+
+.title {
+ font-size: 88px;
+ line-height: 1;
+ margin: 0;
+}
+
+.title span {
+ color: #009bff;
+}
+
+.relative {
+ position: relative;
+}
+
+.titleWrapper {
+ display: flex;
+ gap: 15px;
+ flex-direction: column;
+ align-items: flex-start;
+ margin-bottom: 15px;
+}
+
+.subTitle {
+ font-size: 24px;
+ max-width: 580px;
+}
+
+.actionsAndTextWrapper {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 40px;
+}
+
+.actions {
+ display: flex;
+ gap: 10px;
+ justify-content: flex-start;
+}
+
+.textBadge {
+ font-size: 70px;
+ text-align: right;
+ line-height: 1;
+ font-family: var(--font-orbitron), var(--font-inter), sans-serif;
+}
+
+.textBadge span {
+ color: #009bff;
+}
+
+@media (max-width: 1200px) {
+ .title {
+ font-size: 72px;
+ }
+
+ .textBadge {
+ font-size: 60px;
+ }
+}
+
+@media (max-width: 992px) {
+ .root {
+ --nav-overlap: 100px;
+ padding: calc(110px + var(--nav-overlap)) 0 90px;
+ }
+
+ .title {
+ font-size: 64px;
+ }
+
+ .subTitle {
+ font-size: 22px;
+ }
+
+ .textBadge {
+ font-size: 52px;
+ }
+}
+
+@media (max-width: 768px) {
+ .root {
+ --nav-overlap: 90px;
+ padding: calc(100px + var(--nav-overlap)) 0 80px;
+ }
+
+ .actionsAndTextWrapper {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 30px;
+ }
+
+ .actions {
+ flex-wrap: wrap;
+ }
+
+ .textBadge {
+ text-align: left;
+ }
+}
+
+@media (max-width: 640px) {
+ .title {
+ font-size: 48px;
+ }
+
+ .subTitle {
+ font-size: 18px;
+ }
+
+ .actions {
+ width: 100%;
+ flex-direction: column;
+ gap: 12px;
+ }
+
+ .textBadge {
+ font-size: 42px;
+ }
+}
diff --git a/src/components/homepage/hero-section.tsx b/src/components/homepage/hero-section.tsx
new file mode 100644
index 00000000..e97ccd9b
--- /dev/null
+++ b/src/components/homepage/hero-section.tsx
@@ -0,0 +1,36 @@
+import Button from '../button/button';
+import Container from '../container/container';
+import styles from './hero-section.module.css';
+import LogoSlider from './logo-slider';
+
+export default function HeroSection() {
+ return (
+
+
+
+
+ Global
+ Compute
+ Power
+
+
+ Keep control of your data, jobs & infrastructure on a decentralized compute network.
+
+
+
+
+
+
+
+
+ ONE
+ NETWORK
+
+
+
+
+
+ );
+}
diff --git a/src/components/Pages/Homepage/style.module.css b/src/components/homepage/homepage.module.css
similarity index 98%
rename from src/components/Pages/Homepage/style.module.css
rename to src/components/homepage/homepage.module.css
index 163fc8bf..e0f3687b 100644
--- a/src/components/Pages/Homepage/style.module.css
+++ b/src/components/homepage/homepage.module.css
@@ -1,7 +1,6 @@
.root {
display: flex;
flex-direction: column;
- gap: 100px;
}
@media (max-width: 768px) {
diff --git a/src/components/homepage/homepage.tsx b/src/components/homepage/homepage.tsx
new file mode 100644
index 00000000..b69b2ee2
--- /dev/null
+++ b/src/components/homepage/homepage.tsx
@@ -0,0 +1,18 @@
+import DocsCtaSection from '@/components/homepage/docs-cta-section';
+import FeaturesSection from './features';
+import HeroSection from './hero-section';
+import styles from './homepage.module.css';
+import HowItWorksSection from './how-it-works';
+import LeaderboardSection from './leaderboard';
+
+export default function HomePage() {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/homepage/how-it-works.module.css b/src/components/homepage/how-it-works.module.css
new file mode 100644
index 00000000..aaeb8f2a
--- /dev/null
+++ b/src/components/homepage/how-it-works.module.css
@@ -0,0 +1,139 @@
+.root {
+ background-color: #040a12;
+ padding: 60px 50px;
+ position: relative;
+ z-index: 2;
+}
+
+.twoSections {
+ display: flex;
+ gap: 50px;
+ justify-content: center;
+ margin-top: 50px;
+}
+
+.featuresWrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 40px;
+ flex: 1;
+}
+
+.animation {
+ position: relative;
+ width: 568px;
+ height: 538px;
+ overflow: hidden;
+ border-radius: 32px;
+}
+
+.animation img {
+ width: 100%;
+ height: auto;
+ display: block;
+ border-radius: 32px;
+}
+
+.animation video {
+ width: 100%;
+ height: 100%;
+ display: block;
+ border-radius: 32px;
+ object-fit: cover;
+ object-position: 50% 30%;
+}
+
+.featureItem {
+ display: flex;
+ gap: 16px;
+ align-items: flex-start;
+ color: rgba(255, 255, 255, 0.4);
+ transition: 0.2s all ease-in-out;
+}
+
+.featureItem:hover {
+ color: rgba(255, 255, 255, 1);
+}
+
+.featureItemActive {
+ color: rgba(255, 255, 255, 1);
+}
+
+.featureItem:hover .indexNumber {
+ color: #be00ff;
+}
+
+.featureItemActive .indexNumber {
+ color: #be00ff;
+}
+
+.indexNumber {
+ font-family: var(--font-orbitron), var(--font-inter), sans-serif;
+ font-size: 18px;
+ transition: 0.2s all ease-in-out;
+ font-weight: bold;
+}
+
+.featureTextWrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+}
+
+@media (max-width: 1100px) {
+ .root {
+ padding: 50px 40px;
+ }
+
+ .twoSections {
+ gap: 32px;
+ }
+}
+
+@media (max-width: 900px) {
+ .twoSections {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .animation {
+ align-self: center;
+ width: 90vw;
+ max-width: 568px;
+ height: calc(90vw * 538 / 568);
+ }
+}
+
+@media (max-width: 768px) {
+ .root {
+ padding: 40px 24px;
+ }
+
+ .featuresWrapper {
+ gap: 28px;
+ }
+
+ .featureItem {
+ gap: 12px;
+ }
+
+ .animation {
+ width: 92vw;
+ max-width: 520px;
+ height: calc(92vw * 538 / 568);
+ }
+}
+
+@media (max-width: 480px) {
+ .root {
+ padding: 32px 18px;
+ }
+
+ .twoSections {
+ margin-top: 36px;
+ }
+
+ .indexNumber {
+ font-size: 16px;
+ }
+}
diff --git a/src/components/homepage/how-it-works.tsx b/src/components/homepage/how-it-works.tsx
new file mode 100644
index 00000000..5724628c
--- /dev/null
+++ b/src/components/homepage/how-it-works.tsx
@@ -0,0 +1,70 @@
+import { useVideoScroll } from '../../hooks/useVideoScroll';
+import Container from '../container/container';
+import SectionTitle from '../section-title/section-title';
+import styles from './how-it-works.module.css';
+
+const itemsList: {
+ title: string;
+ description: string;
+}[] = [
+ {
+ title: 'Select Environment',
+ description: 'Use the Smart Compute Wizard to filter by GPU/CPU, RAM, storage, location, and price.',
+ },
+ {
+ title: 'Define Resources',
+ description: 'Pick container or template, set params and limits.',
+ },
+ {
+ title: 'Fund the Job',
+ description: 'Allocate funds in escrow. See a clear cost estimate before launch.',
+ },
+ {
+ title: 'Maximum flexibility',
+ description: 'You are not constrained to preset CPU/GPU/RAM bundles. Choose the resources you need.',
+ },
+ {
+ title: 'Run Job',
+ description: 'Execution on Ocean Nodes with live status and logs.',
+ },
+ {
+ title: 'Get Results',
+ description: 'Outputs are returned. Raw data remains private.',
+ },
+];
+
+const videoSrc = '/globe_how-it-works.mp4';
+const posterSrc = '/banner-how-it-works.png';
+
+export default function HowItWorksSection() {
+ const { sectionRef, videoRef, activeIndex } = useVideoScroll({
+ numSteps: itemsList.length,
+ });
+
+ return (
+
+
+
+
+
+ {itemsList.map((item, index) => (
+
+
0{index + 1}
+
+
{item.title}
+
{item.description}
+
+
+ ))}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/homepage/leaderboard.module.css b/src/components/homepage/leaderboard.module.css
new file mode 100644
index 00000000..81ef65c6
--- /dev/null
+++ b/src/components/homepage/leaderboard.module.css
@@ -0,0 +1,151 @@
+.loader {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 100px;
+ font-size: 1.2rem;
+ color: #888;
+}
+
+.root {
+ padding: 60px 50px;
+ background-color: var(--black-900);
+ position: relative;
+ overflow: hidden;
+}
+
+.root::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: url('/circuit.svg') center/cover no-repeat;
+ opacity: 0.25;
+ pointer-events: none;
+}
+
+.relative {
+ position: relative;
+}
+
+.leaderboardWrapper {
+ margin-top: 50px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 10px;
+ position: relative;
+ z-index: 1;
+}
+
+.tableScroll {
+ width: 100%;
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+}
+
+.tableLine {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ padding: 25px 0;
+ align-items: center;
+ gap: 12px;
+ width: 100%;
+ min-width: 600px;
+}
+
+.tableHeader {
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ font-size: 13px;
+ color: rgba(225, 241, 255, 0.75);
+}
+
+.tableCell {
+ font-size: 15px;
+ color: rgba(225, 241, 255, 0.9);
+}
+
+.tableValue {
+ display: inline-block;
+}
+
+.tableLine:not(:last-child) {
+ border-bottom: 1px solid rgba(0, 153, 255, 0.4);
+}
+
+.leaderboardFooter {
+ margin-top: 32px;
+ display: flex;
+ justify-content: center;
+}
+
+.viewButton {
+ display: inline-flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 16px 32px;
+ min-width: 218px;
+ font-family: var(--font-inter), sans-serif;
+ font-weight: 700;
+ font-size: 20px;
+ line-height: 22px;
+ letter-spacing: 0;
+ text-transform: capitalize;
+ vertical-align: middle;
+ background: #ffffff;
+ color: #030713;
+ border-radius: 999px;
+ text-decoration: none;
+ transition:
+ transform 0.2s ease,
+ box-shadow 0.2s ease;
+}
+
+.viewButton:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 12px 30px rgba(0, 0, 0, 0.25);
+}
+
+@media (max-width: 1100px) {
+ .root {
+ padding: 50px 40px;
+ }
+
+ .leaderboardWrapper {
+ padding: 32px;
+ }
+}
+
+@media (max-width: 768px) {
+ .root {
+ padding: 40px 24px;
+ }
+
+ .leaderboardWrapper {
+ padding: 28px 22px;
+ }
+}
+
+@media (max-width: 480px) {
+ .root {
+ padding: 32px 18px;
+ }
+
+ .leaderboardWrapper {
+ border-radius: 28px;
+ padding: 24px 18px;
+ }
+
+ .tableLine {
+ gap: 12px 16px;
+ }
+
+ .viewButton {
+ width: 100%;
+ text-align: center;
+ justify-content: center;
+ }
+}
diff --git a/src/components/homepage/leaderboard.tsx b/src/components/homepage/leaderboard.tsx
new file mode 100644
index 00000000..d6c8747e
--- /dev/null
+++ b/src/components/homepage/leaderboard.tsx
@@ -0,0 +1,149 @@
+import Card from '@/components/card/card';
+import { getApiRoute, getRoutes } from '@/config';
+import { Node } from '@/types';
+import axios from 'axios';
+import Link from 'next/link';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import Container from '../container/container';
+import SectionTitle from '../section-title/section-title';
+import styles from './leaderboard.module.css';
+
+type LeaderboardItem = {
+ nodeId: string;
+ gpuCpu: string;
+ benchScore: number;
+ jobsCompleted: number;
+ revenue: string;
+};
+
+const columns: { key: keyof LeaderboardItem; label: string }[] = [
+ { key: 'nodeId', label: 'Node ID' },
+ { key: 'gpuCpu', label: 'GPU/CPU' },
+ { key: 'benchScore', label: 'Bench Score' },
+ { key: 'jobsCompleted', label: 'Jobs Completed' },
+ { key: 'revenue', label: 'Revenue' },
+];
+
+export default function LeaderboardSection() {
+ const routes = getRoutes();
+ const [topNodes, setTopNodes] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+
+ async function fetchNodeJobStats(nodeId: string) {
+ const result = await axios.get(`${getApiRoute('nodeStats')}/${nodeId}/stats`);
+
+ return { ...result.data, nodeId };
+ }
+
+ const fetchData = useCallback(async () => {
+ setIsLoading(true);
+ try {
+ const response = await axios.get(
+ `${getApiRoute('nodes')}?page=0&size=3&sort={"latestBenchmarkResults.gpuScore":"desc"}`
+ );
+ const sanitizedData = response.data.nodes.map((element: any) => element._source);
+
+ const promises = [];
+ for (const node of sanitizedData) {
+ promises.push(fetchNodeJobStats(node.id));
+ }
+ const results = await Promise.all(promises);
+ results.forEach((result) => {
+ const currentNodeIndex = sanitizedData.findIndex((item: Node) => item.id === result.nodeId);
+ sanitizedData[currentNodeIndex] = {
+ ...sanitizedData[currentNodeIndex],
+ totalJobs: result.totalJobs,
+ totalRevenue: result.totalRevenue,
+ };
+ });
+
+ setTopNodes(sanitizedData);
+ } catch (error) {
+ console.log(error);
+ setIsLoading(false);
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ let mounted = true;
+ const controller = new AbortController();
+
+ const fetchAllData = async () => {
+ if (!mounted) return;
+ try {
+ await fetchData();
+ } catch (error) {
+ console.error('Error fetching initial leaderboard data:', error);
+ } finally {
+ if (mounted) {
+ // setOverallDashboardLoading(false);
+ }
+ }
+ };
+
+ fetchAllData();
+
+ return () => {
+ mounted = false;
+ controller.abort();
+ };
+ }, [fetchData]);
+
+ function formatNodeGPUCPU(node: Node) {
+ if (node.gpus) {
+ return node.gpus.map((gpu) => `${gpu.vendor} ${gpu.name}`).join(', ');
+ } else if (node.cpus) {
+ return node.cpus.map((cpu) => cpu.model).join(', ');
+ }
+ return '-';
+ }
+
+ const itemsList: LeaderboardItem[] = useMemo(
+ () =>
+ topNodes.map((node) => ({
+ nodeId: node.friendlyName ?? node.id ?? '-',
+ gpuCpu: formatNodeGPUCPU(node),
+ benchScore: node.latestBenchmarkResults.gpuScore,
+ jobsCompleted: node.totalJobs,
+ revenue: `USDC ${node.totalRevenue.toFixed(2)}`,
+ })),
+ [topNodes]
+ );
+
+ return (
+
+
+
+
+
+ {columns.map((column) => (
+
+ {column.label}
+
+ ))}
+
+ {isLoading ? (
+ Loading...
+ ) : (
+ itemsList.map((item, index) => (
+
+ {columns.map((column) => (
+
+ {item[column.key]}
+
+ ))}
+
+ ))
+ )}
+
+
+
+ View Full Leaderboard
+
+
+
+
+ );
+}
diff --git a/src/components/homepage/logo-slider.module.css b/src/components/homepage/logo-slider.module.css
new file mode 100644
index 00000000..4a9548dd
--- /dev/null
+++ b/src/components/homepage/logo-slider.module.css
@@ -0,0 +1,112 @@
+.root {
+ position: relative;
+ overflow: hidden;
+ -webkit-mask-image: linear-gradient(to right, transparent, #000 10%, #000 90%, transparent);
+ mask-image: linear-gradient(to right, transparent, #000 10%, #000 90%, transparent);
+ margin: 120px 0 50px;
+}
+
+.marquee {
+ --gap: 2rem;
+ --duration: 20s;
+}
+
+.track {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--gap);
+ white-space: nowrap;
+ will-change: transform;
+ animation: scroll var(--duration) linear infinite;
+}
+
+@keyframes scroll {
+ from {
+ transform: translateX(0);
+ }
+ to {
+ transform: translateX(-50%);
+ }
+}
+
+.sliderItem {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.5rem 1rem;
+}
+
+.logoWrapper {
+ display: grid;
+ place-items: center;
+ width: 80px;
+ height: 80px;
+ flex: 0 0 auto;
+}
+
+.logoImage {
+ width: 80px;
+ height: 80px;
+ object-fit: cover;
+ border-radius: 9999px;
+ background-color: white;
+}
+
+.name {
+ font-size: 20px;
+ font-weight: bold;
+ white-space: nowrap;
+}
+
+/* .root:hover .track {
+ animation-play-state: paused;
+} */
+
+@media (prefers-reduced-motion: reduce) {
+ .track {
+ animation: none;
+ }
+}
+
+@media (max-width: 900px) {
+ .root {
+ margin: 90px 0 40px;
+ -webkit-mask-image: linear-gradient(to right, transparent, #000 6%, #000 94%, transparent);
+ mask-image: linear-gradient(to right, transparent, #000 6%, #000 94%, transparent);
+ }
+
+ .marquee {
+ --gap: 1.5rem;
+ }
+
+ .logoWrapper,
+ .logoImage {
+ width: 64px;
+ height: 64px;
+ }
+
+ .name {
+ font-size: 18px;
+ }
+}
+
+@media (max-width: 600px) {
+ .root {
+ margin: 70px 0 30px;
+ }
+
+ .marquee {
+ --gap: 1.25rem;
+ --duration: 24s;
+ }
+
+ .logoWrapper,
+ .logoImage {
+ width: 52px;
+ height: 52px;
+ }
+
+ .name {
+ font-size: 16px;
+ }
+}
diff --git a/src/components/homepage/logo-slider.tsx b/src/components/homepage/logo-slider.tsx
new file mode 100644
index 00000000..a3677209
--- /dev/null
+++ b/src/components/homepage/logo-slider.tsx
@@ -0,0 +1,38 @@
+import Image from 'next/image';
+import styles from './logo-slider.module.css';
+
+const sliderItems = [
+ { src: '/banner-video.jpg', name: 'Collaborator 1' },
+ { src: '/banner-video.jpg', name: 'Collaborator 2' },
+ { src: '/banner-video.jpg', name: 'Collaborator 3' },
+ { src: '/banner-video.jpg', name: 'Collaborator 4' },
+ { src: '/banner-video.jpg', name: 'Collaborator 5' },
+ { src: '/banner-video.jpg', name: 'Collaborator 6' },
+ { src: '/banner-video.jpg', name: 'Collaborator 7' },
+ { src: '/banner-video.jpg', name: 'Collaborator 8' },
+ { src: '/banner-video.jpg', name: 'Collaborator 9' },
+ { src: '/banner-video.jpg', name: 'Collaborator 10' },
+];
+
+export default function LogoSlider() {
+ const items = [...sliderItems, ...sliderItems];
+
+ const durationSec = Math.max(12, sliderItems.length * 6);
+
+ return (
+
+
+
+ {items.map((item, idx) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/hooks/use-env-resources.ts b/src/components/hooks/use-env-resources.ts
new file mode 100644
index 00000000..c894b699
--- /dev/null
+++ b/src/components/hooks/use-env-resources.ts
@@ -0,0 +1,134 @@
+import { CHAIN_ID } from '@/constants/chains';
+import { ComputeEnvironment, ComputeResource } from '@/types/environments';
+import { useMemo } from 'react';
+
+type UseEnvResources = {
+ cpu?: ComputeResource;
+ cpuFee?: number;
+ disk?: ComputeResource;
+ diskFee?: number;
+ gpus: ComputeResource[];
+ gpuFees: Record;
+ ram?: ComputeResource;
+ ramFee?: number;
+ supportedTokens: string[];
+};
+
+const useEnvResources = ({
+ environment,
+ freeCompute,
+ tokenAddress,
+}: {
+ environment: ComputeEnvironment;
+ freeCompute: boolean;
+ tokenAddress: string;
+}): UseEnvResources => {
+ const { fees, supportedTokens } = useMemo(() => {
+ try {
+ const fees = environment.fees[CHAIN_ID];
+ if (!fees) {
+ return { fees: [], supportedTokens: [] };
+ }
+ const supportedTokens = fees.map((fee) => fee.feeToken);
+ return { fees, supportedTokens };
+ } catch (error) {
+ console.error('Error processing fees:', error);
+ return { fees: [], supportedTokens: [] };
+ }
+ }, [environment.fees]);
+
+ const selectedTokenFees = useMemo(() => fees.find((fee) => fee.feeToken === tokenAddress), [fees, tokenAddress]);
+
+ const { cpu, disk, gpus, ram } = useMemo(() => {
+ try {
+ let cpu = environment.resources?.find((res) => res.type === 'cpu' || res.id === 'cpu');
+ let disk = environment.resources?.find((res) => res.type === 'disk' || res.id === 'disk');
+ let gpus = environment.resources?.filter((res) => res.type === 'gpu' || res.id === 'gpu') ?? [];
+ let ram = environment.resources?.find((res) => res.type === 'ram' || res.id === 'ram');
+ if (freeCompute) {
+ // only keep resources that are available for free compute
+ // and update their max / inUse values
+ const freeResources = environment.free?.resources ?? [];
+ if (cpu) {
+ const freeCpu = freeResources.find((res) => res.id === cpu!.id);
+ cpu = freeCpu ? { ...cpu, ...freeCpu } : undefined;
+ }
+ if (disk) {
+ const freeDisk = freeResources.find((res) => res.id === disk!.id);
+ disk = freeDisk ? { ...disk, ...freeDisk } : undefined;
+ }
+ if (ram) {
+ const freeRam = freeResources.find((res) => res.id === ram!.id);
+ ram = freeRam ? { ...ram, ...freeRam } : undefined;
+ }
+ if (gpus.length > 0) {
+ const newGpus = [];
+ for (const gpu of gpus) {
+ const freeGpu = freeResources.find((res) => res.id === gpu.id);
+ if (freeGpu) {
+ newGpus.push({ ...gpu, ...freeGpu });
+ }
+ }
+ gpus = newGpus;
+ }
+ }
+ return { cpu, disk, gpus, ram };
+ } catch (error) {
+ console.error('Error processing resources:', error);
+ return { cpu: undefined, disk: undefined, gpus: [], ram: undefined };
+ }
+ }, [environment.free?.resources, environment.resources, freeCompute]);
+
+ const { cpuFee, diskFee, ramFee } = useMemo(() => {
+ try {
+ if (freeCompute) {
+ return { cpuFee: 0, diskFee: 0, ramFee: 0 };
+ }
+ const cpuId = cpu?.id;
+ const diskId = disk?.id;
+ const ramId = ram?.id;
+ const cpuFee = selectedTokenFees?.prices.find((price) => price.id === cpuId)?.price;
+ const diskFee = selectedTokenFees?.prices.find((price) => price.id === diskId)?.price;
+ const ramFee = selectedTokenFees?.prices.find((price) => price.id === ramId)?.price;
+ return { cpuFee, diskFee, ramFee };
+ } catch (error) {
+ console.error('Error processing fees:', error);
+ return { cpuFee: undefined, diskFee: undefined, ramFee: undefined };
+ }
+ }, [cpu?.id, disk?.id, freeCompute, ram?.id, selectedTokenFees?.prices]);
+
+ const gpuFees = useMemo(() => {
+ try {
+ if (freeCompute) {
+ return {};
+ }
+ const fees: Record = {};
+ if (selectedTokenFees) {
+ const gpuIds = gpus.map((gpu) => gpu.id);
+ selectedTokenFees.prices
+ .filter((fee) => gpuIds.includes(fee.id))
+ .forEach((fee) => {
+ fees[fee.id] = fee.price;
+ });
+ }
+ return fees;
+ } catch (error) {
+ console.error('Error processing gpu fees:', error);
+ return {};
+ }
+ }, [freeCompute, selectedTokenFees, gpus]);
+
+ return {
+ cpu,
+ cpuFee,
+ disk,
+ diskFee,
+ gpus,
+ gpuFees,
+ ram,
+ ramFee,
+ supportedTokens,
+ };
+};
+
+export default useEnvResources;
diff --git a/src/components/input/input-wrapper.tsx b/src/components/input/input-wrapper.tsx
new file mode 100644
index 00000000..89b8a170
--- /dev/null
+++ b/src/components/input/input-wrapper.tsx
@@ -0,0 +1,62 @@
+import { styled } from '@mui/material';
+
+const StyledRoot = styled('div')<{ disabled?: boolean }>(({ disabled }) => ({
+ display: 'flex',
+ flexDirection: 'column',
+ gap: 4,
+ opacity: disabled ? 0.5 : 1,
+}));
+
+const StyledLabelWrapper = styled('div')({
+ alignItems: 'end',
+ display: 'flex',
+ justifyContent: 'space-between',
+ padding: '0 16px',
+});
+
+const StyledLabel = styled('label')({
+ fontSize: 14,
+ fontWeight: 600,
+ color: 'var(--text-primary)',
+});
+
+const StyledHint = styled('div')({
+ fontSize: 14,
+ color: 'var(--text-secondary)',
+});
+
+const StyledFooterHint = styled(StyledHint)({
+ padding: '0 16px',
+});
+
+const StyledErrorText = styled(StyledFooterHint)({
+ color: 'var(--error)',
+});
+
+type InputWrapperProps = {
+ children: React.ReactNode;
+ className?: string;
+ disabled?: boolean;
+ errorText?: string | string[];
+ hint?: string;
+ label?: string;
+ topRight?: React.ReactNode;
+};
+
+const InputWrapper = ({ children, className, disabled, errorText, hint, label, topRight }: InputWrapperProps) => (
+
+ {label || topRight ? (
+
+ {label}
+ {topRight ? {topRight} : null}
+
+ ) : null}
+ {children}
+ {hint ? {hint} : null}
+ {errorText ? (
+ {Array.isArray(errorText) ? errorText.join(' | ') : errorText}
+ ) : null}
+
+);
+
+export default InputWrapper;
diff --git a/src/components/input/input.tsx b/src/components/input/input.tsx
new file mode 100644
index 00000000..ee4ce5d0
--- /dev/null
+++ b/src/components/input/input.tsx
@@ -0,0 +1,93 @@
+import InputWrapper from '@/components/input/input-wrapper';
+import { styled, TextField } from '@mui/material';
+
+const StyledTextField = styled(TextField)<{ custom_size?: 'sm' | 'md'; has_error?: boolean }>(
+ ({ custom_size, disabled, has_error }) => ({
+ background: disabled ? 'transparent' : 'var(--background-glass)',
+ border: `1px solid var(${has_error ? '--error' : '--border-glass'})`,
+ borderRadius: 24,
+ lineHeight: '18px',
+
+ fieldset: {
+ border: 'none',
+ },
+
+ '& .Mui-disabled': {
+ '-webkit-text-fill-color': 'var(--text-primary)',
+ },
+
+ '& .MuiInputBase-root': {
+ color: 'var(--text-secondary)',
+ fontFamily: 'var(--font-inter), sans-serif',
+ },
+
+ '& .MuiInputBase-input': {
+ color: 'var(--text-primary)',
+ fontSize: 16,
+ lineHeight: '18px',
+ minHeight: 0,
+ padding: custom_size === 'sm' ? '4px 16px' : '12px 16px',
+ },
+ })
+);
+
+type InputProps = {
+ className?: string;
+ disabled?: boolean;
+ endAdornment?: React.ReactNode;
+ errorText?: string;
+ hint?: string;
+ label?: string;
+ name?: string;
+ onBlur?: (e: React.FocusEvent) => void;
+ onChange?: (e: React.ChangeEvent) => void;
+ placeholder?: string;
+ size?: 'sm' | 'md';
+ startAdornment?: React.ReactNode;
+ topRight?: React.ReactNode;
+ type: 'text' | 'password' | 'email' | 'number';
+ value?: string | number;
+};
+
+const Input = ({
+ className,
+ disabled,
+ endAdornment,
+ errorText,
+ hint,
+ label,
+ name,
+ onBlur,
+ onChange,
+ placeholder,
+ size = 'md',
+ startAdornment,
+ topRight,
+ type,
+ value,
+}: InputProps) => (
+
+
+
+);
+
+export default Input;
diff --git a/src/components/input/select.tsx b/src/components/input/select.tsx
new file mode 100644
index 00000000..167925b3
--- /dev/null
+++ b/src/components/input/select.tsx
@@ -0,0 +1,144 @@
+import InputWrapper from '@/components/input/input-wrapper';
+import { Checkbox, ListItemText, Select as MaterialSelect, MenuItem, selectClasses, styled } from '@mui/material';
+import { useMemo } from 'react';
+
+const StyledMultipleValueContainer = styled('div')({
+ display: 'flex',
+ flexWrap: 'wrap',
+ gap: 4,
+});
+
+const StyledSelect = styled(MaterialSelect)<{ custom_size?: 'sm' | 'md'; has_error?: boolean }>(
+ ({ custom_size, has_error }) => ({
+ background: 'var(--background-glass)',
+ border: `1px solid var(${has_error ? '--error' : '--border-glass'})`,
+ borderRadius: 24,
+ color: 'var(--text-primary)',
+ fontFamily: 'var(--font-inter), sans-serif',
+ fontSize: 16,
+ lineHeight: '18px',
+
+ fieldset: {
+ border: 'none',
+ },
+
+ [`& .${selectClasses.select}`]: {
+ padding: custom_size === 'sm' ? '4px 16px' : '12px 16px',
+ minHeight: 0,
+
+ '& > .MuiListItemText-root': {
+ marginBottom: 0,
+ marginTop: 0,
+
+ '& > .MuiListItemText-primary': {
+ lineHeight: custom_size === 'sm' ? '22px' : '24px',
+ },
+ },
+ },
+
+ [`& .${selectClasses.icon}`]: {
+ color: 'var(--text-secondary)',
+ position: 'relative',
+ },
+ })
+);
+
+export type SelectOption = {
+ label: string;
+ value: T;
+};
+
+type SelectProps = {
+ className?: string;
+ endAdornment?: React.ReactNode;
+ errorText?: string | string[];
+ fullWidth?: boolean;
+ hint?: string;
+ label?: string;
+ name?: string;
+ MenuProps?: any;
+ onBlur?: (e: React.FocusEvent) => void;
+ options?: SelectOption[];
+ renderOption?: (option: SelectOption) => React.ReactNode;
+ renderSelectedValue?: (label: string) => React.ReactNode;
+ size?: 'sm' | 'md';
+ topRight?: React.ReactNode;
+} & (
+ | {
+ multiple?: false;
+ onChange?: (e: any) => void;
+ value?: T;
+ }
+ | {
+ multiple: true;
+ onChange?: (e: any) => void;
+ value?: T[];
+ }
+);
+
+const Select = ({
+ className,
+ endAdornment,
+ errorText,
+ hint,
+ label,
+ multiple,
+ name,
+ MenuProps,
+ onBlur,
+ onChange,
+ options,
+ renderOption,
+ renderSelectedValue,
+ size = 'md',
+ topRight,
+ value,
+}: SelectProps) => {
+ const memoizedRenderValue = useMemo<((value: any) => React.ReactNode) | undefined>(() => {
+ if (multiple) {
+ const MultiRenderValue = (value: T[]) => (
+
+ {options
+ ?.filter((option) => value.includes(option.value))
+ .map((option) => (
+
+ {renderSelectedValue?.(option.label) ?? option.label}
+
+ ))}
+
+ );
+ (MultiRenderValue as any).displayName = 'SelectMultiRenderValue';
+ return MultiRenderValue as (value: any) => React.ReactNode;
+ }
+ return undefined;
+ }, [multiple, options, renderSelectedValue]);
+
+ return (
+
+
+ {options?.map((option) => (
+
+ ))}
+
+
+ );
+};
+
+export default Select;
diff --git a/src/components/leaderboard/leaderboard-page.tsx b/src/components/leaderboard/leaderboard-page.tsx
new file mode 100644
index 00000000..ba32f98e
--- /dev/null
+++ b/src/components/leaderboard/leaderboard-page.tsx
@@ -0,0 +1,31 @@
+import Card from '@/components/card/card';
+import Container from '@/components/container/container';
+import SectionTitle from '@/components/section-title/section-title';
+import JobsRevenueStats from '@/components/stats/jobs-revenue-stats';
+import { Table } from '@/components/table/table';
+import { TableTypeEnum } from '@/components/table/table-type';
+import { useLeaderboardTableContext } from '@/context/table/leaderboard-table-context';
+import { AnyNode } from '@/types/nodes';
+
+const LeaderboardPage = () => {
+ const leaderboardTableContext = useLeaderboardTableContext();
+
+ return (
+
+
+
+
+
+
+ context={leaderboardTableContext}
+ paginationType="context"
+ showToolbar
+ tableType={TableTypeEnum.NODES_LEADERBOARD}
+ />
+
+
+
+ );
+};
+
+export default LeaderboardPage;
diff --git a/src/components/modal/job-info-modal.tsx b/src/components/modal/job-info-modal.tsx
new file mode 100644
index 00000000..2a5cbd29
--- /dev/null
+++ b/src/components/modal/job-info-modal.tsx
@@ -0,0 +1,63 @@
+import { DownloadLogsButton } from '@/components/button/download-logs-button';
+import { DownloadResultButton } from '@/components/button/download-result-button';
+import EnvironmentCard from '@/components/environment-card/environment-card';
+import Modal from '@/components/modal/modal';
+import { useProfileContext } from '@/context/profile-context';
+import { ComputeJob } from '@/types/jobs';
+import { Stack } from '@mui/material';
+import classNames from 'classnames';
+import { useEffect } from 'react';
+import styles from './modal.module.css';
+
+interface JobInfoModalProps {
+ job: ComputeJob | null;
+ open: boolean;
+ onClose: () => void;
+}
+
+export const JobInfoModal = ({ job, open, onClose }: JobInfoModalProps) => {
+ const { fetchNodeEnv, environment, nodeInfo } = useProfileContext();
+
+ useEffect(() => {
+ if (open && job?.environment) {
+ fetchNodeEnv(job.peerId, job.environment);
+ }
+ }, [open, fetchNodeEnv, job]);
+
+ if (!job) return null;
+
+ return (
+
+
+
+
+
+
+ {environment && (
+
+ )}
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/modal/modal.module.css b/src/components/modal/modal.module.css
new file mode 100644
index 00000000..fea51954
--- /dev/null
+++ b/src/components/modal/modal.module.css
@@ -0,0 +1,43 @@
+/* Header */
+.header {
+ align-items: start;
+ display: flex;
+ gap: 24px;
+ justify-content: space-between;
+}
+
+/* Title */
+.title {
+ font-size: 20px;
+ font-weight: 600;
+ margin: 0;
+}
+
+/* Close Button */
+.closeButton {
+ align-items: center;
+ background: transparent;
+ border: none;
+ border-radius: 8px;
+ color: var(--text-secondary);
+ cursor: pointer;
+ display: flex;
+ justify-content: center;
+ padding: 4px;
+ position: relative;
+ right: -8px;
+ top: -8px;
+ transition:
+ color 0.2s,
+ background-color 0.2s;
+
+ .icon {
+ height: 16px;
+ width: 16px;
+ }
+}
+
+.closeButton:hover {
+ color: var(--text-primary);
+ background-color: var(--background-glass);
+}
diff --git a/src/components/modal/modal.tsx b/src/components/modal/modal.tsx
new file mode 100644
index 00000000..48e9d9f1
--- /dev/null
+++ b/src/components/modal/modal.tsx
@@ -0,0 +1,47 @@
+import CloseIcon from '@mui/icons-material/Close';
+import { Breakpoint, Dialog, styled } from '@mui/material';
+import { ReactNode } from 'react';
+import styles from './modal.module.css';
+
+const StyledDialog = styled(Dialog)({
+ '& .MuiModal-backdrop': {
+ backdropFilter: 'var(--backdrop-filter-overlay)',
+ },
+
+ '& .MuiDialog-paper': {
+ background: 'var(--background-modal)',
+ borderRadius: 24,
+ boxShadow: 'var(--shadow-dialog), var(--inner-shadow-glass)',
+ color: 'var(--text-primary)',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: 24,
+ padding: 24,
+ },
+});
+
+type ModalProps = {
+ children: ReactNode;
+ fullWidth?: boolean;
+ hideCloseButton?: boolean;
+ isOpen: boolean;
+ onClose: () => void;
+ title?: string;
+ width?: Breakpoint;
+};
+
+const Modal = ({ children, fullWidth, hideCloseButton, isOpen, onClose, title, width }: ModalProps) => (
+
+
+ {title &&
{title}
}
+ {hideCloseButton ? null : (
+
+ )}
+
+ {children}
+
+);
+
+export default Modal;
diff --git a/src/components/node-details/balance.module.css b/src/components/node-details/balance.module.css
new file mode 100644
index 00000000..21ee0b21
--- /dev/null
+++ b/src/components/node-details/balance.module.css
@@ -0,0 +1,35 @@
+.root {
+ display: flex;
+ flex-direction: column;
+ gap: 48px;
+
+ .content {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+
+ .heading {
+ text-align: center;
+ }
+
+ .list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+
+ .listItem {
+ align-items: center;
+ display: flex;
+ font-size: 18px;
+ justify-content: space-between;
+ }
+ }
+ }
+
+ .buttons {
+ align-items: center;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+}
diff --git a/src/components/node-details/balance.tsx b/src/components/node-details/balance.tsx
new file mode 100644
index 00000000..2ca3cc41
--- /dev/null
+++ b/src/components/node-details/balance.tsx
@@ -0,0 +1,88 @@
+import { useEffect, useMemo, useState } from 'react';
+
+import Button from '@/components/button/button';
+import Card from '@/components/card/card';
+import GasFeeModal from '@/components/node-details/gas-fee-modal';
+import WithdrawModal from '@/components/node-details/withdraw-modal';
+import { useOceanAccount } from '@/lib/use-ocean-account';
+import { NodeBalance } from '@/types/nodes';
+import { formatNumber } from '@/utils/formatters';
+import DownloadIcon from '@mui/icons-material/Download';
+import styles from './balance.module.css';
+
+interface BalanceProps {
+ admins: string[];
+ peerId: string;
+}
+
+export const Balance = ({ admins, peerId }: BalanceProps) => {
+ const { account, ocean } = useOceanAccount();
+
+ const [balances, setBalances] = useState([]);
+ const [loadingBalance, setLoadingBalance] = useState(false);
+ const [isGasFeeModalOpen, setIsGasFeeModalOpen] = useState(false);
+ const [isWithdrawModalOpen, setIsWithdrawModalOpen] = useState(false);
+
+ const isAdmin = useMemo(() => admins.includes(account?.address as string), [admins, account]);
+
+ useEffect(() => {
+ if (ocean && peerId) {
+ setLoadingBalance(true);
+
+ ocean.getNodeBalance(peerId).then((res) => {
+ res.length === 0 ? setBalances([]) : setBalances(res);
+ setLoadingBalance(false);
+ });
+ }
+ }, [ocean, peerId]);
+
+ return (
+
+
+
Node balance
+
+ {!ocean ? (
+
Connect your wallet to see node balance
+ ) : loadingBalance ? (
+
Fetching data...
+ ) : balances.length >= 1 ? (
+ balances.map((balance, index) => (
+
+
{balance.token}
+ {balance.amount &&
{formatNumber(balance.amount)}}
+
+ ))
+ ) : (
+
No balance
+ )}
+
+
+
+ {isAdmin ? (
+ <>
+
+ }
+ disabled={!ocean}
+ onClick={() => setIsWithdrawModalOpen(true)}
+ size="lg"
+ >
+ Withdraw funds
+
+ >
+ ) : null}
+ setIsGasFeeModalOpen(false)} />
+ setIsWithdrawModalOpen(false)} />
+
+
+ );
+};
diff --git a/src/components/node-details/benchmark-jobs.tsx b/src/components/node-details/benchmark-jobs.tsx
new file mode 100644
index 00000000..6ce79441
--- /dev/null
+++ b/src/components/node-details/benchmark-jobs.tsx
@@ -0,0 +1,41 @@
+import Card from '@/components/card/card';
+import { Table } from '@/components/table/table';
+import { TableTypeEnum } from '@/components/table/table-type';
+import {
+ BenchmarkJobsHistoryTableProvider,
+ useBenchmarkJobsHistoryTableContext,
+} from '@/context/table/benchmark-jobs-history-table-context';
+import { Job } from '@/types/jobs';
+import { useParams } from 'next/navigation';
+
+const BenchmarkJobsContent = () => {
+ const benchmarkJobsHistoryTableContext = useBenchmarkJobsHistoryTableContext();
+
+ return (
+
+ Benchmark jobs history
+
+ context={benchmarkJobsHistoryTableContext}
+ paginationType="context"
+ showToolbar
+ tableType={TableTypeEnum.BENCHMARK_JOBS}
+ />
+
+ );
+};
+
+const BenchmarkJobs = () => {
+ const params = useParams<{ nodeId: string }>();
+
+ if (!params?.nodeId) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+};
+
+export default BenchmarkJobs;
diff --git a/src/components/node-details/config-modal.module.css b/src/components/node-details/config-modal.module.css
new file mode 100644
index 00000000..f2f2663f
--- /dev/null
+++ b/src/components/node-details/config-modal.module.css
@@ -0,0 +1,29 @@
+.modalContent {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+
+ .buttons {
+ display: flex;
+ flex-wrap: wrap;
+ flex-direction: row;
+ gap: 8px;
+ justify-content: flex-end;
+ }
+
+ .editorWrapper {
+ border-radius: 16px;
+ border: 1px solid var(--border-glass);
+ max-height: calc(100vh - 300px);
+ overflow-y: auto;
+ }
+
+ .fetching {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 40px;
+ color: var(--text-secondary);
+ font-style: italic;
+ }
+}
diff --git a/src/components/node-details/config-modal.tsx b/src/components/node-details/config-modal.tsx
new file mode 100644
index 00000000..3e373df6
--- /dev/null
+++ b/src/components/node-details/config-modal.tsx
@@ -0,0 +1,66 @@
+import Button from '@/components/button/button';
+import Modal from '@/components/modal/modal';
+import { githubDarkTheme, JsonEditor } from 'json-edit-react';
+import { Dispatch, SetStateAction } from 'react';
+import styles from './config-modal.module.css';
+
+type ConfigModalProps = {
+ isOpen: boolean;
+ fetchingConfig: boolean;
+ pushingConfig: boolean;
+ config: Record;
+ editedConfig: Record;
+ setEditedConfig: Dispatch>>;
+ handlePushConfig: (config: Record) => Promise;
+ onClose: () => void;
+};
+
+const ConfigModal = ({
+ isOpen,
+ fetchingConfig,
+ pushingConfig,
+ config,
+ editedConfig,
+ setEditedConfig,
+ handlePushConfig,
+ onClose,
+}: ConfigModalProps) => {
+ return (
+
+
+ {fetchingConfig && (!config || Object.keys(config).length === 0) ? (
+
Fetching config...
+ ) : (
+
+
+ typeof value === 'object' && value !== null && Object.keys(value).length === 0}
+ data={editedConfig}
+ onUpdate={({ newData }) => setEditedConfig(newData as Record)}
+ theme={githubDarkTheme}
+ minWidth="100%"
+ />
+
+
+
+
+
+
+ )}
+
+
+ );
+};
+
+export default ConfigModal;
diff --git a/src/components/node-details/eligibility.module.css b/src/components/node-details/eligibility.module.css
new file mode 100644
index 00000000..2a3b6b18
--- /dev/null
+++ b/src/components/node-details/eligibility.module.css
@@ -0,0 +1,22 @@
+.root {
+ align-items: center;
+ display: grid;
+ gap: 8px;
+ grid-template-columns: auto 1fr;
+
+ .icon {
+ color: var(--text-secondary);
+ font-size: 28px;
+ }
+
+ .content {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ grid-column: 2 / span 1;
+ }
+
+ .button {
+ align-self: end;
+ }
+}
diff --git a/src/components/node-details/eligibility.tsx b/src/components/node-details/eligibility.tsx
new file mode 100644
index 00000000..5d502c3a
--- /dev/null
+++ b/src/components/node-details/eligibility.tsx
@@ -0,0 +1,52 @@
+import Card from '@/components/card/card';
+import { NodeBanInfo, NodeEligibility } from '@/types/nodes';
+import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
+import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
+import HighlightOffIcon from '@mui/icons-material/HighlightOff';
+import styles from './eligibility.module.css';
+
+type EligibilityProps = {
+ eligibility: NodeEligibility;
+ eligibilityCauseStr?: string;
+ banInfo?: NodeBanInfo;
+};
+
+const Eligibility = ({ eligibility, eligibilityCauseStr, banInfo }: EligibilityProps) => {
+ switch (eligibility) {
+ case NodeEligibility.ELIGIBLE:
+ return (
+
+
+ Eligible
+ This node is active and can receive rewards
+
+ );
+ case NodeEligibility.NON_ELIGIBLE:
+ return (
+
+
+ Not eligible
+
+ This node is active, but does not meet the criteria for receiving rewards
+ Reason: {eligibilityCauseStr ?? '-'}
+
+
+ );
+ case NodeEligibility.BANNED:
+ return (
+
+
+ Banned
+
+
+ This node is excluded from all operations and rewards
+
+ Reason: {banInfo?.reason ?? 'Unknown'}
+
+
+
+ );
+ }
+};
+
+export default Eligibility;
diff --git a/src/components/node-details/environments.module.css b/src/components/node-details/environments.module.css
new file mode 100644
index 00000000..c8136595
--- /dev/null
+++ b/src/components/node-details/environments.module.css
@@ -0,0 +1,5 @@
+.list {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
diff --git a/src/components/node-details/environments.tsx b/src/components/node-details/environments.tsx
new file mode 100644
index 00000000..0cf5f28e
--- /dev/null
+++ b/src/components/node-details/environments.tsx
@@ -0,0 +1,28 @@
+import Card from '@/components/card/card';
+import EnvironmentCard from '@/components/environment-card/environment-card';
+import { useP2P } from '@/contexts/P2PContext';
+import { EnvNodeInfo } from '@/types/environments';
+import styles from './environments.module.css';
+
+type EnvironmentsProps = {
+ nodeInfo: EnvNodeInfo;
+};
+
+const Environments = ({ nodeInfo }: EnvironmentsProps) => {
+ const { isReady, envs } = useP2P();
+
+ return (
+
+ Environments
+
+ {!isReady ? (
+
Fetching data...
+ ) : (
+ envs.map((env) =>
)
+ )}
+
+
+ );
+};
+
+export default Environments;
diff --git a/src/components/node-details/gas-fee-modal.tsx b/src/components/node-details/gas-fee-modal.tsx
new file mode 100644
index 00000000..e87c76bb
--- /dev/null
+++ b/src/components/node-details/gas-fee-modal.tsx
@@ -0,0 +1,90 @@
+import Button from '@/components/button/button';
+import Input from '@/components/input/input';
+import Modal from '@/components/modal/modal';
+import { ETH_SEPOLIA_ADDRESS } from '@/constants/tokens';
+import { useDepositTokens, UseDepositTokensReturn } from '@/lib/use-deposit-tokens';
+import { useFormik } from 'formik';
+import * as Yup from 'yup';
+
+type GasFeeModalProps = {
+ isOpen: boolean;
+ onClose: () => void;
+};
+
+type GasFeeModalFormValues = {
+ amount: number | '';
+};
+
+const GasFeeModalContent = ({
+ depositTokens,
+ onClose,
+}: Pick & { depositTokens: UseDepositTokensReturn }) => {
+ const formik = useFormik({
+ initialValues: {
+ amount: '',
+ },
+ onSubmit: (values) => {
+ if (values.amount !== '') {
+ depositTokens.handleDeposit({
+ tokenAddress: ETH_SEPOLIA_ADDRESS,
+ amount: values.amount.toString(),
+ });
+ }
+ },
+ validationSchema: Yup.object({
+ amount: Yup.number().required('Amount is required').min(0, 'Amount must be greater than 0'),
+ // .not(0, 'Amount must be greater than 0'),
+ }),
+ });
+
+ return (
+
+ );
+};
+
+const GasFeeModal = ({ isOpen, onClose }: GasFeeModalProps) => {
+ const depositTokens = useDepositTokens({
+ onSuccess: onClose,
+ });
+ return (
+
+
+
+ );
+};
+
+export default GasFeeModal;
diff --git a/src/components/node-details/jobs-revenue-stats.module.css b/src/components/node-details/jobs-revenue-stats.module.css
new file mode 100644
index 00000000..3c160e78
--- /dev/null
+++ b/src/components/node-details/jobs-revenue-stats.module.css
@@ -0,0 +1,5 @@
+.root {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 48px;
+}
diff --git a/src/components/node-details/jobs-revenue-stats.tsx b/src/components/node-details/jobs-revenue-stats.tsx
new file mode 100644
index 00000000..67ca3b1e
--- /dev/null
+++ b/src/components/node-details/jobs-revenue-stats.tsx
@@ -0,0 +1,79 @@
+import Card from '@/components/card/card';
+import { ChartTypeEnum } from '@/components/chart/chart-type';
+import Gauge from '@/components/chart/gauge';
+import VBarChart from '@/components/chart/v-bar-chart';
+import { useNodesContext } from '@/context/nodes-context';
+import { useP2P } from '@/contexts/P2PContext';
+import { formatNumber } from '@/utils/formatters';
+import { useEffect, useMemo } from 'react';
+import styles from './jobs-revenue-stats.module.css';
+
+const JobsRevenueStats = () => {
+ const {
+ benchmarkValues,
+ jobsPerEpoch,
+ revenuePerEpoch,
+ totalJobs,
+ totalRevenue,
+ fetchNodeBenchmarkMinMaxLast,
+ fetchNodeStats,
+ } = useNodesContext();
+ const { envs } = useP2P();
+
+ useEffect(() => {
+ fetchNodeStats();
+ }, [fetchNodeStats]);
+
+ useEffect(() => {
+ fetchNodeBenchmarkMinMaxLast();
+ }, [fetchNodeBenchmarkMinMaxLast]);
+
+ const runningAndTotalJobs = useMemo(() => {
+ let totalRunningJobs = 0;
+ let totalJobs = 0;
+ for (const env of envs) {
+ totalRunningJobs += (env.runningJobs ?? 0) + (env.runningFreeJobs ?? 0);
+ totalJobs += (env.queuedJobs ?? 0) + totalRunningJobs;
+ }
+
+ return [totalJobs, totalRunningJobs];
+ }, [envs]);
+
+ return (
+
+
+
+
+
+
+ );
+};
+
+export default JobsRevenueStats;
diff --git a/src/components/node-details/node-details-page.tsx b/src/components/node-details/node-details-page.tsx
new file mode 100644
index 00000000..db2afd1e
--- /dev/null
+++ b/src/components/node-details/node-details-page.tsx
@@ -0,0 +1,71 @@
+import Container from '@/components/container/container';
+import BenchmarkJobs from '@/components/node-details/benchmark-jobs';
+import Environments from '@/components/node-details/environments';
+import JobsRevenueStats from '@/components/node-details/jobs-revenue-stats';
+import NodeInfo from '@/components/node-details/node-info';
+import UnbanRequests from '@/components/node-details/unban-requests';
+import SectionTitle from '@/components/section-title/section-title';
+import { useNodesContext } from '@/context/nodes-context';
+import { useUnbanRequestsContext } from '@/context/unban-requests-context';
+import { useP2P } from '@/contexts/P2PContext';
+import { useParams } from 'next/navigation';
+import { useEffect } from 'react';
+
+const NodeDetailsPage = () => {
+ const { selectedNode, fetchNode } = useNodesContext();
+ const { isReady, getEnvs } = useP2P();
+ const { unbanRequests, fetchUnbanRequests } = useUnbanRequestsContext();
+ const params = useParams<{ nodeId: string }>();
+
+ useEffect(() => {
+ if (!selectedNode && params?.nodeId) {
+ fetchNode(params?.nodeId);
+ }
+ }, [selectedNode, params?.nodeId, fetchNode]);
+
+ useEffect(() => {
+ if (selectedNode?.id && isReady) {
+ getEnvs(selectedNode.id);
+ }
+ }, [selectedNode?.id, isReady, getEnvs]);
+
+ useEffect(() => {
+ if (selectedNode?.id) {
+ fetchUnbanRequests(selectedNode.id);
+ }
+ }, [selectedNode?.id, fetchUnbanRequests]);
+
+ if (!selectedNode) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+ {selectedNode.eligibilityCauseStr !== 'Banned' && unbanRequests?.length === 0 ? null : (
+
+ )}
+
+
+ );
+};
+
+export default NodeDetailsPage;
diff --git a/src/components/node-details/node-info.module.css b/src/components/node-details/node-info.module.css
new file mode 100644
index 00000000..000f6a96
--- /dev/null
+++ b/src/components/node-details/node-info.module.css
@@ -0,0 +1,63 @@
+.root {
+ display: flex;
+ flex-direction: column;
+ gap: 32px;
+
+ @media (min-width: 992px) {
+ display: grid;
+ grid-template-columns: 1fr 380px;
+ }
+
+ .infoWrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 32px;
+ justify-content: space-between;
+ min-width: 0;
+
+ .infoContent {
+ display: flex;
+ flex-direction: column;
+ gap: 32px;
+
+ .title {
+ font-size: 32px;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+
+ .grid {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ column-gap: 12px;
+ row-gap: 8px;
+ }
+
+ .buttons {
+ display: flex;
+ gap: 8px;
+ }
+ }
+
+ .infoFooter {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ }
+ }
+
+ .statusWrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ }
+}
+
+.hash {
+ color: var(--text-secondary);
+ font-size: 14px;
+}
+
+.icon {
+ color: var(--accent1);
+}
diff --git a/src/components/node-details/node-info.tsx b/src/components/node-details/node-info.tsx
new file mode 100644
index 00000000..216bb15e
--- /dev/null
+++ b/src/components/node-details/node-info.tsx
@@ -0,0 +1,184 @@
+import Button from '@/components/button/button';
+import Card from '@/components/card/card';
+import { Balance } from '@/components/node-details/balance';
+import Eligibility from '@/components/node-details/eligibility';
+import { useP2P } from '@/contexts/P2PContext';
+import { useOceanAccount } from '@/lib/use-ocean-account';
+import { Node, NodeEligibility } from '@/types/nodes';
+import { useAuthModal, useSignMessage, useSmartAccountClient } from '@account-kit/react';
+import DnsIcon from '@mui/icons-material/Dns';
+import LocationPinIcon from '@mui/icons-material/LocationPin';
+import PublicIcon from '@mui/icons-material/Public';
+import UploadIcon from '@mui/icons-material/Upload';
+import { useEffect, useMemo, useState } from 'react';
+import { toast } from 'react-toastify';
+import ConfigModal from './config-modal';
+import styles from './node-info.module.css';
+
+type NodeInfoProps = {
+ node: Node;
+};
+
+const NodeInfo = ({ node }: NodeInfoProps) => {
+ const { client } = useSmartAccountClient({ type: 'LightAccount' });
+ const { signMessageAsync } = useSignMessage({
+ client,
+ });
+ const { openAuthModal } = useAuthModal();
+ const { account, ocean } = useOceanAccount();
+ const { config, fetchConfig, pushConfig } = useP2P();
+
+ const [fetchingConfig, setFetchingConfig] = useState(false);
+ const [pushingConfig, setPushingConfig] = useState(false);
+ const [isEditConfigDialogOpen, setIsEditConfigDialogOpen] = useState(false);
+ const [editedConfig, setEditedConfig] = useState>({});
+
+ const isAdmin = useMemo(
+ () => node.allowedAdmins?.includes(account?.address as string),
+ [node.allowedAdmins, account]
+ );
+
+ useEffect(() => {
+ if (config) {
+ setEditedConfig(config);
+ }
+ }, [config]);
+
+ async function handleFetchConfig() {
+ if (!account.isConnected) {
+ openAuthModal();
+ return;
+ }
+ if (!ocean || !node?.id) {
+ return;
+ }
+ const timestamp = Date.now() + 5 * 60 * 1000; // 5 minutes expiry
+ const signedMessage = await signMessageAsync({
+ message: timestamp.toString(),
+ });
+
+ setFetchingConfig(true);
+ try {
+ await fetchConfig(node.id, signedMessage, timestamp, account.address as string);
+ } catch (error) {
+ console.error('Error fetching node config :', error);
+ } finally {
+ setFetchingConfig(false);
+ }
+ }
+
+ async function handlePushConfig(config: Record) {
+ let success = false;
+ if (!account.isConnected) {
+ openAuthModal();
+ return;
+ }
+ if (!ocean || !node?.id) {
+ return;
+ }
+ const timestamp = Date.now() + 5 * 60 * 1000; // 5 minutes expiry
+ const signedMessage = await signMessageAsync({
+ message: timestamp.toString(),
+ });
+
+ setPushingConfig(true);
+ try {
+ await pushConfig(node.id, signedMessage, timestamp, config, account.address as string);
+ success = true;
+ } catch (error) {
+ console.error('Error pushing node config :', error);
+ } finally {
+ setPushingConfig(false);
+ if (success) {
+ toast.success('Successfully pushed new config!');
+ setIsEditConfigDialogOpen(false);
+ } else {
+ toast.error('Failed to push new config');
+ }
+ }
+ }
+
+ function handleOpenEditConfigModal() {
+ if (!config || Object.keys(config).length === 0) {
+ handleFetchConfig();
+ }
+
+ setIsEditConfigDialogOpen(true);
+ }
+
+ function handleCloseModal() {
+ setIsEditConfigDialogOpen(false);
+ }
+
+ return (
+
+
+
+
+
{node.friendlyName ?? node.id}
+
{node.id}
+
+
+
+
{`${node.location?.ip} / ${node.ipAndDns?.dns}`}
+ {
+ <>
+
+ {node.platform?.osType ?
{node.platform?.osType}
:
Unknown
}
+ >
+ }
+
+
+
+ {node.location?.city}, {node.location?.country}
+
+
+ {isAdmin ? (
+
+
+ } onClick={handleOpenEditConfigModal} variant="outlined">
+ Edit node config
+
+
+ ) : null}
+
+
+
+
Admins:
+ {node.allowedAdmins?.map((admin) => (
+
+ {admin}
+
+ ))}
+
+
{node.version && Ocean Node v{node.version}}
+
+
+
+
+
+
+
+ );
+};
+
+export default NodeInfo;
diff --git a/src/components/Toolbar/style.module.css b/src/components/node-details/unban-requests.module.css
similarity index 59%
rename from src/components/Toolbar/style.module.css
rename to src/components/node-details/unban-requests.module.css
index ba6c7770..e0fea914 100644
--- a/src/components/Toolbar/style.module.css
+++ b/src/components/node-details/unban-requests.module.css
@@ -1,12 +1,7 @@
-.root {
+.header {
+ align-items: center;
display: flex;
flex-direction: row;
- align-items: center;
+ gap: 16px;
justify-content: space-between;
- padding: 8px;
-}
-
-.buttons {
- display: flex;
- gap: 50px;
}
diff --git a/src/components/node-details/unban-requests.tsx b/src/components/node-details/unban-requests.tsx
new file mode 100644
index 00000000..15a4b3e9
--- /dev/null
+++ b/src/components/node-details/unban-requests.tsx
@@ -0,0 +1,72 @@
+import Button from '@/components/button/button';
+import Card from '@/components/card/card';
+import { Table } from '@/components/table/table';
+import { TableTypeEnum } from '@/components/table/table-type';
+import { useUnbanRequestsContext } from '@/context/unban-requests-context';
+import { useOceanAccount } from '@/lib/use-ocean-account';
+import { Node } from '@/types';
+import { UnbanRequest } from '@/types/unban-requests';
+import { useAuthModal, useSignMessage, useSmartAccountClient } from '@account-kit/react';
+import { useMemo, useState } from 'react';
+import styles from './unban-requests.module.css';
+
+type UnbanRequestsProps = {
+ node: Node;
+};
+
+const UnbanRequests = ({ node }: UnbanRequestsProps) => {
+ const { client } = useSmartAccountClient({ type: 'LightAccount' });
+ const { signMessageAsync } = useSignMessage({
+ client,
+ });
+ const { openAuthModal } = useAuthModal();
+ const { account, ocean } = useOceanAccount();
+ const { unbanRequests, fetchUnbanRequests, requestNodeUnban } = useUnbanRequestsContext();
+
+ const [loading, setLoading] = useState(false);
+
+ const isAdmin = useMemo(
+ () => node.allowedAdmins?.includes(account?.address as string),
+ [node.allowedAdmins, account]
+ );
+
+ const handleRequestUnban = async () => {
+ if (!account.isConnected) {
+ openAuthModal();
+ return;
+ }
+ if (!ocean || !node?.id) {
+ return;
+ }
+ setLoading(true);
+ try {
+ const timestamp = Date.now() + 5 * 60 * 1000; // 5 minutes expiry
+ const signedMessage = await signMessageAsync({
+ message: timestamp.toString(),
+ });
+
+ await requestNodeUnban(node.id, signedMessage as string, timestamp, account.address as string);
+ await fetchUnbanRequests(node.id);
+ } catch (error) {
+ console.error('Error requesting unban:', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
Unban requests
+ {isAdmin && (
+
+ )}
+
+ data={unbanRequests} paginationType="none" tableType={TableTypeEnum.UNBAN_REQUESTS} />
+
+ );
+};
+
+export default UnbanRequests;
diff --git a/src/components/node-details/withdraw-modal.tsx b/src/components/node-details/withdraw-modal.tsx
new file mode 100644
index 00000000..5ae246e9
--- /dev/null
+++ b/src/components/node-details/withdraw-modal.tsx
@@ -0,0 +1,187 @@
+import Button from '@/components/button/button';
+import Input from '@/components/input/input';
+import Select from '@/components/input/select';
+import Modal from '@/components/modal/modal';
+import { useWithdrawTokens, UseWithdrawTokensReturn } from '@/lib/use-withdraw-tokens';
+import { NodeBalance } from '@/types/nodes';
+import { useFormik } from 'formik';
+import * as Yup from 'yup';
+import styles from './balance.module.css';
+
+interface WithdrawModalProps {
+ balances: NodeBalance[];
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+type WithdrawModalFormValues = {
+ amounts: Record;
+ tokens: string[];
+};
+
+const WithdrawModalContent = ({
+ balances,
+ onClose,
+ withdrawTokens,
+}: WithdrawModalProps & { withdrawTokens: UseWithdrawTokensReturn }) => {
+ const formik = useFormik({
+ initialValues: {
+ amounts: {},
+ tokens: [],
+ },
+ onSubmit: (values) => {
+ const tokenAddresses = values.tokens
+ .map((token) => balances.find((b) => b.token === token)?.address)
+ .filter((addr): addr is string => !!addr);
+ const amounts = values.tokens.map((token) => values.amounts[token] ?? '0');
+ if (tokenAddresses.length > 0 && amounts.every((amt) => parseFloat(amt) > 0)) {
+ withdrawTokens.handleWithdraw({
+ tokenAddresses,
+ amounts,
+ });
+ }
+ },
+ validationSchema: Yup.object({
+ tokens: Yup.array().min(1, 'Select at least one token'),
+ amounts: Yup.object().test(
+ 'amounts-validation',
+ 'Invalid amounts',
+ (amounts: Record, context) => {
+ const tokens = (context.parent.tokens as string[]) || [];
+ if (tokens.length === 0) {
+ return true;
+ }
+ const errors: Yup.ValidationError[] = [];
+ for (const token of tokens) {
+ const amount = amounts?.[token];
+ if (!amount || isNaN(Number(amount)) || Number(amount) <= 0) {
+ errors.push(
+ context.createError({
+ path: `amounts.${token}`,
+ message: `${token} amount required`,
+ })
+ );
+ }
+ }
+ if (errors.length > 0) {
+ return new Yup.ValidationError(errors, amounts, 'amounts');
+ }
+ return true;
+ }
+ ),
+ }),
+ });
+
+ const setMaxAmount = (token: string) => {
+ const balance = balances.find((b) => b.token === token)?.amount;
+ if (balance) {
+ formik.setFieldValue(`amounts.${token}`, balance);
+ }
+ };
+
+ /**
+ * Handles token selection change
+ * When a token is removed from the selection, the corresponding amount is also removed
+ */
+ const handleTokensChange = (newTokens: string[]) => {
+ formik.setFieldValue('tokens', newTokens);
+ const newAmounts = { ...formik.values.amounts };
+ // Remove amounts for deselected tokens
+ Object.keys(newAmounts).forEach((token) => {
+ if (!newTokens.includes(token)) {
+ newAmounts[token] = '';
+ }
+ });
+ // Initialize amounts for newly selected tokens
+ newTokens.forEach((token) => {
+ if (!(token in newAmounts)) {
+ newAmounts[token] = '';
+ }
+ });
+ formik.setFieldValue('amounts', newAmounts);
+ };
+
+ return (
+
+ );
+};
+
+const WithdrawModal = ({ balances, isOpen, onClose }: WithdrawModalProps) => {
+ const withdrawTokens = useWithdrawTokens({
+ onSuccess: onClose,
+ });
+ return (
+
+
+
+ );
+};
+
+export default WithdrawModal;
diff --git a/src/components/profile/consumer-jobs.tsx b/src/components/profile/consumer-jobs.tsx
new file mode 100644
index 00000000..9b34e451
--- /dev/null
+++ b/src/components/profile/consumer-jobs.tsx
@@ -0,0 +1,22 @@
+import Card from '@/components/card/card';
+import { Table } from '@/components/table/table';
+import { TableTypeEnum } from '@/components/table/table-type';
+import { useMyJobsTableContext } from '@/context/table/my-jobs-table-context';
+import { Job } from '@/types/jobs';
+
+const ConsumerJobs = () => {
+ return (
+
+ My jobs
+
+ context={useMyJobsTableContext()}
+ autoHeight
+ showToolbar
+ paginationType="context"
+ tableType={TableTypeEnum.MY_JOBS}
+ />
+
+ );
+};
+
+export default ConsumerJobs;
diff --git a/src/components/profile/consumer-stats.module.css b/src/components/profile/consumer-stats.module.css
new file mode 100644
index 00000000..3c160e78
--- /dev/null
+++ b/src/components/profile/consumer-stats.module.css
@@ -0,0 +1,5 @@
+.root {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 48px;
+}
diff --git a/src/components/profile/consumer-stats.tsx b/src/components/profile/consumer-stats.tsx
new file mode 100644
index 00000000..746f1097
--- /dev/null
+++ b/src/components/profile/consumer-stats.tsx
@@ -0,0 +1,65 @@
+import Card from '@/components/card/card';
+import { ChartTypeEnum } from '@/components/chart/chart-type';
+import Gauge from '@/components/chart/gauge';
+import VBarChart from '@/components/chart/v-bar-chart';
+import { useProfileContext } from '@/context/profile-context';
+import { formatNumber } from '@/utils/formatters';
+import { useEffect } from 'react';
+import styles from './consumer-stats.module.css';
+
+const ConsumerStats = () => {
+ const {
+ totalJobs,
+ totalPaidAmount,
+ consumerStatsPerEpoch,
+ successfullJobs,
+ fetchConsumerStats,
+ fetchJobsSuccessRate,
+ } = useProfileContext();
+
+ useEffect(() => {
+ fetchConsumerStats();
+ }, [fetchConsumerStats]);
+
+ useEffect(() => {
+ fetchJobsSuccessRate();
+ }, [fetchJobsSuccessRate]);
+
+ return (
+
+
+
+ 0 ? Number(((successfullJobs / totalJobs) * 100).toFixed(1)) : 0}
+ valueSuffix="%"
+ />
+
+ );
+};
+
+export default ConsumerStats;
diff --git a/src/components/profile/consumer.profile-page.tsx b/src/components/profile/consumer.profile-page.tsx
new file mode 100644
index 00000000..cb563d49
--- /dev/null
+++ b/src/components/profile/consumer.profile-page.tsx
@@ -0,0 +1,24 @@
+import Container from '@/components/container/container';
+import ConsumerJobs from '@/components/profile/consumer-jobs';
+import ConsumerStats from '@/components/profile/consumer-stats';
+import ProfileHeader from '@/components/profile/profile-header';
+import SectionTitle from '@/components/section-title/section-title';
+
+const ConsumerProfilePage = () => {
+ return (
+
+
+
+
+ );
+};
+
+export default ConsumerProfilePage;
diff --git a/src/components/profile/owner-nodes.module.css b/src/components/profile/owner-nodes.module.css
new file mode 100644
index 00000000..e0fea914
--- /dev/null
+++ b/src/components/profile/owner-nodes.module.css
@@ -0,0 +1,7 @@
+.header {
+ align-items: center;
+ display: flex;
+ flex-direction: row;
+ gap: 16px;
+ justify-content: space-between;
+}
diff --git a/src/components/profile/owner-nodes.tsx b/src/components/profile/owner-nodes.tsx
new file mode 100644
index 00000000..dc6cee84
--- /dev/null
+++ b/src/components/profile/owner-nodes.tsx
@@ -0,0 +1,29 @@
+import Button from '@/components/button/button';
+import Card from '@/components/card/card';
+import { Table } from '@/components/table/table';
+import { TableTypeEnum } from '@/components/table/table-type';
+import { useMyNodesTableContext } from '@/context/table/my-nodes-table-context';
+import { AnyNode } from '@/types/nodes';
+import styles from './owner-nodes.module.css';
+
+const OwnerNodes = () => {
+ return (
+
+
+
My nodes
+
+
+
+ context={useMyNodesTableContext()}
+ autoHeight
+ paginationType="context"
+ showToolbar
+ tableType={TableTypeEnum.MY_NODES}
+ />
+
+ );
+};
+
+export default OwnerNodes;
diff --git a/src/components/profile/owner-profile-page.tsx b/src/components/profile/owner-profile-page.tsx
new file mode 100644
index 00000000..b938ecb8
--- /dev/null
+++ b/src/components/profile/owner-profile-page.tsx
@@ -0,0 +1,24 @@
+import Container from '@/components/container/container';
+import OwnerNodes from '@/components/profile/owner-nodes';
+import OwnerStats from '@/components/profile/owner-stats';
+import ProfileHeader from '@/components/profile/profile-header';
+import SectionTitle from '@/components/section-title/section-title';
+
+const OwnerProfilePage = () => {
+ return (
+
+
+
+
+ );
+};
+
+export default OwnerProfilePage;
diff --git a/src/components/profile/owner-stats.module.css b/src/components/profile/owner-stats.module.css
new file mode 100644
index 00000000..3c160e78
--- /dev/null
+++ b/src/components/profile/owner-stats.module.css
@@ -0,0 +1,5 @@
+.root {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 48px;
+}
diff --git a/src/components/profile/owner-stats.tsx b/src/components/profile/owner-stats.tsx
new file mode 100644
index 00000000..bcf3f36a
--- /dev/null
+++ b/src/components/profile/owner-stats.tsx
@@ -0,0 +1,68 @@
+import Card from '@/components/card/card';
+import { ChartTypeEnum } from '@/components/chart/chart-type';
+import Gauge from '@/components/chart/gauge';
+import VBarChart from '@/components/chart/v-bar-chart';
+import { useProfileContext } from '@/context/profile-context';
+import { formatNumber } from '@/utils/formatters';
+import { useEffect } from 'react';
+import styles from './owner-stats.module.css';
+
+const OwnerStats = () => {
+ const {
+ totalNetworkRevenue,
+ totalBenchmarkRevenue,
+ totalNetworkJobs,
+ totalBenchmarkJobs,
+ ownerStatsPerEpoch,
+ eligibleNodes,
+ totalNodes,
+ fetchOwnerStats,
+ fetchActiveNodes,
+ } = useProfileContext();
+
+ useEffect(() => {
+ fetchActiveNodes();
+ }, [fetchActiveNodes]);
+
+ useEffect(() => {
+ fetchOwnerStats();
+ }, [fetchOwnerStats]);
+
+ return (
+
+
+
+ 0 ? Number(((eligibleNodes / totalNodes) * 100).toFixed(1)) : 0}
+ valueSuffix="%"
+ />
+
+ );
+};
+
+export default OwnerStats;
diff --git a/src/components/profile/profile-header.module.css b/src/components/profile/profile-header.module.css
new file mode 100644
index 00000000..a7fd0a59
--- /dev/null
+++ b/src/components/profile/profile-header.module.css
@@ -0,0 +1,21 @@
+.tabBar {
+ align-self: center;
+}
+
+.root {
+ align-items: center;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ gap: 24px;
+
+ .name {
+ font-size: 28px;
+ }
+
+ .address {
+ font-size: 14px;
+ color: var(--text-secondary);
+ word-break: break-word;
+ }
+}
diff --git a/src/components/profile/profile-header.tsx b/src/components/profile/profile-header.tsx
new file mode 100644
index 00000000..37de3577
--- /dev/null
+++ b/src/components/profile/profile-header.tsx
@@ -0,0 +1,59 @@
+import Avatar from '@/components/avatar/avatar';
+import Card from '@/components/card/card';
+import TabBar from '@/components/tab-bar/tab-bar';
+import { useProfileContext } from '@/context/profile-context';
+import { useOceanAccount } from '@/lib/use-ocean-account';
+import { formatWalletAddress } from '@/utils/formatters';
+import { useMemo } from 'react';
+import styles from './profile-header.module.css';
+
+type ProfileHeaderProps = {
+ role: 'owner' | 'consumer';
+};
+
+const ProfileHeader = ({ role }: ProfileHeaderProps) => {
+ const { account } = useOceanAccount();
+ const { ensName, ensProfile } = useProfileContext();
+
+ const accountName = useMemo(() => {
+ if (account.status === 'connected' && account.address) {
+ if (ensName) {
+ return ensName;
+ }
+ if (account.address) {
+ return formatWalletAddress(account.address);
+ }
+ }
+ return 'Not connected';
+ }, [account, ensName]);
+
+ return (
+ <>
+
+
+ {account.address ? : null}
+
+
{accountName}
+
{account?.address}
+
+
+ >
+ );
+};
+
+export default ProfileHeader;
diff --git a/src/components/progress-bar/progress-bar.module.css b/src/components/progress-bar/progress-bar.module.css
new file mode 100644
index 00000000..91c31df1
--- /dev/null
+++ b/src/components/progress-bar/progress-bar.module.css
@@ -0,0 +1,14 @@
+.root {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+
+ .row {
+ align-items: center;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ gap: 8px;
+ }
+}
diff --git a/src/components/progress-bar/progress-bar.tsx b/src/components/progress-bar/progress-bar.tsx
new file mode 100644
index 00000000..2ec6e4ec
--- /dev/null
+++ b/src/components/progress-bar/progress-bar.tsx
@@ -0,0 +1,51 @@
+import { LinearProgress, styled } from '@mui/material';
+import classNames from 'classnames';
+import styles from './progress-bar.module.css';
+
+const StyledLinearProgress = styled(LinearProgress)({
+ background: 'var(--background-glass)',
+ border: '2px solid var(--background-glass)',
+ borderRadius: 8,
+ height: 16,
+ '& .MuiLinearProgress-bar': {
+ borderRadius: 8,
+ },
+});
+
+type ProgressBarProps = {
+ className?: string;
+ topLeftContent?: React.ReactNode;
+ topRightContent?: React.ReactNode;
+ bottomLeftContent?: React.ReactNode;
+ bottomRightContent?: React.ReactNode;
+ value: number;
+};
+
+const ProgressBar = ({
+ className,
+ topLeftContent,
+ topRightContent,
+ bottomLeftContent,
+ bottomRightContent,
+ value,
+}: ProgressBarProps) => {
+ return (
+
+ {topLeftContent || topRightContent ? (
+
+
{topLeftContent}
+
{topRightContent}
+
+ ) : null}
+
+ {bottomLeftContent || bottomRightContent ? (
+
+
{bottomLeftContent}
+
{bottomRightContent}
+
+ ) : null}
+
+ );
+};
+
+export default ProgressBar;
diff --git a/src/components/run-job/environments-page.tsx b/src/components/run-job/environments-page.tsx
new file mode 100644
index 00000000..ee854355
--- /dev/null
+++ b/src/components/run-job/environments-page.tsx
@@ -0,0 +1,23 @@
+import Container from '@/components/container/container';
+import SelectEnvironment from '@/components/run-job/select-environment';
+import SectionTitle from '@/components/section-title/section-title';
+import { getRunJobSteps, RunJobStep } from '@/components/stepper/get-steps';
+import Stepper from '@/components/stepper/stepper';
+
+const EnvironmentsPage = () => {
+ return (
+
+ currentStep="environment" steps={getRunJobSteps(false)} />}
+ />
+
+
+
+
+ );
+};
+
+export default EnvironmentsPage;
diff --git a/src/components/run-job/payment-authorize.module.css b/src/components/run-job/payment-authorize.module.css
new file mode 100644
index 00000000..0f0c75b5
--- /dev/null
+++ b/src/components/run-job/payment-authorize.module.css
@@ -0,0 +1,21 @@
+.root {
+ display: flex;
+ flex-direction: column;
+ gap: 32px;
+
+ .inputs {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+
+ @media (min-width: 576px) {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ @media (min-width: 992px) {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ }
+ }
+}
diff --git a/src/components/run-job/payment-authorize.tsx b/src/components/run-job/payment-authorize.tsx
new file mode 100644
index 00000000..55cadd77
--- /dev/null
+++ b/src/components/run-job/payment-authorize.tsx
@@ -0,0 +1,116 @@
+import Button from '@/components/button/button';
+import Input from '@/components/input/input';
+import { SelectedToken } from '@/context/run-job-context';
+import { useAuthorizeTokens } from '@/lib/use-authorize-tokens';
+import { ComputeEnvironment, EnvResourcesSelection } from '@/types/environments';
+import { useFormik } from 'formik';
+import * as Yup from 'yup';
+import styles from './payment-authorize.module.css';
+
+type AuthorizeFormValues = {
+ // amountToAuthorize: number;
+ maxLockedAmount: number;
+ maxLockCount: number;
+ maxLockSeconds: number;
+};
+
+type PaymentAuthorizeProps = {
+ // authorizations: any;
+ loadPaymentInfo: () => void;
+ selectedEnv: ComputeEnvironment;
+ selectedResources: EnvResourcesSelection;
+ selectedToken: SelectedToken;
+ totalCost: number;
+};
+
+const PaymentAuthorize = ({
+ // authorizations,
+ loadPaymentInfo,
+ selectedEnv,
+ selectedResources,
+ selectedToken,
+ totalCost,
+}: PaymentAuthorizeProps) => {
+ const { handleAuthorize, isAuthorizing } = useAuthorizeTokens({ onSuccess: loadPaymentInfo });
+
+ const formik = useFormik({
+ enableReinitialize: true,
+ initialValues: {
+ // amountToAuthorize: totalCost - (authorizations?.currentLockedAmount ?? 0),
+ maxLockedAmount: totalCost,
+ maxLockCount: 10,
+ maxLockSeconds: selectedResources.maxJobDurationHours * 60 * 60,
+ },
+ onSubmit: async (values) => {
+ handleAuthorize({
+ tokenAddress: selectedToken.address,
+ spender: selectedEnv.consumerAddress,
+ maxLockedAmount: values.maxLockedAmount.toString(),
+ maxLockSeconds: values.maxLockSeconds.toString(),
+ maxLockCount: values.maxLockCount.toString(),
+ });
+ },
+ validateOnMount: true,
+ validationSchema: Yup.object({}),
+ });
+
+ return (
+
+ );
+};
+
+export default PaymentAuthorize;
diff --git a/src/components/run-job/payment-deposit.module.css b/src/components/run-job/payment-deposit.module.css
new file mode 100644
index 00000000..a8bfd788
--- /dev/null
+++ b/src/components/run-job/payment-deposit.module.css
@@ -0,0 +1,5 @@
+.root {
+ display: flex;
+ flex-direction: column;
+ gap: 32px;
+}
diff --git a/src/components/run-job/payment-deposit.tsx b/src/components/run-job/payment-deposit.tsx
new file mode 100644
index 00000000..d1a78ea6
--- /dev/null
+++ b/src/components/run-job/payment-deposit.tsx
@@ -0,0 +1,59 @@
+import Button from '@/components/button/button';
+import Input from '@/components/input/input';
+import { SelectedToken } from '@/context/run-job-context';
+import { useDepositTokens } from '@/lib/use-deposit-tokens';
+import { useFormik } from 'formik';
+import * as Yup from 'yup';
+import styles from './payment-deposit.module.css';
+
+type DepositFormValues = {
+ amount: number;
+};
+
+type PaymentDepositProps = {
+ escrowBalance: number;
+ loadPaymentInfo: () => void;
+ selectedToken: SelectedToken;
+ totalCost: number;
+};
+
+const PaymentDeposit = ({ escrowBalance, loadPaymentInfo, selectedToken, totalCost }: PaymentDepositProps) => {
+ const { handleDeposit, isDepositing } = useDepositTokens({ onSuccess: loadPaymentInfo });
+
+ const formik = useFormik({
+ enableReinitialize: true,
+ initialValues: {
+ amount: totalCost - escrowBalance,
+ },
+ onSubmit: async (values) => {
+ handleDeposit({
+ tokenAddress: selectedToken.address,
+ amount: values.amount.toString(),
+ });
+ },
+ validateOnMount: true,
+ validationSchema: Yup.object({
+ amount: Yup.number().required('Required').min(0, 'Invalid amount'),
+ }),
+ });
+
+ return (
+
+ );
+};
+
+export default PaymentDeposit;
diff --git a/src/components/run-job/payment-page.tsx b/src/components/run-job/payment-page.tsx
new file mode 100644
index 00000000..ca075719
--- /dev/null
+++ b/src/components/run-job/payment-page.tsx
@@ -0,0 +1,43 @@
+import Container from '@/components/container/container';
+import Payment from '@/components/run-job/payment';
+import SectionTitle from '@/components/section-title/section-title';
+import { getRunJobSteps, RunJobStep } from '@/components/stepper/get-steps';
+import Stepper from '@/components/stepper/stepper';
+import { useRunJobContext } from '@/context/run-job-context';
+import { useRouter } from 'next/router';
+import { useEffect } from 'react';
+
+const PaymentPage = () => {
+ const router = useRouter();
+
+ const { estimatedTotalCost, freeCompute, selectedEnv, selectedResources, selectedToken } = useRunJobContext();
+
+ useEffect(() => {
+ if (!selectedToken) {
+ router.replace('/run-job/environments');
+ }
+ }, [router, selectedToken]);
+
+ return (
+
+ currentStep="payment" steps={getRunJobSteps(freeCompute)} />}
+ />
+ {selectedEnv && selectedResources && selectedToken ? (
+
+ ) : null}
+
+ );
+};
+
+export default PaymentPage;
diff --git a/src/components/run-job/payment-summary.module.css b/src/components/run-job/payment-summary.module.css
new file mode 100644
index 00000000..1e4ebf09
--- /dev/null
+++ b/src/components/run-job/payment-summary.module.css
@@ -0,0 +1,45 @@
+.cost {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 12px 24px;
+
+ @media (min-width: 768px) {
+ align-items: center;
+ display: grid;
+ grid-template-columns: 1fr auto;
+ }
+
+ .valueWithChip {
+ align-items: end;
+ display: flex;
+ flex-direction: column;
+ }
+
+ .values {
+ text-align: right;
+
+ .token {
+ color: var(--text-secondary);
+ font-size: 14px;
+ }
+
+ .amount {
+ font-size: 32px;
+ font-weight: 700;
+ }
+ }
+
+ .sm {
+ color: var(--text-secondary);
+ font-size: 16px;
+
+ .token {
+ font-size: 12px;
+ }
+
+ .amount {
+ font-size: 20px;
+ }
+ }
+}
diff --git a/src/components/run-job/payment-summary.tsx b/src/components/run-job/payment-summary.tsx
new file mode 100644
index 00000000..3d5ebc49
--- /dev/null
+++ b/src/components/run-job/payment-summary.tsx
@@ -0,0 +1,78 @@
+import Card from '@/components/card/card';
+import { Authorizations } from '@/types/payment';
+import { formatNumber } from '@/utils/formatters';
+import classNames from 'classnames';
+import styles from './payment-summary.module.css';
+
+type PaymentSummaryProps = {
+ authorizations: Authorizations | null;
+ escrowBalance: number | null;
+ tokenSymbol: string;
+ totalCost: number;
+ walletBalance: number;
+};
+
+const PaymentSummary = ({
+ authorizations,
+ escrowBalance,
+ tokenSymbol,
+ totalCost,
+ walletBalance,
+}: PaymentSummaryProps) => {
+ const insufficientAutorized = (Number(authorizations?.maxLockedAmount) ?? 0) < totalCost;
+ const insufficientEscrow = escrowBalance !== null && escrowBalance < totalCost;
+
+ return (
+
+ {/* Estimated total cost */}
+ Estimated total cost
+
+ {tokenSymbol}
+
+ {formatNumber(totalCost)}
+
+ {/* User available funds in escrow */}
+ User available funds in escrow
+
+
+ {tokenSymbol}
+
+
+ {formatNumber(escrowBalance ?? 0)}
+
+
+ {insufficientEscrow ?
Insufficient funds
: null}
+
+ {/* Current locked amount */}
+ Current locked amount
+
+
+ {tokenSymbol}
+
+ {formatNumber(authorizations?.currentLockedAmount ?? 0)}
+
+
+ {/* Max locked amount */}
+ Max locked amount
+
+
+ {tokenSymbol}
+
+
+ {formatNumber(authorizations?.maxLockedAmount ?? 0)}
+
+
+ {insufficientAutorized ?
Insufficient allowance
: null}
+
+ {/* User available funds in wallet */}
+ User available funds in wallet
+
+ {tokenSymbol}
+
+ {formatNumber(walletBalance)}
+
+
+ );
+};
+
+export default PaymentSummary;
diff --git a/src/components/run-job/payment.tsx b/src/components/run-job/payment.tsx
new file mode 100644
index 00000000..ac45f390
--- /dev/null
+++ b/src/components/run-job/payment.tsx
@@ -0,0 +1,112 @@
+import Card from '@/components/card/card';
+import PaymentAuthorize from '@/components/run-job/payment-authorize';
+import PaymentDeposit from '@/components/run-job/payment-deposit';
+import PaymentSummary from '@/components/run-job/payment-summary';
+import { SelectedToken } from '@/context/run-job-context';
+import { useOceanAccount } from '@/lib/use-ocean-account';
+import { ComputeEnvironment, EnvResourcesSelection } from '@/types/environments';
+import { Authorizations } from '@/types/payment';
+import { CircularProgress } from '@mui/material';
+import { useRouter } from 'next/router';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+
+type PaymentProps = {
+ selectedEnv: ComputeEnvironment;
+ selectedResources: EnvResourcesSelection;
+ selectedToken: SelectedToken;
+ totalCost: number;
+};
+
+const Payment = ({ selectedEnv, selectedResources, selectedToken, totalCost }: PaymentProps) => {
+ const router = useRouter();
+
+ const { account, ocean } = useOceanAccount();
+
+ const [authorizations, setAuthorizations] = useState(null);
+ const [escrowBalance, setEscrowBalance] = useState(null);
+ const [walletBalance, setWalletBalance] = useState(null);
+
+ const [loadingAuthorizations, setLoadingAuthorizations] = useState(false);
+ const [loadingUserFunds, setLoadingUserFunds] = useState(false);
+
+ const step: 'authorize' | 'deposit' = useMemo(() => {
+ if ((escrowBalance ?? 0) >= totalCost) {
+ return 'authorize';
+ }
+ return 'deposit';
+ }, [escrowBalance, totalCost]);
+
+ const loadPaymentInfo = useCallback(() => {
+ if (ocean && account?.address) {
+ setLoadingAuthorizations(true);
+ ocean
+ .getAuthorizations(selectedToken.address, account.address, selectedEnv.consumerAddress)
+ .then((authorizations) => {
+ setAuthorizations(authorizations);
+ setLoadingAuthorizations(false);
+ });
+ ocean.getBalance(selectedToken.address, account.address).then((balance) => {
+ setWalletBalance(Number(balance));
+ });
+ setLoadingUserFunds(true);
+ ocean.getUserFunds(selectedToken.address, account.address).then((balance) => {
+ setEscrowBalance(Number(balance));
+ setLoadingUserFunds(false);
+ });
+ }
+ }, [ocean, account.address, selectedToken.address, selectedEnv.consumerAddress]);
+
+ useEffect(() => {
+ loadPaymentInfo();
+ }, [loadPaymentInfo]);
+
+ useEffect(() => {
+ const sufficientEscrow = (escrowBalance ?? 0) >= totalCost;
+ const suffficientAuthorized = (Number(authorizations?.maxLockedAmount) ?? 0) >= totalCost;
+ const enoughLockSeconds = (Number(authorizations?.maxLockSeconds) ?? 0) >= selectedResources.maxJobDurationHours;
+ if (sufficientEscrow && suffficientAuthorized && enoughLockSeconds) {
+ router.push('/run-job/summary');
+ }
+ }, [
+ authorizations?.maxLockSeconds,
+ authorizations?.maxLockedAmount,
+ escrowBalance,
+ router,
+ selectedResources.maxJobDurationHours,
+ totalCost,
+ ]);
+
+ return loadingAuthorizations || loadingUserFunds ? (
+
+ ) : (
+
+ Payment
+
+ {step === 'deposit' ? (
+
+ ) : step === 'authorize' ? (
+
+ ) : null}
+
+ );
+};
+
+export default Payment;
diff --git a/src/components/run-job/resources-page.tsx b/src/components/run-job/resources-page.tsx
new file mode 100644
index 00000000..7d83ea95
--- /dev/null
+++ b/src/components/run-job/resources-page.tsx
@@ -0,0 +1,38 @@
+import Container from '@/components/container/container';
+import SelectResources from '@/components/run-job/select-resources';
+import SectionTitle from '@/components/section-title/section-title';
+import { getRunJobSteps, RunJobStep } from '@/components/stepper/get-steps';
+import Stepper from '@/components/stepper/stepper';
+import { useRunJobContext } from '@/context/run-job-context';
+import { useRouter } from 'next/router';
+import { useEffect } from 'react';
+
+const ResourcesPage = () => {
+ const router = useRouter();
+
+ const { freeCompute, selectedEnv, selectedToken } = useRunJobContext();
+
+ useEffect(() => {
+ if (!selectedEnv || !selectedToken) {
+ router.replace('/run-job/environments');
+ }
+ }, [router, selectedEnv, selectedToken]);
+
+ return (
+
+ currentStep="resources" steps={getRunJobSteps(freeCompute)} />}
+ />
+ {selectedEnv && selectedToken ? (
+
+
+
+ ) : null}
+
+ );
+};
+
+export default ResourcesPage;
diff --git a/src/components/run-job/select-environment.module.css b/src/components/run-job/select-environment.module.css
new file mode 100644
index 00000000..b826e0a0
--- /dev/null
+++ b/src/components/run-job/select-environment.module.css
@@ -0,0 +1,40 @@
+.extraFilters {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+
+ @media (min-width: 576px) {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ @media (min-width: 992px) {
+ grid-template-columns: repeat(4, 1fr);
+ }
+}
+
+.footer {
+ align-items: end;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ gap: 16px;
+ justify-content: space-between;
+
+ .sortSelect {
+ min-width: 250px;
+ }
+
+ .buttons {
+ align-items: center;
+ display: flex;
+ flex-direction: row;
+ gap: 8px;
+ }
+}
+
+.list {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
diff --git a/src/components/run-job/select-environment.tsx b/src/components/run-job/select-environment.tsx
new file mode 100644
index 00000000..37384ae2
--- /dev/null
+++ b/src/components/run-job/select-environment.tsx
@@ -0,0 +1,200 @@
+import Button from '@/components/button/button';
+import Card from '@/components/card/card';
+import EnvironmentCard from '@/components/environment-card/environment-card';
+import GpuLabel from '@/components/gpu-label/gpu-label';
+import Input from '@/components/input/input';
+import Select from '@/components/input/select';
+import { RawFilters, useRunJobEnvsContext } from '@/context/run-job-envs-context';
+import FilterAltIcon from '@mui/icons-material/FilterAlt';
+import { Collapse } from '@mui/material';
+import { useFormik } from 'formik';
+import { useEffect, useMemo, useState } from 'react';
+import styles from './select-environment.module.css';
+
+const sortOptions = [
+ { label: 'No sorting', value: '' },
+ { label: 'Price ascending', value: JSON.stringify({ price: 'asc' }) },
+ { label: 'Price descending', value: JSON.stringify({ price: 'desc' }) },
+];
+
+type FilterFormValues = {
+ gpuName: string[];
+ fromMaxJobDuration: number | '';
+ minimumCPU: number | '';
+ minimumRAM: number | '';
+ minimumStorage: number | '';
+ feeToken: string;
+ sortBy: string;
+};
+
+const SelectEnvironment = () => {
+ const { fetchGpus, filters, gpus, loading, loadMoreEnvs, nodeEnvs, paginationResponse, setFilters, setSort, sort } =
+ useRunJobEnvsContext();
+
+ const [expanded, setExpanded] = useState(!!filters);
+
+ useEffect(() => {
+ fetchGpus();
+ }, [fetchGpus]);
+
+ const gpuOptions = useMemo(() => gpus.map((gpu) => ({ value: gpu.gpuName, label: gpu.gpuName })), [gpus]);
+
+ const formik = useFormik({
+ initialValues: {
+ gpuName: filters.gpuName ?? [],
+ fromMaxJobDuration: filters.fromMaxJobDuration ?? '',
+ minimumCPU: filters.minimumCPU ?? '',
+ minimumRAM: filters.minimumRAM ?? '',
+ minimumStorage: filters.minimumStorage ?? '',
+ feeToken: '',
+ sortBy: sort ?? '',
+ },
+ onSubmit: async (values) => {
+ const filters: RawFilters = { gpuName: values.gpuName };
+ if (values.fromMaxJobDuration !== '') {
+ filters.fromMaxJobDuration = Number(values.fromMaxJobDuration);
+ }
+ if (values.minimumCPU !== '') {
+ filters.minimumCPU = Number(values.minimumCPU);
+ }
+ if (values.minimumRAM !== '') {
+ filters.minimumRAM = Number(values.minimumRAM);
+ }
+ if (values.minimumStorage !== '') {
+ filters.minimumStorage = Number(values.minimumStorage);
+ }
+ if (values.gpuName.length > 0) {
+ filters.gpuName = values.gpuName;
+ }
+ setFilters(filters);
+ setSort(values.sortBy);
+ },
+ });
+
+ const toggleFilters = () => {
+ if (expanded) {
+ formik.setValues({
+ ...formik.values,
+ fromMaxJobDuration: '',
+ minimumCPU: '',
+ minimumRAM: '',
+ minimumStorage: '',
+ feeToken: '',
+ });
+ }
+ setExpanded(!expanded);
+ };
+
+ return (
+
+ Environments
+
+
+ {nodeEnvs.map((node) =>
+ node.computeEnvironments.environments.map((env) => (
+
+ ))
+ )}
+ {paginationResponse && paginationResponse.currentPage < paginationResponse.totalPages && (
+
+ )}
+
+
+ );
+};
+
+export default SelectEnvironment;
diff --git a/src/components/run-job/select-resources.module.css b/src/components/run-job/select-resources.module.css
new file mode 100644
index 00000000..85d25252
--- /dev/null
+++ b/src/components/run-job/select-resources.module.css
@@ -0,0 +1,76 @@
+.form {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+
+ .selectRow {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ }
+
+ .inputsGrid {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ }
+
+ .button {
+ align-self: end;
+ }
+
+ @media (min-width: 768px) {
+ .selectRow {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ }
+
+ .inputsGrid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ }
+ }
+
+ @media (min-width: 992px) {
+ .selectRow {
+ grid-template-columns: 2fr 1fr;
+ }
+ }
+
+ @media (min-width: 1200px) {
+ .selectRow {
+ grid-template-columns: 3fr 1fr;
+ }
+ }
+}
+
+.cost {
+ align-items: center;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ gap: 32px;
+ justify-content: space-between;
+ padding: 12px 24px;
+
+ .values {
+ display: flex;
+ flex-grow: 1;
+ flex-direction: column;
+ align-items: end;
+
+ .token {
+ color: var(--text-secondary);
+ font-size: 14px;
+ }
+
+ .amount {
+ font-size: 32px;
+ font-weight: 700;
+ }
+
+ .reimbursment {
+ color: var(--success);
+ }
+ }
+}
diff --git a/src/components/run-job/select-resources.tsx b/src/components/run-job/select-resources.tsx
new file mode 100644
index 00000000..e180591c
--- /dev/null
+++ b/src/components/run-job/select-resources.tsx
@@ -0,0 +1,267 @@
+import Button from '@/components/button/button';
+import Card from '@/components/card/card';
+import GpuLabel from '@/components/gpu-label/gpu-label';
+import useEnvResources from '@/components/hooks/use-env-resources';
+import Input from '@/components/input/input';
+import Select from '@/components/input/select';
+import Slider from '@/components/slider/slider';
+import { SelectedToken, useRunJobContext } from '@/context/run-job-context';
+import { ComputeEnvironment } from '@/types/environments';
+import { formatNumber } from '@/utils/formatters';
+import { useAuthModal, useSignerStatus } from '@account-kit/react';
+import { useFormik } from 'formik';
+import { useRouter } from 'next/router';
+import { useMemo } from 'react';
+import * as Yup from 'yup';
+import styles from './select-resources.module.css';
+
+type SelectResourcesProps = {
+ environment: ComputeEnvironment;
+ freeCompute: boolean;
+ token: SelectedToken;
+};
+
+type ResourcesFormValues = {
+ cpuCores: number;
+ diskSpace: number;
+ gpus: string[];
+ maxJobDurationHours: number;
+ ram: number;
+};
+
+const SelectResources = ({ environment, freeCompute, token }: SelectResourcesProps) => {
+ const { openAuthModal } = useAuthModal();
+ const router = useRouter();
+ const { isAuthenticating, isDisconnected } = useSignerStatus();
+
+ const { setEstimatedTotalCost, setSelectedResources } = useRunJobContext();
+
+ const { cpu, cpuFee, disk, diskFee, gpus, gpuFees, ram, ramFee } = useEnvResources({
+ environment,
+ freeCompute,
+ tokenAddress: token.address,
+ });
+
+ const minAllowedCpuCores = cpu?.min ?? 1;
+ const minAllowedDiskSpace = disk?.min ?? 0;
+ const minAllowedJobDurationHours = environment.minJobDuration ?? 0;
+ const minAllowedRam = ram?.min ?? 0;
+
+ const maxAllowedCpuCores = cpu?.max ?? minAllowedCpuCores;
+ const maxAllowedDiskSpace = disk?.max ?? minAllowedDiskSpace;
+ const maxAllowedJobDurationHours = (environment.maxJobDuration ?? minAllowedJobDurationHours) / 60 / 60;
+ const maxAllowedRam = ram?.max ?? minAllowedRam;
+
+ const formik = useFormik({
+ initialValues: {
+ cpuCores: minAllowedCpuCores,
+ diskSpace: minAllowedDiskSpace,
+ gpus: [],
+ maxJobDurationHours: minAllowedJobDurationHours,
+ ram: minAllowedRam,
+ },
+ onSubmit: (values) => {
+ if (isDisconnected) {
+ openAuthModal();
+ return;
+ }
+ setEstimatedTotalCost(estimatedTotalCost);
+ setSelectedResources({
+ cpuCores: values.cpuCores,
+ cpuId: cpu?.id ?? 'cpu',
+ diskSpace: values.diskSpace,
+ diskId: disk?.id ?? 'disk',
+ gpus: gpus
+ .filter((gpu) => values.gpus.includes(gpu.id))
+ .map((gpu) => ({ id: gpu.id, description: gpu.description })),
+ maxJobDurationHours: values.maxJobDurationHours,
+ ram: values.ram,
+ ramId: ram?.id ?? 'ram',
+ });
+ if (estimatedTotalCost > 0 && !freeCompute) {
+ router.push('/run-job/payment');
+ } else {
+ router.push('/run-job/summary');
+ }
+ },
+ validateOnMount: true,
+ validationSchema: Yup.object({
+ cpuCores: Yup.number()
+ .required('Required')
+ .min(minAllowedCpuCores, 'Limits exceeded')
+ .max(maxAllowedCpuCores, 'Limits exceeded')
+ .integer('Invalid format'),
+ diskSpace: Yup.number()
+ .required('Required')
+ .min(minAllowedDiskSpace, 'Limits exceeded')
+ .max(maxAllowedDiskSpace, 'Limits exceeded'),
+ gpus: Yup.array().of(Yup.string()),
+ maxJobDurationHours: Yup.number()
+ .required('Required')
+ .min(minAllowedJobDurationHours, 'Limits exceeded')
+ .max(maxAllowedJobDurationHours, 'Limits exceeded'),
+ ram: Yup.number()
+ .required('Required')
+ .min(minAllowedRam, 'Limits exceeded')
+ .max(maxAllowedRam, 'Limits exceeded'),
+ }),
+ });
+
+ const estimatedTotalCost = useMemo(() => {
+ if (freeCompute) {
+ return 0;
+ }
+ const timeInMinutes = Number(formik.values.maxJobDurationHours) * 60;
+ const cpuCost = Number(formik.values.cpuCores) * (cpuFee ?? 0) * timeInMinutes;
+ const ramCost = Number(formik.values.ram) * (ramFee ?? 0) * timeInMinutes;
+ const diskCost = Number(formik.values.diskSpace) * (diskFee ?? 0) * timeInMinutes;
+ const gpuCost = formik.values.gpus.reduce((total, gpuId) => {
+ const fee = gpuFees[gpuId] ?? 0;
+ return total + fee * timeInMinutes;
+ }, 0);
+ return cpuCost + ramCost + diskCost + gpuCost;
+ }, [
+ cpuFee,
+ diskFee,
+ formik.values.cpuCores,
+ formik.values.diskSpace,
+ formik.values.gpus,
+ formik.values.maxJobDurationHours,
+ formik.values.ram,
+ freeCompute,
+ gpuFees,
+ ramFee,
+ ]);
+
+ const selectAllGpus = () => {
+ formik.setFieldValue(
+ 'gpus',
+ gpus.map((gpu) => gpu.id)
+ );
+ };
+
+ const setMaxDiskSpace = () => {
+ formik.setFieldValue('diskSpace', maxAllowedDiskSpace);
+ };
+
+ const setMaxJobDurationHours = () => {
+ formik.setFieldValue('maxJobDurationHours', maxAllowedJobDurationHours);
+ };
+
+ return (
+
+ Select resources
+
+
+ );
+};
+
+export default SelectResources;
diff --git a/src/components/run-job/summary-page.tsx b/src/components/run-job/summary-page.tsx
new file mode 100644
index 00000000..b16a0320
--- /dev/null
+++ b/src/components/run-job/summary-page.tsx
@@ -0,0 +1,45 @@
+import Container from '@/components/container/container';
+import Summary from '@/components/run-job/summary';
+import SectionTitle from '@/components/section-title/section-title';
+import { getRunJobSteps, RunJobStep } from '@/components/stepper/get-steps';
+import Stepper from '@/components/stepper/stepper';
+import { useRunJobContext } from '@/context/run-job-context';
+import { useRouter } from 'next/router';
+import { useEffect } from 'react';
+
+const SummaryPage = () => {
+ const router = useRouter();
+
+ const { estimatedTotalCost, freeCompute, nodeInfo, selectedEnv, selectedResources, selectedToken } =
+ useRunJobContext();
+
+ useEffect(() => {
+ if (!selectedEnv || !selectedResources) {
+ router.replace('/run-job/environments');
+ }
+ }, [router, selectedEnv, selectedResources]);
+
+ return (
+
+ currentStep="finish" steps={getRunJobSteps(freeCompute)} />}
+ />
+ {nodeInfo && selectedEnv && selectedResources && selectedToken ? (
+
+
+
+ ) : null}
+
+ );
+};
+
+export default SummaryPage;
diff --git a/src/components/run-job/summary.module.css b/src/components/run-job/summary.module.css
new file mode 100644
index 00000000..c4292375
--- /dev/null
+++ b/src/components/run-job/summary.module.css
@@ -0,0 +1,41 @@
+.grid {
+ display: flex;
+ flex-direction: column;
+ gap: 8px 32px;
+
+ .label {
+ font-weight: 700;
+ color: var(--text-secondary);
+ }
+
+ .gpus {
+ display: flex;
+ gap: 12px;
+ }
+
+ @media (min-width: 768px) {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ }
+}
+
+.footer {
+ align-items: center;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ gap: 16px;
+
+ .buttons {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ gap: 16px;
+
+ @media (min-width: 576px) {
+ align-items: center;
+ flex-direction: row;
+ justify-content: end;
+ }
+ }
+}
diff --git a/src/components/run-job/summary.tsx b/src/components/run-job/summary.tsx
new file mode 100644
index 00000000..41069bad
--- /dev/null
+++ b/src/components/run-job/summary.tsx
@@ -0,0 +1,219 @@
+import VscodeLogoWhite from '@/assets/icons/ide/vscode-white.svg';
+import Button from '@/components/button/button';
+import Card from '@/components/card/card';
+import GpuLabel from '@/components/gpu-label/gpu-label';
+import useEnvResources from '@/components/hooks/use-env-resources';
+import { SelectedToken } from '@/context/run-job-context';
+import { useOceanAccount } from '@/lib/use-ocean-account';
+import { ComputeEnvironment, EnvNodeInfo, EnvResourcesSelection } from '@/types/environments';
+import { Ide } from '@/types/ide';
+import { useSignMessage, useSmartAccountClient } from '@account-kit/react';
+import { ListItemIcon, Menu, MenuItem } from '@mui/material';
+import classNames from 'classnames';
+import { useState } from 'react';
+import { toast } from 'react-toastify';
+import styles from './summary.module.css';
+
+type SummaryProps = {
+ estimatedTotalCost: number;
+ freeCompute: boolean;
+ nodeInfo: EnvNodeInfo;
+ selectedEnv: ComputeEnvironment;
+ selectedResources: EnvResourcesSelection;
+ token: SelectedToken;
+};
+
+const Summary = ({
+ estimatedTotalCost,
+ freeCompute,
+ nodeInfo,
+ selectedEnv,
+ selectedResources,
+ token,
+}: SummaryProps) => {
+ const { client } = useSmartAccountClient({ type: 'LightAccount' });
+ const { signMessageAsync } = useSignMessage({
+ client,
+ });
+
+ const { account, ocean } = useOceanAccount();
+
+ const { gpus } = useEnvResources({
+ environment: selectedEnv,
+ freeCompute,
+ tokenAddress: token.address,
+ });
+
+ const [anchorEl, setAnchorEl] = useState(null);
+ const [authToken, setAuthToken] = useState(null);
+
+ const generateToken = async () => {
+ if (!account.address || !ocean) {
+ return;
+ }
+ try {
+ const nonce = await ocean.getNonce(account.address, nodeInfo.id);
+ const incrementedNonce = nonce + 1;
+ const signedMessage = await signMessageAsync({
+ message: account.address + incrementedNonce,
+ });
+ const token = await ocean.generateAuthToken(account.address, incrementedNonce, signedMessage, nodeInfo.id);
+ setAuthToken(token);
+ } catch (error) {
+ console.error('Failed to generate auth token:', error);
+ toast.error('Failed to generate auth token');
+ }
+ };
+
+ const openIde = async (uriScheme: string) => {
+ if (!authToken || !account.address || !ocean) {
+ return;
+ }
+ const resources = [
+ {
+ id: selectedResources.cpuId,
+ amount: selectedResources.cpuCores,
+ },
+ {
+ id: selectedResources.ramId,
+ amount: selectedResources.ram,
+ },
+ {
+ id: selectedResources.diskId,
+ amount: selectedResources.diskSpace,
+ },
+ ...gpus.map((availableGpu) => ({
+ id: availableGpu.id,
+ amount: selectedResources.gpus.find((selectedGpu) => selectedGpu.id === availableGpu.id) ? 1 : 0,
+ })),
+ ];
+ const isFreeCompute = estimatedTotalCost === 0;
+ ocean.updateConfiguration(
+ authToken,
+ account.address,
+ nodeInfo.id,
+ isFreeCompute,
+ selectedEnv.id,
+ token.address,
+ selectedResources.maxJobDurationHours * 60 * 60,
+ resources,
+ uriScheme
+ );
+ };
+
+ const handleOpenIdeMenu = () => {
+ setAnchorEl(document.getElementById('choose-editor-button'));
+ };
+
+ const handleCloseIdeMenu = () => {
+ setAnchorEl(null);
+ };
+
+ return (
+
+ Your selection
+
+ {nodeInfo.friendlyName ? (
+ <>
+
Node:
+
{nodeInfo.friendlyName}
+ >
+ ) : null}
+
Peer ID:
+
{nodeInfo.id}
+
Environment:
+
{selectedEnv.consumerAddress}
+
Fee token address:
+
{token.address}
+
Job duration:
+
+ {selectedResources!.maxJobDurationHours} hours ({selectedResources!.maxJobDurationHours * 60 * 60} seconds)
+
+
GPU:
+
+ {selectedResources.gpus.length
+ ? selectedResources.gpus.map((gpu) => )
+ : '-'}
+
+
CPU cores:
+
{selectedResources!.cpuCores}
+
RAM:
+
{selectedResources!.ram} GB
+
Disk space:
+
{selectedResources!.diskSpace} GB
+
Total cost:
+
{freeCompute ? 'Free' : `${estimatedTotalCost} ${token.symbol}`}
+
+ {authToken ? (
+
+
Continue on our VSCode extension, or select your editor of choice
+
+
+
+ }
+ onClick={async () => await openIde('vscode')}
+ size="lg"
+ >
+ Open VSCode
+
+
+
+ ) : (
+
+
Continue on our VSCode extension, or select your editor of choice
+
+
+
+
+ )}
+
+ );
+};
+
+export default Summary;
diff --git a/src/components/run-node/configure-page.tsx b/src/components/run-node/configure-page.tsx
new file mode 100644
index 00000000..9360f40e
--- /dev/null
+++ b/src/components/run-node/configure-page.tsx
@@ -0,0 +1,38 @@
+import Container from '@/components/container/container';
+import NodeConfig from '@/components/run-node/node-config';
+import SectionTitle from '@/components/section-title/section-title';
+import { getRunNodeSteps, RunNodeStep } from '@/components/stepper/get-steps';
+import Stepper from '@/components/stepper/stepper';
+import { useRunNodeContext } from '@/context/run-node-context';
+import { useRouter } from 'next/router';
+import { useEffect } from 'react';
+
+const ConfigurePage = () => {
+ const router = useRouter();
+
+ const { peerId } = useRunNodeContext();
+
+ useEffect(() => {
+ if (!peerId) {
+ router.replace('/run-node/setup');
+ }
+ }, [peerId, router]);
+
+ return (
+
+ currentStep="configure" steps={getRunNodeSteps()} />}
+ />
+ {peerId ? (
+
+
+
+ ) : null}
+
+ );
+};
+
+export default ConfigurePage;
diff --git a/src/components/run-node/node-config.module.css b/src/components/run-node/node-config.module.css
new file mode 100644
index 00000000..9e7f5993
--- /dev/null
+++ b/src/components/run-node/node-config.module.css
@@ -0,0 +1,19 @@
+.editorWrapper {
+ border-radius: 16px;
+ border: 1px solid var(--border-glass);
+ max-height: calc(100vh - 300px);
+ overflow-y: auto;
+}
+
+.buttons {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ gap: 16px;
+ justify-content: flex-end;
+}
+
+.errorsList {
+ list-style-type: disc;
+ padding-left: 14px;
+}
diff --git a/src/components/run-node/node-config.tsx b/src/components/run-node/node-config.tsx
new file mode 100644
index 00000000..df5eda9f
--- /dev/null
+++ b/src/components/run-node/node-config.tsx
@@ -0,0 +1,78 @@
+import Button from '@/components/button/button';
+import Card from '@/components/card/card';
+import NodePreview from '@/components/run-node/node-preview';
+import { useRunNodeContext } from '@/context/run-node-context';
+import { githubDarkTheme, JsonEditor } from 'json-edit-react';
+import { useEffect, useState } from 'react';
+import styles from './node-config.module.css';
+
+const NodeConfig = () => {
+ const { configErrors, loadingPushConfig, loadingFetchConfig, nodeConfig, pushConfig } = useRunNodeContext();
+
+ const [editedConfig, setEditedConfig] = useState(nodeConfig ?? {});
+
+ useEffect(() => {
+ setEditedConfig(nodeConfig ?? {});
+ }, [nodeConfig]);
+
+ return (
+
+ {loadingFetchConfig ? (
+ 'Loading config...'
+ ) : (
+ <>
+
+ setEditedConfig(newData as Record)}
+ theme={githubDarkTheme}
+ />
+
+ {editedConfig ? : null}
+ {configErrors.length > 0 ? (
+
+ Format errors
+
+ {configErrors.map((error, index) => (
+ - {error}
+ ))}
+
+
+ ) : null}
+ >
+ )}
+
+
+
+
+
+ );
+};
+
+export default NodeConfig;
diff --git a/src/components/run-node/node-connection.module.css b/src/components/run-node/node-connection.module.css
new file mode 100644
index 00000000..96a0f184
--- /dev/null
+++ b/src/components/run-node/node-connection.module.css
@@ -0,0 +1,11 @@
+.header {
+ align-items: center;
+ display: flex;
+ gap: 8px;
+}
+
+.form {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
diff --git a/src/components/run-node/node-connection.tsx b/src/components/run-node/node-connection.tsx
new file mode 100644
index 00000000..3e3ad6a4
--- /dev/null
+++ b/src/components/run-node/node-connection.tsx
@@ -0,0 +1,86 @@
+import Button from '@/components/button/button';
+import Card from '@/components/card/card';
+import Input from '@/components/input/input';
+import { useRunNodeContext } from '@/context/run-node-context';
+import { useOceanAccount } from '@/lib/use-ocean-account';
+import { useAuthModal } from '@account-kit/react';
+import LinkIcon from '@mui/icons-material/Link';
+import classNames from 'classnames';
+import { useFormik } from 'formik';
+import * as Yup from 'yup';
+import styles from './node-connection.module.css';
+
+type ConnectFormValues = {
+ nodeId: string;
+};
+
+const NodeConnection = () => {
+ const { openAuthModal } = useAuthModal();
+
+ const { account } = useOceanAccount();
+
+ const { clearRunNodeSelection, connectToNode, peerId } = useRunNodeContext();
+
+ const isConnected = !!peerId;
+
+ const formik = useFormik({
+ initialValues: {
+ nodeId: '',
+ },
+ validationSchema: Yup.object().shape({
+ nodeId: Yup.string().required('Node ID is required'),
+ }),
+ onSubmit: async (values) => {
+ if (!account.isConnected) {
+ openAuthModal();
+ return;
+ }
+ await connectToNode(values.nodeId);
+ },
+ });
+
+ return (
+
+
+
Connect to your node
+
+ {isConnected ? 'Connected' : 'Not connected'}
+
+
+ {isConnected ? (
+ <>
+
+ Currently connected to node ID: {peerId}
+
+
+ >
+ ) : (
+ <>
+ Enter the ID of your node to connect and configure it
+
+ }
+ loading={formik.isSubmitting}
+ onClick={formik.submitForm}
+ >
+ Connect
+
+ >
+ )}
+
+ );
+};
+
+export default NodeConnection;
diff --git a/src/components/run-node/node-preview.module.css b/src/components/run-node/node-preview.module.css
new file mode 100644
index 00000000..ce864cca
--- /dev/null
+++ b/src/components/run-node/node-preview.module.css
@@ -0,0 +1,37 @@
+.grid {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ gap: 16px 32px;
+}
+
+.label {
+ align-items: center;
+ color: var(--text-secondary);
+ display: flex;
+ font-size: 14px;
+
+ &.heading,
+ .heading {
+ color: var(--text-primary);
+ font-size: 14px;
+ font-weight: 600;
+ }
+
+ &.em,
+ .em {
+ color: var(--text-primary);
+ font-weight: 700;
+ }
+
+ .icon {
+ color: var(--accent1);
+ margin-right: 4px;
+ }
+}
+
+.envWrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
diff --git a/src/components/run-node/node-preview.tsx b/src/components/run-node/node-preview.tsx
new file mode 100644
index 00000000..ef131cbd
--- /dev/null
+++ b/src/components/run-node/node-preview.tsx
@@ -0,0 +1,169 @@
+import Card from '@/components/card/card';
+import GpuLabel from '@/components/gpu-label/gpu-label';
+import useEnvResources from '@/components/hooks/use-env-resources';
+import { USDC_TOKEN_ADDRESS } from '@/constants/tokens';
+import { ComputeEnvironment } from '@/types/environments';
+import DnsIcon from '@mui/icons-material/Dns';
+import MemoryIcon from '@mui/icons-material/Memory';
+import SdStorageIcon from '@mui/icons-material/SdStorage';
+import classNames from 'classnames';
+import styles from './node-preview.module.css';
+
+type NodePreviewProps = {
+ nodeConfig: Record;
+};
+
+const NodePreview = ({ nodeConfig }: NodePreviewProps) => {
+ return (
+
+ Preview configuration
+ {nodeConfig.dockerComputeEnvironments?.length > 0 ? (
+ nodeConfig.dockerComputeEnvironments?.map((env: ComputeEnvironment, index: number) => (
+ 1}
+ />
+ ))
+ ) : (
+ No valid compute environments to display
+ )}
+
+ );
+};
+
+const NodeEnvPreview = ({
+ environment,
+ index,
+ showEnvName,
+}: {
+ environment: ComputeEnvironment;
+ index: number;
+ showEnvName?: boolean;
+}) => {
+ const { cpu, cpuFee, disk, diskFee, gpus, gpuFees, ram, ramFee } = useEnvResources({
+ environment,
+ freeCompute: false,
+ tokenAddress: USDC_TOKEN_ADDRESS,
+ });
+
+ // TODO
+ const tokenSymbol = 'USDC';
+
+ const renderCpu = () => {
+ if (!cpu) {
+ return null;
+ }
+ const max = cpu.max ?? cpu.total ?? 0;
+ const fee = cpuFee ?? 0;
+ return (
+
+
+
+ {cpu?.description}
+
+
+ {fee} {tokenSymbol}/min
+
+
+ 1 - {max}
+ available
+
+
+ );
+ };
+
+ const renderGpus = () => {
+ const mergedGpus = gpus.reduce(
+ (merged, gpuToCheck) => {
+ const existingGpu = merged.find(
+ (gpu) => gpu.description === gpuToCheck.description && gpuFees[gpu.id] === gpuFees[gpuToCheck.id]
+ );
+ if (existingGpu) {
+ existingGpu.inUse = (existingGpu.inUse ?? 0) + (gpuToCheck.inUse ?? 0);
+ existingGpu.max += gpuToCheck.max;
+ } else {
+ merged.push({ ...gpuToCheck });
+ }
+ return merged;
+ },
+ [] as typeof gpus
+ );
+ return mergedGpus.map((gpu) => {
+ const max = gpu.max ?? gpu.total ?? 0;
+ const fee = gpuFees[gpu.id] ?? 0;
+ return (
+
+
+
+ {fee} {tokenSymbol}/min
+
+
+ 0 - {max}
+ available
+
+
+ );
+ });
+ };
+
+ const renderRam = () => {
+ if (!ram) {
+ return null;
+ }
+ const max = ram.max ?? ram.total ?? 0;
+ const fee = ramFee ?? 0;
+ return (
+
+
+
+ GB RAM capacity
+
+
+ {fee} {tokenSymbol}/min
+
+
+ 0 - {max}
+ available
+
+
+ );
+ };
+
+ const renderDisk = () => {
+ if (!disk) {
+ return null;
+ }
+ const max = disk.max ?? disk.total ?? 0;
+ const fee = diskFee ?? 0;
+ return (
+
+
+
+ GB Disk space
+
+
+ {fee} {tokenSymbol}/min
+
+
+ 0 - {max}
+ available
+
+
+ );
+ };
+ return (
+
+ {showEnvName ?
Environment {index}
: null}
+
+ {renderGpus()}
+ {renderCpu()}
+ {renderRam()}
+ {renderDisk()}
+
+
+ );
+};
+
+export default NodePreview;
diff --git a/src/components/run-node/node-setup.module.css b/src/components/run-node/node-setup.module.css
new file mode 100644
index 00000000..c265828b
--- /dev/null
+++ b/src/components/run-node/node-setup.module.css
@@ -0,0 +1,5 @@
+.section {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
diff --git a/src/components/run-node/node-setup.tsx b/src/components/run-node/node-setup.tsx
new file mode 100644
index 00000000..7df284cd
--- /dev/null
+++ b/src/components/run-node/node-setup.tsx
@@ -0,0 +1,54 @@
+import Button from '@/components/button/button';
+import Card from '@/components/card/card';
+import { CodeBlock } from '@/components/code-block/code-block';
+import NodeConnection from '@/components/run-node/node-connection';
+import { useRunNodeContext } from '@/context/run-node-context';
+import { useRouter } from 'next/router';
+import styles from './node-setup.module.css';
+
+const NodeSetup = () => {
+ const router = useRouter();
+
+ const { fetchConfig, peerId } = useRunNodeContext();
+
+ const goToConfig = async () => {
+ await fetchConfig();
+ router.push('/run-node/configure');
+ };
+
+ return (
+
+ Set up Ocean Node via Docker
+ Before starting, make sure the system requirements are met
+
+ Docker Engine and Docker Compose are recommended for hosting a node eligible for incentives
+
+ You can explore other options in the Ocean Node readme
+
+
+
1. Download the setup script
+
+
+
+
2. Run the setup script and provide the required info
+
+
+
+
3. Run Ocean Node
+
+
+
+
4. Confirm that Docker containers are running
+
+
+
+ {peerId ? (
+
+ ) : null}
+
+ );
+};
+
+export default NodeSetup;
diff --git a/src/components/run-node/setup-page.tsx b/src/components/run-node/setup-page.tsx
new file mode 100644
index 00000000..315f7389
--- /dev/null
+++ b/src/components/run-node/setup-page.tsx
@@ -0,0 +1,23 @@
+import Container from '@/components/container/container';
+import NodeSetup from '@/components/run-node/node-setup';
+import SectionTitle from '@/components/section-title/section-title';
+import { getRunNodeSteps, RunNodeStep } from '@/components/stepper/get-steps';
+import Stepper from '@/components/stepper/stepper';
+
+const SetupPage = () => {
+ return (
+
+ currentStep="setup" steps={getRunNodeSteps()} />}
+ />
+
+
+
+
+ );
+};
+
+export default SetupPage;
diff --git a/src/components/section-title/section-title.module.css b/src/components/section-title/section-title.module.css
new file mode 100644
index 00000000..049947e7
--- /dev/null
+++ b/src/components/section-title/section-title.module.css
@@ -0,0 +1,17 @@
+.root {
+ text-align: center;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 16px;
+}
+
+.title {
+ margin: 0;
+ color: #009bff;
+ font-size: 40px;
+}
+
+.subTitle {
+ font-size: 20px;
+}
diff --git a/src/components/section-title/section-title.tsx b/src/components/section-title/section-title.tsx
new file mode 100644
index 00000000..6d9f1842
--- /dev/null
+++ b/src/components/section-title/section-title.tsx
@@ -0,0 +1,21 @@
+import cx from 'classnames';
+import styles from './section-title.module.css';
+
+type SectionTitleProps = {
+ title: string;
+ subTitle?: string;
+ className?: string;
+ contentBetween?: React.ReactNode;
+};
+
+const SectionTitle = ({ title, subTitle, className, contentBetween }: SectionTitleProps) => {
+ return (
+
+
{title}
+ {contentBetween}
+ {subTitle &&
{subTitle}
}
+
+ );
+};
+
+export default SectionTitle;
diff --git a/src/components/shared/NetworkSelector.tsx b/src/components/shared/NetworkSelector.tsx
deleted file mode 100644
index bd056115..00000000
--- a/src/components/shared/NetworkSelector.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import { Select, MenuItem, InputLabel, FormControl } from '@mui/material'
-import { useAdminContext } from '@/context/AdminProvider'
-
-interface NetworkSelectorProps {
- chainId?: string
- setChainId: (chainId: string) => void
-}
-
-export default function NetworkSelector({ chainId, setChainId }: NetworkSelectorProps) {
- const { networks } = useAdminContext()
-
- return (
-
- Network
-
-
- )
-}
diff --git a/src/components/slider/slider.tsx b/src/components/slider/slider.tsx
new file mode 100644
index 00000000..f7dcec57
--- /dev/null
+++ b/src/components/slider/slider.tsx
@@ -0,0 +1,62 @@
+import InputWrapper from '@/components/input/input-wrapper';
+import { Slider as MaterialSlider, styled } from '@mui/material';
+
+const StyledSliderWrapper = styled('div')(() => ({
+ padding: '0 12px',
+}));
+
+type SliderProps = {
+ className?: string;
+ errorText?: string;
+ hint?: string;
+ label?: string;
+ marks?: boolean;
+ max?: number;
+ min?: number;
+ name?: string;
+ onBlur?: (e: React.FocusEvent) => void;
+ onChange?: (e: Event, value: number) => void;
+ placeholder?: string;
+ size?: 'sm' | 'md';
+ startAdornment?: React.ReactNode;
+ step?: number;
+ topRight?: React.ReactNode;
+ value?: number;
+ valueLabelFormat?: (value: number) => string;
+};
+
+const Slider = ({
+ className,
+ errorText,
+ hint,
+ label,
+ marks,
+ max,
+ min,
+ name,
+ onBlur,
+ onChange,
+ step,
+ topRight,
+ value,
+ valueLabelFormat,
+}: SliderProps) => (
+
+
+
+
+
+);
+
+export default Slider;
diff --git a/src/components/stats/jobs-revenue-stats.module.css b/src/components/stats/jobs-revenue-stats.module.css
new file mode 100644
index 00000000..88771453
--- /dev/null
+++ b/src/components/stats/jobs-revenue-stats.module.css
@@ -0,0 +1,31 @@
+.root {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 48px;
+}
+
+.revenueWrapper {
+ align-content: start;
+ align-items: center;
+ display: grid;
+ grid-template-rows: auto 110px;
+ gap: 24px;
+
+ .heading {
+ text-align: center;
+ }
+
+ .revenue {
+ text-align: center;
+
+ .token {
+ color: var(--text-secondary);
+ font-size: 14px;
+ }
+
+ .amount {
+ font-size: 48px;
+ font-weight: 600;
+ }
+ }
+}
diff --git a/src/components/stats/jobs-revenue-stats.tsx b/src/components/stats/jobs-revenue-stats.tsx
new file mode 100644
index 00000000..1b2471b7
--- /dev/null
+++ b/src/components/stats/jobs-revenue-stats.tsx
@@ -0,0 +1,46 @@
+import Card from '@/components/card/card';
+import { ChartTypeEnum } from '@/components/chart/chart-type';
+import VBarChart from '@/components/chart/v-bar-chart';
+import { useStatsContext } from '@/context/stats-context';
+import { formatNumber } from '@/utils/formatters';
+import { useEffect } from 'react';
+import styles from './jobs-revenue-stats.module.css';
+
+const JobsRevenueStats = () => {
+ const { jobsPerEpoch, revenuePerEpoch, totalJobs, totalRevenue, fetchAnalyticsGlobalStats } = useStatsContext();
+
+ useEffect(() => {
+ fetchAnalyticsGlobalStats();
+ }, [fetchAnalyticsGlobalStats]);
+
+ return (
+
+
+
Total revenue
+
+ USDC {formatNumber(totalRevenue)}
+
+
+
+
+
+ );
+};
+
+export default JobsRevenueStats;
diff --git a/src/components/stats/stats-page.tsx b/src/components/stats/stats-page.tsx
new file mode 100644
index 00000000..101599c4
--- /dev/null
+++ b/src/components/stats/stats-page.tsx
@@ -0,0 +1,26 @@
+import Container from '@/components/container/container';
+import SectionTitle from '@/components/section-title/section-title';
+import JobsRevenueStats from '@/components/stats/jobs-revenue-stats';
+import NodeSpecStats from '@/components/stats/system-stats';
+import TopGpuModels from '@/components/stats/top-gpu-models';
+import TopNodes from '@/components/stats/top-nodes';
+
+const StatsPage = () => {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default StatsPage;
diff --git a/src/components/stats/system-stats.module.css b/src/components/stats/system-stats.module.css
new file mode 100644
index 00000000..a0e2454d
--- /dev/null
+++ b/src/components/stats/system-stats.module.css
@@ -0,0 +1,8 @@
+.root {
+ column-gap: 16px;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ row-gap: 32px;
+}
diff --git a/src/components/stats/system-stats.tsx b/src/components/stats/system-stats.tsx
new file mode 100644
index 00000000..92a1471f
--- /dev/null
+++ b/src/components/stats/system-stats.tsx
@@ -0,0 +1,105 @@
+import Card from '@/components/card/card';
+import { ChartTypeEnum } from '@/components/chart/chart-type';
+import PieChart from '@/components/chart/pie-chart';
+import { useStatsContext } from '@/context/stats-context';
+import { SystemStatsData } from '@/types/stats';
+import { useEffect } from 'react';
+import styles from './system-stats.module.css';
+
+const brandColors = {
+ primary: ['#009bff', '#0084dc', '#006eb9', '#005896', '#004273', '#002c50'],
+ other: '#ffffff',
+};
+
+interface ChartDataItem {
+ name: string;
+ value: number;
+ color: string;
+ details?: string[];
+}
+
+const processChartData = (data: Record, maxSlices: number): ChartDataItem[] => {
+ if (!data) return [];
+
+ const sortedEntries = Object.entries(data).sort(([, a], [, b]) => b - a);
+
+ const mainEntries = sortedEntries.slice(0, maxSlices);
+ const otherEntries = sortedEntries.slice(maxSlices);
+ const otherCount = otherEntries.reduce((sum, [, count]) => sum + count, 0);
+
+ const result = mainEntries.map(
+ ([key, count], index): ChartDataItem => ({
+ name: key,
+ value: count,
+ color: brandColors.primary[index],
+ })
+ );
+
+ if (otherCount > 0) {
+ result.push({
+ name: 'Other',
+ value: otherCount,
+ color: brandColors.other,
+ details: otherEntries.map(([key, count]) => `${key}: ${count} nodes`),
+ });
+ }
+
+ return result;
+};
+
+const processCpuData = (stats: SystemStatsData): ChartDataItem[] => {
+ if (!stats?.cpuCounts) return [];
+ const data = processChartData(stats.cpuCounts, 5);
+ return data.map((item) => ({
+ ...item,
+ name: item.name === 'Other' ? item.name : `${item.name} CPU${item.name !== '1' ? 's' : ''}`,
+ details: item.details?.map((detail) => {
+ const [count, nodes] = detail.split(':');
+ return `${count} CPU${count !== '1' ? 's' : ''}:${nodes}`;
+ }),
+ }));
+};
+
+const processOsData = (stats: SystemStatsData): ChartDataItem[] => {
+ if (!stats?.operatingSystems) return [];
+ return processChartData(stats.operatingSystems, 3);
+};
+
+const processCpuArchData = (stats: SystemStatsData): ChartDataItem[] => {
+ if (!stats?.cpuArchitectures) return [];
+ const data = processChartData(stats.cpuArchitectures, 3);
+
+ return data.map((item) => ({
+ ...item,
+ name: item.name.toUpperCase(),
+ details: item.details?.map((detail) => detail.toUpperCase()),
+ }));
+};
+
+const SystemStats = () => {
+ const { fetchSystemStats, systemStats } = useStatsContext();
+
+ useEffect(() => {
+ if (!systemStats.cpuCounts || Object.keys(systemStats.cpuCounts).length === 0) {
+ fetchSystemStats();
+ }
+ }, [fetchSystemStats, systemStats.cpuCounts]);
+
+ return (
+
+
+
+
+
+ );
+};
+
+export default SystemStats;
diff --git a/src/components/stats/top-gpu-models.tsx b/src/components/stats/top-gpu-models.tsx
new file mode 100644
index 00000000..f56ab8c4
--- /dev/null
+++ b/src/components/stats/top-gpu-models.tsx
@@ -0,0 +1,21 @@
+import Card from '@/components/card/card';
+import HBarChart from '@/components/chart/h-bar-chart';
+import { useStatsContext } from '@/context/stats-context';
+import { useEffect } from 'react';
+
+const TopGpuModels = () => {
+ const { topGpuModels, fetchTopGpus } = useStatsContext();
+
+ useEffect(() => {
+ fetchTopGpus();
+ }, [fetchTopGpus]);
+
+ return (
+
+ Top GPUs by popularity
+
+
+ );
+};
+
+export default TopGpuModels;
diff --git a/src/components/stats/top-nodes.tsx b/src/components/stats/top-nodes.tsx
new file mode 100644
index 00000000..0479fa4b
--- /dev/null
+++ b/src/components/stats/top-nodes.tsx
@@ -0,0 +1,45 @@
+import Card from '@/components/card/card';
+import { Table } from '@/components/table/table';
+import { TableTypeEnum } from '@/components/table/table-type';
+import { useStatsContext } from '@/context/stats-context';
+import { Node } from '@/types/nodes';
+import { useEffect } from 'react';
+
+const TopNodes = () => {
+ const { topNodesByJobs, topNodesByRevenue, fetchTopNodesByRevenue, fetchTopNodesByJobCount } = useStatsContext();
+
+ useEffect(() => {
+ fetchTopNodesByRevenue();
+ }, [fetchTopNodesByRevenue]);
+
+ useEffect(() => {
+ fetchTopNodesByJobCount();
+ }, [fetchTopNodesByJobCount]);
+
+ return (
+ <>
+
+ Top nodes by revenue
+
+ autoHeight
+ data={topNodesByRevenue.map((item, idx) => ({ index: idx + 1, ...item }))}
+ paginationType="none"
+ tableType={TableTypeEnum.NODES_TOP_REVENUE}
+ getRowId={(row) => row.nodeId}
+ />
+
+
+ Top nodes by number of jobs
+
+ autoHeight
+ data={topNodesByJobs.map((item, idx) => ({ index: idx + 1, ...item }))}
+ paginationType="none"
+ tableType={TableTypeEnum.NODES_TOP_JOBS}
+ getRowId={(row) => row.nodeId}
+ />
+
+ >
+ );
+};
+
+export default TopNodes;
diff --git a/src/components/stepper/get-steps.tsx b/src/components/stepper/get-steps.tsx
new file mode 100644
index 00000000..014ab5bb
--- /dev/null
+++ b/src/components/stepper/get-steps.tsx
@@ -0,0 +1,15 @@
+import { Step } from '@/components/stepper/stepper';
+
+export type RunJobStep = 'environment' | 'resources' | 'payment' | 'finish';
+export const getRunJobSteps = (freeCompute: boolean): Step[] => [
+ { key: 'environment', label: 'Environment' },
+ { key: 'resources', label: 'Resources' },
+ { key: 'payment', label: 'Payment', hidden: freeCompute },
+ { key: 'finish', label: 'Finish' },
+];
+
+export type RunNodeStep = 'setup' | 'configure';
+export const getRunNodeSteps = (): Step[] => [
+ { key: 'setup', label: 'Set up' },
+ { key: 'configure', label: 'Configure' },
+];
diff --git a/src/components/stepper/stepper.module.css b/src/components/stepper/stepper.module.css
new file mode 100644
index 00000000..ffa6d940
--- /dev/null
+++ b/src/components/stepper/stepper.module.css
@@ -0,0 +1,50 @@
+.root {
+ border-radius: 24px;
+ display: flex;
+ flex-direction: column;
+ font-size: 16px;
+ font-weight: 500;
+ gap: 8px;
+ min-width: 250px;
+ padding: 8px;
+
+ .separator {
+ color: var(--border-glass);
+ transform: rotate(90deg);
+
+ &.active {
+ color: var(--success);
+ }
+ }
+
+ .step {
+ align-items: center;
+ background: var(--background-glass);
+ border: 2px solid transparent;
+ border-radius: 18px;
+ color: var(--text-primary);
+ display: flex;
+ gap: 8px;
+ justify-content: center;
+ padding: 8px 18px;
+
+ &.active {
+ background: var(--accent1);
+ }
+
+ &.complete {
+ background: none;
+ border-color: var(--success);
+ color: var(--success);
+ }
+ }
+
+ @media (min-width: 992px) {
+ align-items: center;
+ flex-direction: row;
+
+ .separator {
+ transform: rotate(0deg);
+ }
+ }
+}
diff --git a/src/components/stepper/stepper.tsx b/src/components/stepper/stepper.tsx
new file mode 100644
index 00000000..b0c8b819
--- /dev/null
+++ b/src/components/stepper/stepper.tsx
@@ -0,0 +1,49 @@
+import Card from '@/components/card/card';
+import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
+import classNames from 'classnames';
+import { useMemo } from 'react';
+import styles from './stepper.module.css';
+
+export type Step = {
+ hidden?: boolean;
+ key: T;
+ label: string;
+};
+
+type StepperProps = {
+ currentStep: T;
+ steps: Step[];
+};
+
+const Stepper = ({ currentStep, steps }: StepperProps) => {
+ const visibleSteps = useMemo(() => steps.filter((step) => !step.hidden), [steps]);
+
+ const currentStepIndex = useMemo(
+ () => visibleSteps.findIndex((step) => step.key === currentStep),
+ [currentStep, visibleSteps]
+ );
+
+ const renderStep = (step: Step, index: number) => {
+ const isActive = currentStepIndex === index;
+ const isComplete = currentStepIndex > index;
+ return (
+ <>
+ {index > 0 ? (
+ —
+ ) : null}
+
+ {isComplete ? : null}
+ {step.label}
+
+ >
+ );
+ };
+
+ return (
+
+ {visibleSteps.map((step, index) => renderStep(step, index))}
+
+ );
+};
+
+export default Stepper;
diff --git a/src/components/tab-bar/tab-bar.module.css b/src/components/tab-bar/tab-bar.module.css
new file mode 100644
index 00000000..309ef60c
--- /dev/null
+++ b/src/components/tab-bar/tab-bar.module.css
@@ -0,0 +1,27 @@
+.root {
+ display: inline-flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ gap: 8px;
+ padding: 8px;
+
+ .tab {
+ border-radius: 32px;
+ font-size: 16px;
+ font-weight: 600;
+ padding: 8px 18px;
+
+ &:not(.tabActive) {
+ cursor: pointer;
+
+ &:hover {
+ background-color: var(--background-glass);
+ }
+ }
+ }
+
+ .tabActive {
+ background-color: var(--accent1);
+ color: var(--text-primary);
+ }
+}
diff --git a/src/components/tab-bar/tab-bar.tsx b/src/components/tab-bar/tab-bar.tsx
new file mode 100644
index 00000000..973d712e
--- /dev/null
+++ b/src/components/tab-bar/tab-bar.tsx
@@ -0,0 +1,46 @@
+import Card from '@/components/card/card';
+import classNames from 'classnames';
+import { useRouter } from 'next/router';
+import styles from './tab-bar.module.css';
+
+type Tab = {
+ href?: string;
+ key: string;
+ label: React.ReactNode;
+ onClick?: () => void;
+};
+
+type TabBarProps = {
+ activeKey: string;
+ className?: string;
+ tabs: Tab[];
+};
+
+const TabBar = ({ activeKey, className, tabs }: TabBarProps) => {
+ const router = useRouter();
+
+ const handleTabClick = (tab: Tab) => {
+ if (tab.onClick) {
+ tab.onClick();
+ }
+ if (tab.href) {
+ router.push(tab.href);
+ }
+ };
+
+ return (
+
+ {tabs.map((tab) => (
+ handleTabClick(tab)}
+ >
+ {tab.label}
+
+ ))}
+
+ );
+};
+
+export default TabBar;
diff --git a/src/components/table/columns.tsx b/src/components/table/columns.tsx
new file mode 100644
index 00000000..5e8c3c60
--- /dev/null
+++ b/src/components/table/columns.tsx
@@ -0,0 +1,357 @@
+import InfoButton from '@/components/button/info-button';
+import JobInfoButton from '@/components/button/job-info-button';
+import { ComputeJob } from '@/types/jobs';
+import { GPUPopularity, Node } from '@/types/nodes';
+import { UnbanRequest } from '@/types/unban-requests';
+import { formatNumber } from '@/utils/formatters';
+import CheckCircleOutlinedIcon from '@mui/icons-material/CheckCircleOutlined';
+import ErrorOutlineOutlinedIcon from '@mui/icons-material/ErrorOutlineOutlined';
+import HighlightOffOutlinedIcon from '@mui/icons-material/HighlightOffOutlined';
+import { getGridNumericOperators, getGridStringOperators, GridColDef, GridRenderCellParams } from '@mui/x-data-grid';
+import classNames from 'classnames';
+
+function getEligibleCheckbox(eligible = false, eligibilityCauseStr?: string) {
+ if (eligible) {
+ return (
+ <>
+
+ Eligible
+ >
+ );
+ } else {
+ switch (eligibilityCauseStr) {
+ case 'Invalid status response':
+ return (
+ <>
+
+ Not eligible
+ >
+ );
+
+ case 'Banned':
+ return (
+ <>
+
+ Banned
+ >
+ );
+
+ case 'No peer data':
+ return (
+ <>
+
+ Not eligible
+ >
+ );
+
+ default:
+ return (
+ <>
+
+ Not eligible
+ >
+ );
+ }
+ }
+}
+
+function getUnbanAttemptResult(result: string) {
+ switch (result) {
+ case 'Pending':
+ return (
+ <>
+
+ Pending
+ >
+ );
+
+ default:
+ return (
+ <>
+
+ Failed
+ >
+ );
+ }
+}
+
+function getUnbanAttemptStatus(status: string) {
+ return (
+
+ {status}
+
+ );
+}
+
+export const nodesLeaderboardColumns: GridColDef[] = [
+ {
+ align: 'center',
+ field: 'index',
+ filterable: false,
+ headerAlign: 'center',
+ headerName: 'Index',
+ sortable: false,
+ },
+ {
+ field: 'friendlyName',
+ filterable: true,
+ flex: 1,
+ headerName: 'Name',
+ sortable: false,
+ filterOperators: getGridStringOperators().filter(
+ (operator) => operator.value === 'contains' || operator.value === 'startsWith' || operator.value === 'equals'
+ ),
+ },
+ {
+ field: 'gpus',
+ filterable: false,
+ flex: 1,
+ headerName: 'GPUs',
+ sortable: false,
+ renderCell: (params) => params.value?.map((gpu: GPUPopularity) => `${gpu.vendor} ${gpu.name}`).join(', ') ?? '-',
+ },
+ {
+ field: 'latestBenchmarkResults.totalScore',
+ filterable: false,
+ flex: 1,
+ headerName: 'Total Score',
+ sortable: false,
+ valueGetter: (_value, row) => row.latestBenchmarkResults?.totalScore || 0,
+ filterOperators: getGridNumericOperators().filter(
+ (operator) => operator.value === '=' || operator.value === '>' || operator.value === '<'
+ ),
+ },
+ {
+ field: 'location.region',
+ filterable: true,
+ flex: 1,
+ headerName: 'Region',
+ valueGetter: (_value, row) => row.location?.region,
+ sortable: false,
+ filterOperators: getGridStringOperators().filter(
+ (operator) => operator.value === 'contains' || operator.value === 'startsWith' || operator.value === 'equals'
+ ),
+ },
+ {
+ field: 'eligible',
+ filterable: true,
+ flex: 1,
+ headerName: 'Reward eligibility',
+ sortable: false,
+ renderCell: (params: GridRenderCellParams) => (
+
+ {getEligibleCheckbox(params.row.eligible, params.row.eligibilityCauseStr)}
+
+ ),
+ filterOperators: getGridStringOperators().filter((operator) => operator.value === 'equals'),
+ },
+ {
+ align: 'right',
+ field: 'actions',
+ filterable: false,
+ headerAlign: 'center',
+ headerName: 'Actions',
+ sortable: false,
+ renderCell: (params) => {
+ return ;
+ },
+ },
+];
+
+export const jobsColumns: GridColDef[] = [
+ {
+ align: 'center',
+ field: 'index',
+ filterable: false,
+ headerAlign: 'center',
+ headerName: 'Index',
+ sortable: false,
+ },
+ {
+ field: 'statusText',
+ filterable: false,
+ flex: 1,
+ headerName: 'Status',
+ sortable: false,
+ },
+ {
+ field: 'startTime',
+ filterable: true,
+ flex: 1,
+ headerName: 'Start Time',
+ sortable: false,
+ filterOperators: getGridNumericOperators().filter(
+ (operator) => operator.value === '=' || operator.value === '>' || operator.value === '<'
+ ),
+ },
+ {
+ field: 'nodeFriendlyName',
+ filterable: true,
+ flex: 1,
+ headerName: 'Node Name',
+ sortable: false,
+ },
+ {
+ field: 'amountPaid',
+ filterable: true,
+ flex: 1,
+ headerName: 'Amount Paid',
+ sortable: false,
+ valueGetter: (_value, row) => row.paymentInfo?.cost,
+ filterOperators: getGridNumericOperators().filter(
+ (operator) => operator.value === '=' || operator.value === '>' || operator.value === '<'
+ ),
+ },
+ {
+ field: 'duration',
+ filterable: true,
+ flex: 1,
+ headerName: 'Duration',
+ sortable: false,
+ filterOperators: getGridNumericOperators().filter(
+ (operator) => operator.value === '=' || operator.value === '>' || operator.value === '<'
+ ),
+ },
+ {
+ align: 'right',
+ field: 'actions',
+ filterable: false,
+ headerAlign: 'center',
+ headerName: 'Actions',
+ sortable: false,
+ renderCell: (params: GridRenderCellParams) => {
+ return ;
+ },
+ },
+];
+
+export const unbanRequestsColumns: GridColDef[] = [
+ {
+ align: 'center',
+ field: 'index',
+ filterable: false,
+ headerAlign: 'center',
+ headerName: 'Index',
+ sortable: false,
+ },
+ {
+ field: 'status',
+ filterable: false,
+ flex: 1,
+ headerName: 'Status',
+ sortable: false,
+ renderCell: (params: GridRenderCellParams) => getUnbanAttemptStatus(params.row.status),
+ },
+ {
+ field: 'startedAt',
+ filterable: false,
+ flex: 1,
+ headerName: 'Start Time',
+ sortable: false,
+ },
+ {
+ field: 'completedAt',
+ filterable: false,
+ flex: 1,
+ headerName: 'End Time',
+ sortable: false,
+ },
+ {
+ field: 'benchmarkResult',
+ filterable: false,
+ flex: 1,
+ headerName: 'Result',
+ sortable: false,
+ renderCell: (params: GridRenderCellParams) => (
+
+ {getUnbanAttemptResult(params.row.benchmarkResult)}
+
+ ),
+ },
+];
+
+export const topNodesByRevenueColumns: GridColDef[] = [
+ {
+ align: 'center',
+ field: 'index',
+ filterable: false,
+ headerAlign: 'center',
+ headerName: 'Index',
+ sortable: false,
+ },
+ {
+ field: 'friendlyName',
+ filterable: true,
+ flex: 1,
+ headerName: 'Name',
+ sortable: false,
+ },
+ {
+ field: 'region',
+ filterable: true,
+ flex: 1,
+ headerName: 'Region',
+ sortable: false,
+ },
+ {
+ field: 'totalRevenue',
+ filterable: false,
+ renderCell: ({ value }) => formatNumber(value.toFixed(2)),
+ flex: 1,
+ headerName: 'Total Revenue',
+ sortable: false,
+ },
+ {
+ field: 'latestGpuScore',
+ filterable: false,
+ flex: 1,
+ headerName: 'Last benchmark score (GPU)',
+ sortable: false,
+ },
+];
+
+export const topNodesByJobsColumns: GridColDef[] = [
+ {
+ align: 'center',
+ field: 'index',
+ filterable: false,
+ headerAlign: 'center',
+ headerName: 'Index',
+ sortable: false,
+ },
+ {
+ field: 'friendlyName',
+ filterable: false,
+ flex: 1,
+ headerName: 'Name',
+ sortable: true,
+ },
+ {
+ field: 'region',
+ filterable: false,
+ flex: 1,
+ headerName: 'Region',
+ sortable: true,
+ },
+ {
+ field: 'totalJobs',
+ filterable: false,
+ flex: 1,
+ headerName: 'Total Jobs',
+ sortable: true,
+ },
+ {
+ field: 'latestGpuScore',
+ filterable: false,
+ flex: 1,
+ headerName: 'Last benchmark score (GPU)',
+ sortable: false,
+ },
+];
diff --git a/src/components/table/context-type.ts b/src/components/table/context-type.ts
new file mode 100644
index 00000000..c6052113
--- /dev/null
+++ b/src/components/table/context-type.ts
@@ -0,0 +1,21 @@
+import { GridFilterModel } from '@mui/x-data-grid';
+
+export type TableContextType = {
+ crtPage: number;
+ data: T[];
+ error: any;
+ filterModel: GridFilterModel;
+ filters: Record;
+ loading: boolean;
+ pageSize: number;
+ searchTerm: string;
+ sortModel: Record;
+ totalItems: number;
+ fetchData: () => Promise;
+ setCrtPage: (page: TableContextType['crtPage']) => void;
+ setFilterModel: (filter: TableContextType['filterModel']) => void;
+ setFilters: (filters: TableContextType['filters']) => void;
+ setPageSize: (size: TableContextType['pageSize']) => void;
+ setSearchTerm: (term: string) => void;
+ setSortModel: (model: TableContextType['sortModel']) => void;
+};
diff --git a/src/components/Table/CustomPagination.module.css b/src/components/table/custom-pagination.module.css
similarity index 75%
rename from src/components/Table/CustomPagination.module.css
rename to src/components/table/custom-pagination.module.css
index ee09197a..1f89d904 100644
--- a/src/components/Table/CustomPagination.module.css
+++ b/src/components/table/custom-pagination.module.css
@@ -4,8 +4,8 @@
justify-content: space-between;
align-items: center;
gap: 16px;
- padding: 30px 90px;
- font-family: 'Sharp Sans', sans-serif!important;
+ /* padding: 30px 90px; */
+ font-family: var(--font-inter), sans-serif !important;
}
@media (max-width: 768px) {
@@ -14,7 +14,7 @@
padding: 10px;
align-items: center;
}
-
+
.pagination > div {
width: 100%;
display: flex;
@@ -40,12 +40,12 @@
flex-direction: row;
align-items: center;
gap: 8px;
- font-family: 'Sharp Sans', sans-serif;
+ font-family: var(--font-inter), sans-serif;
}
.paginationArrowText {
- color: #000000;
- font-family: 'Sharp Sans', sans-serif;
+ color: #ffffff;
+ font-family: var(--font-inter), sans-serif;
font-size: 18px;
font-weight: 400;
line-height: 18px;
@@ -59,12 +59,12 @@
display: flex;
align-items: center;
gap: 8px;
- font-family: 'Sharp Sans', sans-serif;
+ font-family: var(--font-inter), sans-serif;
}
.paginationCore {
display: flex;
align-items: center;
gap: 8px;
- font-family: 'Sharp Sans', sans-serif!important;
+ font-family: var(--font-inter), sans-serif !important;
}
diff --git a/src/components/Table/CustomPagination.tsx b/src/components/table/custom-pagination.tsx
similarity index 65%
rename from src/components/Table/CustomPagination.tsx
rename to src/components/table/custom-pagination.tsx
index f25ae4ef..a83a2b9d 100644
--- a/src/components/Table/CustomPagination.tsx
+++ b/src/components/table/custom-pagination.tsx
@@ -1,142 +1,141 @@
-import React, { useCallback, useState, useEffect } from 'react'
+import ArrowBackIcon from '@mui/icons-material/ArrowBack';
+import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
+import ArrowDropUpIcon from '@mui/icons-material/ArrowDropUp';
+import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import {
- Pagination,
- styled,
Button,
- Typography,
- Select,
+ IconButton,
+ InputAdornment,
MenuItem,
+ Pagination,
+ Select,
+ styled,
TextField,
- InputAdornment,
- IconButton,
+ Typography,
+ useMediaQuery,
useTheme,
- useMediaQuery
-} from '@mui/material'
-import ArrowBackIcon from '@mui/icons-material/ArrowBack'
-import ArrowForwardIcon from '@mui/icons-material/ArrowForward'
-import ArrowDropUpIcon from '@mui/icons-material/ArrowDropUp'
-import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'
-import styles from './CustomPagination.module.css'
+} from '@mui/material';
+import React, { useCallback, useEffect, useState } from 'react';
+import styles from './custom-pagination.module.css';
const StyledPagination = styled(Pagination)(({ theme }) => ({
'& .MuiPaginationItem-root': {
- color: '#000000',
- fontFamily: 'Sharp Sans, sans-serif',
+ color: '#ffffff',
+ fontFamily: 'var(--font-inter), sans-serif',
fontSize: '16px',
fontWeight: 400,
lineHeight: '16px',
- paddingTop: '3px'
+ paddingTop: '3px',
},
'& .MuiPaginationItem-page.Mui-selected': {
- backgroundColor: '#CF1FB1',
+ backgroundColor: 'var(--accent1)',
color: '#FFFFFF',
'&:hover': {
- backgroundColor: '#CF1FB1'
+ backgroundColor: 'var(--accent1)',
},
minWidth: '32px',
height: '32px',
borderRadius: '8px',
- padding: '3px 8px'
- }
-}))
+ padding: '3px 8px',
+ },
+}));
const NavButton = styled(Button)(({ theme }) => ({
minWidth: 'auto',
padding: '6px',
'&.Mui-disabled': {
- opacity: 0.5
+ opacity: 0.5,
},
'& .MuiTypography-root': {
- fontFamily: "'Sharp Sans', sans-serif",
+ fontFamily: 'var(--font-inter), sans-serif',
fontSize: '16px',
fontWeight: 400,
- lineHeight: '24px'
- }
-}))
+ lineHeight: '24px',
+ },
+}));
const StyledSelect = styled(Select)(({ theme }) => ({
minWidth: 80,
marginLeft: theme.spacing(2),
'& .MuiOutlinedInput-notchedOutline': {
- borderColor: '#CF1FB1'
+ borderColor: 'var(--accent1)',
},
'&:hover .MuiOutlinedInput-notchedOutline': {
- borderColor: '#CF1FB1'
+ borderColor: 'var(--accent1)',
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
- borderColor: '#CF1FB1'
+ borderColor: 'var(--accent1)',
},
'& .MuiSelect-icon': {
- color: '#CF1FB1'
+ color: 'var(--accent1)',
},
'& .MuiSelect-select': {
paddingTop: 5,
paddingBottom: 5,
- fontFamily: "'Sharp Sans', sans-serif",
+ fontFamily: 'var(--font-inter), sans-serif',
fontSize: '16px',
fontWeight: 400,
lineHeight: '24px',
- color: '#000000'
+ color: '#ffffff',
},
'& .MuiMenuItem-root': {
- fontFamily: "'Sharp Sans', sans-serif",
+ fontFamily: 'var(--font-inter), sans-serif',
fontSize: '16px',
fontWeight: 400,
lineHeight: '16px',
- color: '#000000'
- }
-}))
+ color: '#ffffff',
+ },
+}));
const StyledTextField = styled(TextField)(({ theme }) => ({
'& .MuiOutlinedInput-root': {
'& fieldset': {
- borderColor: '#CF1FB1',
- borderRadius: '4px'
+ borderColor: 'var(--accent1)',
+ borderRadius: '4px',
},
'&:hover fieldset': {
- borderColor: '#CF1FB1'
+ borderColor: 'var(--accent1)',
},
'&.Mui-focused fieldset': {
- borderColor: '#CF1FB1'
- }
+ borderColor: 'var(--accent1)',
+ },
},
'& .MuiInputBase-input': {
padding: '5px 12px',
- fontFamily: "'Sharp Sans', sans-serif",
+ fontFamily: 'var(--font-inter), sans-serif',
fontSize: '16px',
fontWeight: 400,
lineHeight: '24px',
- color: '#000000',
- minWidth: '42px'
+ color: '#ffffff',
+ minWidth: '42px',
},
'& .MuiInputBase-input::placeholder': {
color: '#A0AEC0',
- opacity: 1
+ opacity: 1,
+ },
+ '& input[type=number]::-webkit-inner-spin-button, & input[type=number]::-webkit-outer-spin-button': {
+ WebkitAppearance: 'none',
+ margin: 0,
},
- '& input[type=number]::-webkit-inner-spin-button, & input[type=number]::-webkit-outer-spin-button':
- {
- WebkitAppearance: 'none',
- margin: 0
- },
'& input[type=number]': {
- MozAppearance: 'textfield'
+ MozAppearance: 'textfield',
},
'& .MuiInputAdornment-root': {
'& button': {
- color: '#CF1FB1',
+ color: 'var(--accent1)',
'&:hover': {
- backgroundColor: 'transparent'
- }
- }
- }
-}))
+ backgroundColor: 'transparent',
+ },
+ },
+ },
+}));
interface CustomPaginationProps {
- page: number
- pageSize: number
- totalItems: number
- onPageChange: (page: number) => void
- onPageSizeChange: (pageSize: number) => void
+ page: number;
+ pageSize: number;
+ totalItems: number;
+ onPageChange: (page: number) => void;
+ onPageSizeChange: (pageSize: number) => void;
}
const CustomPagination = React.memo(function CustomPagination({
@@ -144,63 +143,59 @@ const CustomPagination = React.memo(function CustomPagination({
pageSize,
totalItems,
onPageChange,
- onPageSizeChange
+ onPageSizeChange,
}: CustomPaginationProps) {
- const [pageInput, setPageInput] = useState('')
- const totalPages = Math.ceil(totalItems / pageSize)
+ const [pageInput, setPageInput] = useState('');
+ const totalPages = Math.ceil(totalItems / pageSize);
- const theme = useTheme()
- const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
+ const theme = useTheme();
+ const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const handlePageChange = useCallback(
(event: React.ChangeEvent, value: number) => {
- onPageChange(value)
+ onPageChange(value);
},
[onPageChange]
- )
+ );
const handlePageSizeChange = useCallback(
(event: any) => {
- onPageSizeChange(Number(event.target.value))
+ onPageSizeChange(Number(event.target.value));
},
[onPageSizeChange]
- )
+ );
const handlePageJump = () => {
- const newPage = parseInt(pageInput)
+ const newPage = parseInt(pageInput);
if (!isNaN(newPage) && newPage > 0 && newPage <= totalPages) {
- const searchParams = new URLSearchParams(window.location.search)
- searchParams.set('page', String(newPage))
- window.history.replaceState(null, '', `?${searchParams.toString()}`)
+ const searchParams = new URLSearchParams(window.location.search);
+ searchParams.set('page', String(newPage));
+ window.history.replaceState(null, '', `?${searchParams.toString()}`);
- onPageChange(newPage)
+ onPageChange(newPage);
}
- setPageInput('')
- }
+ setPageInput('');
+ };
useEffect(() => {
- const searchParams = new URLSearchParams(window.location.search)
- const urlPage = searchParams.get('page')
+ const searchParams = new URLSearchParams(window.location.search);
+ const urlPage = searchParams.get('page');
if (urlPage) {
- const parsedPage = parseInt(urlPage)
+ const parsedPage = parseInt(urlPage);
if (!isNaN(parsedPage) && parsedPage >= 1 && parsedPage <= totalPages) {
if (parsedPage !== page) {
- onPageChange(parsedPage)
+ onPageChange(parsedPage);
}
- setPageInput(String(parsedPage))
+ setPageInput(String(parsedPage));
}
}
- }, [onPageChange, page, totalPages])
+ }, [onPageChange, page, totalPages]);
if (isMobile) {
return (
-
onPageChange(page - 1)}
- disabled={page === 1}
- >
+ onPageChange(page - 1)} disabled={page === 1}>
@@ -215,11 +210,7 @@ const CustomPagination = React.memo(function CustomPagination({
-
+
{[10, 25, 50, 100].map((size) => (
- )
+ ),
}}
onKeyPress={(e) => e.key === 'Enter' && handlePageJump()}
/>
@@ -286,10 +271,10 @@ const CustomPagination = React.memo(function CustomPagination({
variant="contained"
onClick={handlePageJump}
sx={{
- backgroundColor: '#CF1FB1',
- '&:hover': { backgroundColor: '#A8188D' },
+ backgroundColor: 'var(--accent1)',
+ '&:hover': { backgroundColor: 'var(--accent2)' },
ml: 1,
- fontFamily: "'Sharp Sans', sans-serif"
+ fontFamily: 'var(--font-inter), sans-serif',
}}
>
Go
@@ -297,17 +282,13 @@ const CustomPagination = React.memo(function CustomPagination({
- )
+ );
}
return (
-
onPageChange(page - 1)}
- disabled={page === 1}
- >
-
+ onPageChange(page - 1)} disabled={page === 1}>
+
Previous
@@ -336,7 +317,7 @@ const CustomPagination = React.memo(function CustomPagination({
type="number"
inputProps={{
min: 1,
- max: totalPages
+ max: totalPages,
}}
InputProps={{
endAdornment: (
@@ -346,40 +327,34 @@ const CustomPagination = React.memo(function CustomPagination({
display: 'flex',
flexDirection: 'column',
gap: '0px',
- margin: '-4px -8px -4px 0'
+ margin: '-4px -8px -4px 0',
}}
>
- setPageInput(
- String(Math.min(Number(pageInput || 1) + 1, totalPages))
- )
- }
+ onClick={() => setPageInput(String(Math.min(Number(pageInput || 1) + 1, totalPages)))}
sx={{
- color: '#CF1FB1',
+ color: 'var(--accent1)',
padding: '4px 2px 0 2px',
- '&:hover': { backgroundColor: 'transparent' }
+ '&:hover': { backgroundColor: 'transparent' },
}}
>
- setPageInput(String(Math.max(Number(pageInput || 1) - 1, 1)))
- }
+ onClick={() => setPageInput(String(Math.max(Number(pageInput || 1) - 1, 1)))}
sx={{
- color: '#CF1FB1',
+ color: 'var(--accent1)',
padding: '0 2px 4px 2px',
- '&:hover': { backgroundColor: 'transparent' }
+ '&:hover': { backgroundColor: 'transparent' },
}}
>
- )
+ ),
}}
onKeyPress={(e) => e.key === 'Enter' && handlePageJump()}
/>
@@ -387,10 +362,10 @@ const CustomPagination = React.memo(function CustomPagination({
variant="contained"
onClick={handlePageJump}
sx={{
- backgroundColor: '#CF1FB1',
- '&:hover': { backgroundColor: '#A8188D' },
+ backgroundColor: 'var(--accent1)',
+ '&:hover': { backgroundColor: 'var(--accent2)' },
ml: 1,
- fontFamily: "'Sharp Sans', sans-serif"
+ fontFamily: 'var(--font-inter), sans-serif',
}}
>
Go
@@ -403,10 +378,10 @@ const CustomPagination = React.memo(function CustomPagination({
disabled={page >= totalPages}
>
Next
-
+
- )
-})
+ );
+});
-export default CustomPagination
+export default CustomPagination;
diff --git a/src/components/table/custom-toolbar.tsx b/src/components/table/custom-toolbar.tsx
new file mode 100644
index 00000000..62d8ecae
--- /dev/null
+++ b/src/components/table/custom-toolbar.tsx
@@ -0,0 +1,143 @@
+import { TableTypeEnum } from '@/components/table/table-type';
+import { exportToCsv } from '@/components/table/utils';
+import ClearIcon from '@mui/icons-material/Clear';
+import FileDownloadIcon from '@mui/icons-material/FileDownload';
+import SearchIcon from '@mui/icons-material/Search';
+import { Button, IconButton, styled, TextField } from '@mui/material';
+import {
+ GridApi,
+ GridToolbarColumnsButton,
+ GridToolbarDensitySelector,
+ GridToolbarFilterButton,
+ GridToolbarProps,
+} from '@mui/x-data-grid';
+import React from 'react';
+
+const StyledRoot = styled('div')({
+ display: 'flex',
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ marginBottom: 16,
+});
+
+const StyledButtonsWrapper = styled('div')({
+ display: 'flex',
+ gap: 32,
+
+ '& .MuiButton-root': {
+ color: 'var(--text-primary)',
+ fontFamily: 'var(--font-inter), sans-serif',
+ fontSize: '14px',
+ fontWeight: 400,
+ lineHeight: '21px',
+ textAlign: 'left',
+
+ '& .MuiSvgIcon-root': {
+ color: 'var(--accent1)',
+ },
+ },
+});
+
+const StyledTextField = styled(TextField)({
+ '& .MuiOutlinedInput-root': {
+ backgroundColor: 'var(--background-glass)',
+ border: '1px solid var(--border-glass)',
+ borderRadius: 20,
+ paddingRight: 8,
+
+ '& fieldset': {
+ border: 'none',
+ },
+
+ '&:hover fieldset': {
+ border: 'none',
+ },
+
+ '&.Mui-focused fieldset': {
+ border: 'none',
+ },
+ },
+
+ '& .MuiInputBase-input': {
+ color: 'var(--text-primary)',
+ fontFamily: 'var(--font-inter), sans-serif',
+ fontSize: 14,
+ fontWeight: 400,
+ padding: '4px 16px',
+ },
+
+ '& .MuiIconButton-root': {
+ color: 'var(--accent1)',
+ },
+});
+
+export interface CustomToolbarProps extends GridToolbarProps {
+ searchTerm: string;
+ onSearchChange: (value: string) => void;
+ onSearch: () => void;
+ onReset: () => void;
+ tableType: TableTypeEnum;
+ apiRef?: GridApi;
+ totalUptime: number | null;
+}
+
+const CustomToolbar: React.FC = ({
+ searchTerm,
+ onSearchChange,
+ onSearch,
+ onReset,
+ apiRef,
+ tableType,
+ totalUptime,
+}) => {
+ const handleExport = () => {
+ if (apiRef) {
+ exportToCsv(apiRef, tableType, totalUptime);
+ }
+ };
+
+ const handleKeyPress = (event: React.KeyboardEvent) => {
+ if (event.key === 'Enter' && searchTerm) {
+ event.preventDefault();
+ onSearchChange(searchTerm);
+ }
+ };
+
+ return (
+
+
+
+
+
+ } onClick={handleExport} size="small">
+ Export
+
+
+ onSearchChange(e.target.value)}
+ onKeyPress={handleKeyPress}
+ placeholder="Search..."
+ variant="outlined"
+ size="small"
+ InputProps={{
+ endAdornment: (
+ <>
+
+
+
+ {searchTerm && (
+
+
+
+ )}
+ >
+ ),
+ }}
+ />
+
+ );
+};
+
+export default CustomToolbar;
diff --git a/src/components/table/table-type.ts b/src/components/table/table-type.ts
new file mode 100644
index 00000000..c0343f02
--- /dev/null
+++ b/src/components/table/table-type.ts
@@ -0,0 +1,10 @@
+export enum TableTypeEnum {
+ BENCHMARK_JOBS = 'benchmark-jobs',
+ MY_JOBS = 'my-jobs',
+ MY_NODES = 'my-nodes',
+ NODES_LEADERBOARD = 'nodes-leaderboard',
+ NODES_TOP_JOBS = 'nodes-top-jobs',
+ NODES_TOP_REVENUE = 'nodes-top-revenue',
+ UNBAN_REQUESTS = 'unban-requests',
+ BENCHMARK_JOBS_HISTORY = 'benchmark-jobs-history',
+}
diff --git a/src/components/table/table.tsx b/src/components/table/table.tsx
new file mode 100644
index 00000000..f30ee33f
--- /dev/null
+++ b/src/components/table/table.tsx
@@ -0,0 +1,359 @@
+import {
+ jobsColumns,
+ nodesLeaderboardColumns,
+ topNodesByJobsColumns,
+ topNodesByRevenueColumns,
+ unbanRequestsColumns,
+} from '@/components/table/columns';
+import { TableContextType } from '@/components/table/context-type';
+import CustomPagination from '@/components/table/custom-pagination';
+import CustomToolbar, { CustomToolbarProps } from '@/components/table/custom-toolbar';
+import { TableTypeEnum } from '@/components/table/table-type';
+import styled from '@emotion/styled';
+import {
+ DataGrid,
+ GridColDef,
+ GridFilterModel,
+ GridInitialState,
+ GridRowIdGetter,
+ GridSortModel,
+ GridValidRowModel,
+ useGridApiRef,
+} from '@mui/x-data-grid';
+import { GridSlotsComponentsProps, GridToolbarProps } from '@mui/x-data-grid/internals';
+import { JSXElementConstructor, useCallback, useMemo, useRef, useState } from 'react';
+
+const StyledRoot = styled('div')({
+ display: 'flex',
+ flexDirection: 'column',
+ gap: 16,
+ overflow: 'hidden',
+ width: '100%',
+});
+
+const StyledDataGridWrapper = styled('div')<{ autoHeight?: boolean }>(({ autoHeight }) => ({
+ height: autoHeight ? 'auto' : 'calc(100vh - 200px)',
+ width: '100%',
+}));
+
+const StyledDataGrid = styled(DataGrid)({
+ background: 'none',
+ border: 'none',
+ borderBottom: '1px solid var(--border-glass)',
+ borderRadius: 0,
+ color: 'var(--text-primary)',
+
+ '& .MuiDataGrid-columnHeaders': {
+ backgroundColor: 'var(--background-glass-opaque)',
+ borderRadius: 0,
+
+ '& .MuiDataGrid-columnHeader, & .MuiDataGrid-filler': {
+ background: 'none',
+ borderBottomColor: 'var(--border-glass)',
+
+ '& .MuiDataGrid-columnHeaderTitle': {
+ fontSize: 14,
+ fontWeight: 600,
+ whiteSpace: 'normal',
+ },
+
+ '& .MuiDataGrid-sortButton': {
+ color: 'var(--text-primary)',
+ },
+
+ '& .MuiDataGrid-columnSeparator': {
+ color: 'var(--border-glass)',
+ },
+ },
+ },
+
+ '& .MuiDataGrid-main': {
+ '& .MuiDataGrid-filler': {
+ background: 'rgba(0, 0, 0, 0.3)',
+
+ '& > div': {
+ borderTop: '1px solid var(--border-glass)',
+ },
+ },
+ },
+
+ '& .MuiDataGrid-row': {
+ '&:hover': {
+ backgroundColor: 'var(--background-glass)',
+ },
+ },
+
+ '& .MuiDataGrid-cell': {
+ borderTopColor: 'var(--border-glass)',
+ fontFamily: 'var(--font-inter), sans-serif',
+ fontSize: 14,
+ },
+
+ '& .MuiDataGrid-overlay': {
+ // backgroundColor: 'rgba(0, 0, 0, 0.3)',
+ background: 'none',
+ },
+
+ '& .MuiLinearProgress-root': {
+ backgroundColor: 'var(--background-glass)',
+
+ '& .MuiLinearProgress-bar': {
+ backgroundColor: 'var(--accent1)',
+ },
+ },
+
+ '& .MuiSkeleton-root': {
+ backgroundColor: 'rgba(255, 255, 255, 0.1)',
+ },
+});
+
+type TableProps = {
+ autoHeight?: boolean;
+ context?: TableContextType;
+ data?: any[];
+ loading?: boolean;
+ // TODO internal pagination
+ paginationType: 'context' | 'none';
+ tableType: TableTypeEnum;
+ showToolbar?: boolean;
+ getRowId?: GridRowIdGetter;
+};
+
+export const Table = ({
+ autoHeight,
+ context,
+ data: propsData,
+ loading: propsLoading,
+ paginationType,
+ showToolbar,
+ tableType,
+ getRowId,
+}: TableProps) => {
+ const apiRef = useGridApiRef();
+
+ const searchTimeout = useRef(null);
+
+ const [searchTerm, setSearchTerm] = useState('');
+
+ const loading = propsLoading ?? context?.loading;
+
+ const { currentPage, data, pageSize, totalItems } = useMemo(() => {
+ if (paginationType === 'context') {
+ return {
+ currentPage: context?.crtPage ?? 1,
+ data: context?.data ?? [],
+ pageSize: context?.pageSize ?? 0,
+ totalItems: context?.totalItems ?? 0,
+ };
+ }
+ return {
+ currentPage: 1,
+ data: propsData ?? [],
+ pageSize: propsData?.length ?? 0,
+ totalItems: propsData?.length ?? 0,
+ };
+ }, [paginationType, propsData, context?.crtPage, context?.data, context?.pageSize, context?.totalItems]);
+
+ const columns = useMemo(() => {
+ switch (tableType) {
+ case TableTypeEnum.BENCHMARK_JOBS:
+ case TableTypeEnum.MY_JOBS: {
+ return jobsColumns;
+ }
+ case TableTypeEnum.UNBAN_REQUESTS: {
+ return unbanRequestsColumns;
+ }
+ case TableTypeEnum.NODES_LEADERBOARD:
+ case TableTypeEnum.MY_NODES:
+ case TableTypeEnum.NODES_TOP_JOBS:
+ case TableTypeEnum.NODES_TOP_REVENUE: {
+ return nodesLeaderboardColumns;
+ }
+ case TableTypeEnum.NODES_TOP_JOBS: {
+ return topNodesByJobsColumns;
+ }
+ case TableTypeEnum.NODES_TOP_REVENUE: {
+ return topNodesByRevenueColumns;
+ }
+ case TableTypeEnum.BENCHMARK_JOBS_HISTORY: {
+ return jobsColumns;
+ }
+ }
+ }, [tableType]);
+
+ const handlePaginationChange = useCallback(
+ (model: { page: number; pageSize: number }) => {
+ if (paginationType === 'context' && context) {
+ context.setCrtPage(model.page);
+ context.setPageSize(model.pageSize);
+ }
+ },
+ [context, paginationType]
+ );
+
+ const handleSortModelChange = useCallback(
+ (model: GridSortModel) => {
+ if (paginationType === 'context' && context) {
+ if (model.length > 0) {
+ const { field, sort } = model[0];
+ const filterModel: GridFilterModel = {
+ items: [
+ {
+ id: 1,
+ field,
+ operator: 'sort',
+ value: sort,
+ },
+ ],
+ };
+ context.setFilterModel(filterModel);
+ }
+ }
+ },
+ [context, paginationType]
+ );
+
+ const handleFilterModelChange = useCallback(
+ (model: GridFilterModel) => {
+ if (paginationType === 'context' && context) {
+ context.setFilterModel(model);
+ }
+ },
+ [context, paginationType]
+ );
+
+ const handleResetFilters = useCallback(() => {
+ setSearchTerm('');
+ if (paginationType === 'context' && context) {
+ const emptyFilter: GridFilterModel = { items: [] };
+ context.setFilterModel(emptyFilter);
+ if (context.setSearchTerm) {
+ context.setSearchTerm('');
+ }
+ if (searchTimeout.current) {
+ clearTimeout(searchTimeout.current);
+ }
+ }
+ }, [context, paginationType, setSearchTerm]);
+
+ const handleSearchChange = useCallback(
+ (term: string) => {
+ setSearchTerm(term);
+ if (searchTimeout.current) {
+ clearTimeout(searchTimeout.current);
+ }
+ searchTimeout.current = setTimeout(() => {
+ if (paginationType === 'context' && context?.setSearchTerm) {
+ context.setSearchTerm(term);
+ }
+ }, 500);
+ },
+ [context, paginationType, setSearchTerm]
+ );
+
+ const processRowUpdate = useCallback((newRow: GridValidRowModel, oldRow: GridValidRowModel): GridValidRowModel => {
+ const processCell = (value: unknown) => {
+ if (typeof value === 'object' && value !== null) {
+ if ('dns' in value || 'ip' in value) {
+ const dnsIpObj = value as { dns?: string; ip?: string; port?: string };
+ return `${dnsIpObj.dns || dnsIpObj.ip}${dnsIpObj.port ? ':' + dnsIpObj.port : ''}`;
+ }
+
+ if ('city' in value || 'country' in value) {
+ const locationObj = value as { city?: string; country?: string };
+ return `${locationObj.city} ${locationObj.country}`;
+ }
+ }
+ return value;
+ };
+ return Object.fromEntries(
+ Object.entries(newRow).map(([key, value]) => [key, processCell(value)])
+ ) as GridValidRowModel;
+ }, []);
+
+ const initialState = useMemo(() => {
+ const coreState: GridInitialState = { density: 'comfortable' };
+ return paginationType === 'none'
+ ? coreState
+ : {
+ ...coreState,
+ pagination: {
+ paginationModel: {
+ pageSize: pageSize,
+ page: currentPage - 1,
+ },
+ },
+ };
+ }, [currentPage, pageSize, paginationType]);
+
+ const paginationModel =
+ paginationType === 'none'
+ ? undefined
+ : {
+ page: currentPage - 1,
+ pageSize: pageSize,
+ };
+
+ const slotProps: GridSlotsComponentsProps & { toolbar: Partial } = {
+ basePopper: {
+ style: {
+ color: '#000000',
+ },
+ },
+ loadingOverlay: {
+ variant: 'skeleton',
+ noRowsVariant: 'skeleton',
+ },
+ toolbar: {
+ searchTerm,
+ onSearchChange: handleSearchChange,
+ onReset: handleResetFilters,
+ tableType: tableType,
+ apiRef: apiRef.current ?? undefined,
+ // totalUptime: totalUptime,
+ },
+ };
+
+ const defaultGetRowId: GridRowIdGetter = (row) => row.id;
+
+ return (
+
+
+ []}
+ disableColumnMenu
+ disableRowSelectionOnClick
+ filterMode={paginationType === 'none' ? 'client' : 'server'}
+ getRowId={getRowId ?? defaultGetRowId}
+ hideFooter
+ initialState={initialState}
+ loading={loading ?? context?.loading}
+ onFilterModelChange={handleFilterModelChange}
+ onPaginationModelChange={handlePaginationChange}
+ onSortModelChange={handleSortModelChange}
+ pageSizeOptions={[10, 25, 50, 100]}
+ // pagination
+ paginationMode={paginationType === 'none' ? undefined : 'server'}
+ paginationModel={paginationModel}
+ processRowUpdate={processRowUpdate}
+ rowCount={totalItems}
+ rows={data}
+ showToolbar={showToolbar}
+ slots={{ toolbar: CustomToolbar as JSXElementConstructor }}
+ slotProps={slotProps}
+ sortingMode={paginationType === 'none' ? 'client' : 'server'}
+ />
+
+ {paginationType === 'none' ? null : (
+ handlePaginationChange({ page, pageSize })}
+ onPageSizeChange={(pageSize: number) => handlePaginationChange({ page: currentPage, pageSize })}
+ />
+ )}
+
+ );
+};
diff --git a/src/components/table/utils.ts b/src/components/table/utils.ts
new file mode 100644
index 00000000..0dc35355
--- /dev/null
+++ b/src/components/table/utils.ts
@@ -0,0 +1,133 @@
+import { TableTypeEnum } from '@/components/table/table-type';
+import { type Node } from '@/types';
+import { GridApi } from '@mui/x-data-grid';
+
+export const getAllNetworks = (indexers: Node['indexer']): string => {
+ return indexers?.map((indexer) => indexer.network).join(', ') || '';
+};
+
+export const formatSupportedStorage = (supportedStorage: Node['supportedStorage']): string => {
+ const storageTypes = [];
+
+ if (supportedStorage?.url) storageTypes.push('URL');
+ if (supportedStorage?.arwave) storageTypes.push('Arweave');
+ if (supportedStorage?.ipfs) storageTypes.push('IPFS');
+
+ return storageTypes.join(', ');
+};
+
+export const formatPlatform = (platform: Node['platform']): string => {
+ if (platform) {
+ const { cpus, arch, machine, platform: platformName, osType, node } = platform;
+ return `CPUs: ${cpus}, Architecture: ${arch}, Machine: ${machine}, Platform: ${platformName}, OS Type: ${osType}, Node.js: ${node}`;
+ }
+ return '';
+};
+
+export const formatUptime = (uptimeInSeconds: number): string => {
+ const days = Math.floor(uptimeInSeconds / (3600 * 24));
+ const hours = Math.floor((uptimeInSeconds % (3600 * 24)) / 3600);
+ const minutes = Math.floor((uptimeInSeconds % 3600) / 60);
+
+ const dayStr = days > 0 ? `${days} day${days > 1 ? 's' : ''} ` : '';
+ const hourStr = hours > 0 ? `${hours} hour${hours > 1 ? 's' : ''} ` : '';
+ const minuteStr = minutes > 0 ? `${minutes} minute${minutes > 1 ? 's' : ''}` : '';
+
+ return `${dayStr}${hourStr}${minuteStr}`.trim();
+};
+
+export const formatUptimePercentage = (uptimeInSeconds: number, totalUptime: number | null | undefined): string => {
+ const defaultTotalUptime = 7 * 24 * 60 * 60;
+
+ const actualTotalUptime = totalUptime || defaultTotalUptime;
+
+ const uptimePercentage = (uptimeInSeconds / actualTotalUptime) * 100;
+ const percentage = uptimePercentage > 100 ? 100 : uptimePercentage;
+ return `${percentage.toFixed(2)}%`;
+};
+
+export const exportToCsv = (apiRef: GridApi, tableType: TableTypeEnum, totalUptime: number | null) => {
+ if (!apiRef) return;
+
+ const columns = apiRef.getAllColumns().filter((col) => {
+ if (tableType === TableTypeEnum.NODES_LEADERBOARD) {
+ return col.field !== 'viewMore' && col.field !== 'location';
+ }
+ return true;
+ });
+
+ const rows = apiRef.getRowModels();
+
+ const formattedRows = Array.from(rows.values()).map((row) => {
+ const formattedRow: Record = {};
+
+ columns.forEach((column) => {
+ const field = column.field;
+ const value = row[field];
+
+ // if (tableType === TableTypeEnum.COUNTRIES) {
+ // if (field === 'cityWithMostNodes') {
+ // const cityName = row.cityWithMostNodes || '';
+ // const nodeCount = row.cityWithMostNodesCount || 0;
+ // formattedRow[column.headerName || field] = `${cityName} (${nodeCount})`;
+ // } else {
+ // formattedRow[column.headerName || field] = String(value || '');
+ // }
+ // } else {
+ // if (field === 'uptime') {
+ // formattedRow[column.headerName || field] = formatUptimePercentage(value, totalUptime);
+ // } else if (field === 'network') {
+ // const networks = row.provider?.map((p: { network: string }) => p.network).join(', ') || '';
+ // formattedRow[column.headerName || field] = networks;
+ // } else if (field === 'dnsFilter') {
+ // const ipAndDns = row.ipAndDns as { dns?: string; ip?: string; port?: string };
+ // formattedRow[column.headerName || field] =
+ // `${ipAndDns?.dns || ''} ${ipAndDns?.ip || ''} ${ipAndDns?.port ? ':' + ipAndDns?.port : ''}`.trim();
+ // } else if (field === 'city') {
+ // formattedRow[column.headerName || field] = row.location?.city || '';
+ // } else if (field === 'country') {
+ // formattedRow[column.headerName || field] = row.location?.country || '';
+ // } else if (field === 'platform') {
+ // formattedRow[column.headerName || field] = formatPlatform(value);
+ // } else if (field === 'supportedStorage') {
+ // formattedRow[column.headerName || field] = formatSupportedStorage(value);
+ // } else if (field === 'indexer') {
+ // formattedRow[column.headerName || field] = getAllNetworks(value);
+ // } else if (field === 'lastCheck') {
+ // formattedRow[column.headerName || field] = new Date(value).toLocaleString();
+ // } else if (typeof value === 'boolean') {
+ // formattedRow[column.headerName || field] = value ? 'Yes' : 'No';
+ // } else if (Array.isArray(value)) {
+ // formattedRow[column.headerName || field] = value.join(', ');
+ // } else if (field === 'eligibilityCauseStr') {
+ // formattedRow[column.headerName || field] = value || 'none';
+ // } else {
+ // formattedRow[column.headerName || field] = String(value || '');
+ // }
+ // }
+ });
+ return formattedRow;
+ });
+
+ const headers = Object.keys(formattedRows[0]);
+ const csvRows = [
+ headers.join(','),
+ ...formattedRows.map((row) =>
+ headers
+ .map((header) => {
+ const value = row[header];
+ return value.includes(',') || value.includes('"') ? `"${value.replace(/"/g, '""')}"` : value;
+ })
+ .join(',')
+ ),
+ ].join('\n');
+
+ const blob = new Blob(['\ufeff' + csvRows], { type: 'text/csv;charset=utf-8;' });
+ const link = document.createElement('a');
+ link.href = URL.createObjectURL(blob);
+ link.download = `${tableType}_export_${new Date().toISOString()}.csv`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(link.href);
+};
diff --git a/src/components/withdraw/withdraw-page.module.css b/src/components/withdraw/withdraw-page.module.css
new file mode 100644
index 00000000..4b59fa9e
--- /dev/null
+++ b/src/components/withdraw/withdraw-page.module.css
@@ -0,0 +1,4 @@
+.content {
+ align-self: center;
+ max-width: 700px;
+}
diff --git a/src/components/withdraw/withdraw-page.tsx b/src/components/withdraw/withdraw-page.tsx
new file mode 100644
index 00000000..bbe64464
--- /dev/null
+++ b/src/components/withdraw/withdraw-page.tsx
@@ -0,0 +1,22 @@
+import Container from '@/components/container/container';
+import SectionTitle from '@/components/section-title/section-title';
+import Withdraw from '@/components/withdraw/withdraw';
+import classNames from 'classnames';
+import styles from './withdraw-page.module.css';
+
+const WithdrawPage = () => {
+ return (
+
+
+
+
+
+
+ );
+};
+
+export default WithdrawPage;
diff --git a/src/components/withdraw/withdraw.module.css b/src/components/withdraw/withdraw.module.css
new file mode 100644
index 00000000..691eabb1
--- /dev/null
+++ b/src/components/withdraw/withdraw.module.css
@@ -0,0 +1,43 @@
+.balance {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 12px 24px;
+
+ @media (min-width: 768px) {
+ align-items: center;
+ display: grid;
+ grid-template-columns: 1fr auto;
+ }
+
+ .values {
+ text-align: right;
+
+ .token {
+ color: var(--text-secondary);
+ font-size: 14px;
+ }
+
+ .amount {
+ font-size: 32px;
+ font-weight: 700;
+ }
+ }
+
+ .sm {
+ color: var(--text-secondary);
+ font-size: 16px;
+
+ .token {
+ font-size: 12px;
+ }
+
+ .amount {
+ font-size: 20px;
+ }
+ }
+}
+
+.nextButton {
+ align-self: center;
+}
diff --git a/src/components/withdraw/withdraw.tsx b/src/components/withdraw/withdraw.tsx
new file mode 100644
index 00000000..25009fc5
--- /dev/null
+++ b/src/components/withdraw/withdraw.tsx
@@ -0,0 +1,35 @@
+import Button from '@/components/button/button';
+import Card from '@/components/card/card';
+import Input from '@/components/input/input';
+import DownloadIcon from '@mui/icons-material/Download';
+import classNames from 'classnames';
+import styles from './withdraw.module.css';
+
+// TODO replace mock data
+
+const Withdraw = () => {
+ return (
+
+
+ User available funds in escrow
+
+ OCEAN
+
+ {99}
+
+ User available funds in wallet
+
+ OCEAN
+
+ {12345.6789}
+
+
+
+ } size="lg">
+ Withdraw
+
+
+ );
+};
+
+export default Withdraw;
diff --git a/src/config.ts b/src/config.ts
index 86ad8ef9..90e2111d 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -1,113 +1,133 @@
type Route = {
- path: string
- name: string
-}
+ path: string;
+ name: string;
+};
type Routes = {
- [key: string]: Route
-}
-
-type ApiRoute = {
- root: 'incentive' | 'analytics'
- path: string
-}
-
-type ApiRoutes = {
- [key: string]: ApiRoute
-}
+ [key: string]: Route;
+};
type SocialMedia = {
- [key: string]: string
-}
+ [key: string]: string;
+};
type Config = {
- backendUrl: string
- routes: Routes
- apiRoutes: ApiRoutes
- socialMedia: SocialMedia
+ backendUrl: string;
+ routes: Routes;
+ socialMedia: SocialMedia;
links: {
- website: string
- github: string
- }
+ website: string;
+ github: string;
+ };
queryParams: {
- [key: string]: string
- }
+ [key: string]: string;
+ };
cookies: {
- [key: string]: string
- }
-}
-
-const API_ROOTS = {
- incentive: 'https://incentive-backend.oceanprotocol.com',
- analytics: 'https://analytics.nodes.oceanprotocol.com'
-} as const
-
-const apiRoutes = {
- // Incentive API routes
- nodes: { root: 'incentive', path: '/nodes' },
- locations: { root: 'incentive', path: '/locations' },
- countryStats: { root: 'incentive', path: '/countryStats' },
- nodeSystemStats: { root: 'incentive', path: '/nodeSystemStats' },
- history: { root: 'incentive', path: '/history' },
- weekStats: { root: 'incentive', path: '/weekStats' },
-
- // Analytics API routes
- analyticsSummary: { root: 'analytics', path: '/summary' },
- analyticsAllSummary: { root: 'analytics', path: '/all-summary' },
- analyticsRewardsHistory: { root: 'analytics', path: '/rewards-history' }
-} as const
-
-type ApiRouteKeys = keyof typeof apiRoutes
+ [key: string]: string;
+ };
+};
const config: Config = {
- backendUrl:
- process.env.NEXT_PUBLIC_API_URL || 'https://incentive-backend.oceanprotocol.com',
+ backendUrl: process.env.NEXT_PUBLIC_API_URL || 'https://incentive-backend.oceanprotocol.com',
routes: {
home: {
path: '/',
- name: 'Home'
+ name: 'Home',
+ },
+ runJob: {
+ path: '/run-job/environments',
+ name: 'Run Job',
+ },
+ stats: {
+ path: '/stats',
+ name: 'Stats',
+ },
+ docs: {
+ path: '/docs',
+ name: 'Docs',
},
- nodes: {
- path: '/nodes',
- name: 'Nodes'
+ leaderboard: {
+ path: '/leaderboard',
+ name: 'Leaderboard',
},
- countries: {
- path: '/countries',
- name: 'Countries'
+ runNode: {
+ path: '/run-node/setup',
+ name: 'Run Node',
},
- history: {
- path: '/history',
- name: 'History'
- }
},
- apiRoutes,
socialMedia: {
medium: 'https://medium.com/oceanprotocol',
twitter: 'https://twitter.com/oceanprotocol',
discord: 'https://discord.gg/CjdsWngg47',
youtube: 'https://www.youtube.com/channel/UCH8TXwmWWAE9TZO0yTBHB3A',
- telegram: 'https://t.me/oceanprotocol'
+ telegram: 'https://t.me/oceanprotocol',
},
links: {
website: 'https://oceanprotocol.com/',
- github: 'https://github.com/oceanprotocol/ocean-node'
+ github: 'https://github.com/oceanprotocol/ocean-node',
},
queryParams: {
accessToken: 'access_token',
- status: 'status'
+ status: 'status',
},
cookies: {
- accessToken: 'access_token'
- }
-}
+ accessToken: 'access_token',
+ },
+};
+
+export default config;
+
+export const getRoutes = (): Routes => config.routes;
+export const getSocialMedia = (): SocialMedia => config.socialMedia;
+export const getLinks = () => config.links;
+
+const API_ROOTS = {
+ ens: 'https://ens-proxy.oceanprotocol.com/api',
+ incentive: 'https://incentive-backend.oceanprotocol.io',
+ incentive_old: 'https://incentive-backend.oceanprotocol.com',
+ analytics: 'https://analytics.nodes.oceanprotocol.io',
+} as const;
+
+const apiRoutes = {
+ // Incentive API routes
+ environments: { root: 'incentive', path: '/envs' },
+ nodes: { root: 'incentive', path: '/nodes' },
+ locations: { root: 'incentive', path: '/locations' },
+ countryStats: { root: 'incentive', path: '/countryStats' },
+ nodeSystemStats: { root: 'incentive_old', path: '/nodeSystemStats' },
+ history: { root: 'incentive', path: '/history' },
+ weekStats: { root: 'incentive', path: '/weekStats' },
+ banStatus: { root: 'incentive', path: '/nodes' },
+ nodeBenchmarkMinMaxLast: { root: 'incentive', path: '/nodes' },
+ benchmarkHistory: { root: 'incentive', path: '/nodes' },
+ nodeUnbanRequests: { root: 'incentive', path: '/nodes' },
+ owners: { root: 'incentive', path: '/owners' },
+ admin: { root: 'incentive', path: '/admin' },
+ jobsSuccessRate: { root: 'incentive', path: '/consumers' },
+ nodesStats: { root: 'incentive', path: '/owners' },
+
+ // Analytics API routes
+ analyticsSummary: { root: 'analytics', path: '/summary' },
+ analyticsAllSummary: { root: 'analytics', path: '/all-summary' },
+ analyticsRewardsHistory: { root: 'analytics', path: '/rewards-history' },
+ analyticsGlobalStats: { root: 'analytics', path: '/global-stats' },
+ gpuPopularity: { root: 'analytics', path: '/gpu-popularity' },
+ topNodesByRevenue: { root: 'analytics', path: '/nodes' },
+ topNodesByJobCount: { root: 'analytics', path: '/nodes' },
+ nodeStats: { root: 'analytics', path: '/nodes' },
+ consumerStats: { root: 'analytics', path: '/consumers' },
+ ownerStats: { root: 'analytics', path: '/owners' },
+
+ // ENS API routes
+ ensAddress: { root: 'ens', path: '/address' },
+ ensName: { root: 'ens', path: '/name' },
+ ensProfile: { root: 'ens', path: '/profile' },
+} as const;
-export default config
+type ApiRouteKeys = keyof typeof apiRoutes;
-export const getRoutes = (): Routes => config.routes
-export const getSocialMedia = (): SocialMedia => config.socialMedia
-export const getLinks = () => config.links
-export const getApiRoute = (key: ApiRouteKeys, param?: string | number): string => {
- const route = apiRoutes[key]
- const baseUrl = API_ROOTS[route.root]
- return `${baseUrl}${route.path}`
-}
+export const getApiRoute = (key: ApiRouteKeys): string => {
+ const route = apiRoutes[key];
+ const baseUrl = API_ROOTS[route.root];
+ return `${baseUrl}${route.path}`;
+};
diff --git a/src/constants/chains.ts b/src/constants/chains.ts
new file mode 100644
index 00000000..5eda7ec0
--- /dev/null
+++ b/src/constants/chains.ts
@@ -0,0 +1,4 @@
+export const BASE_CHAIN_ID = 8453;
+export const ETH_SEPOLIA_CHAIN_ID = 11155111;
+
+export const CHAIN_ID = process.env.NODE_ENV === 'production' ? BASE_CHAIN_ID : ETH_SEPOLIA_CHAIN_ID;
diff --git a/src/constants/tokens.ts b/src/constants/tokens.ts
new file mode 100644
index 00000000..1aa928c6
--- /dev/null
+++ b/src/constants/tokens.ts
@@ -0,0 +1,2 @@
+export const ETH_SEPOLIA_ADDRESS = '0xb16F35c0Ae2912430DAc15764477E179D9B9EbEa';
+export const USDC_TOKEN_ADDRESS = '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238';
diff --git a/src/context/AdminProvider.tsx b/src/context/AdminProvider.tsx
deleted file mode 100644
index 641a48bb..00000000
--- a/src/context/AdminProvider.tsx
+++ /dev/null
@@ -1,150 +0,0 @@
-import {
- createContext,
- useContext,
- useState,
- ReactNode,
- FunctionComponent,
- Dispatch,
- SetStateAction,
- useEffect
-} from 'react'
-import { useAccount, useSignMessage } from 'wagmi'
-import { verifyMessage } from 'ethers'
-
-interface network {
- chainId: number
- network: string
-}
-
-interface AdminContextType {
- admin: boolean
- setAdmin: Dispatch>
- allAdmins: string[]
- setAllAdmins: Dispatch>
- expiryTimestamp: number | undefined
- setExpiryTimestamp: Dispatch>
- generateSignature: () => void
- signature: string | undefined
- setSignature: Dispatch>
- validTimestamp: boolean
- setValidTimestamp: Dispatch>
- networks: network[]
- setNetworks: Dispatch>
-}
-
-const AdminContext = createContext(undefined)
-
-export const AdminProvider: FunctionComponent<{ children: ReactNode }> = ({
- children
-}) => {
- const { address, isConnected } = useAccount()
- const { signMessage, data: signMessageData } = useSignMessage()
- const [admin, setAdmin] = useState(false)
- const [allAdmins, setAllAdmins] = useState([])
- const [expiryTimestamp, setExpiryTimestamp] = useState()
- const [signature, setSignature] = useState()
- const [validTimestamp, setValidTimestamp] = useState(true)
- const [networks, setNetworks] = useState([])
-
- // Ensure signature and expiry are cleared when the account is changed or disconnected
- useEffect(() => {
- if (!isConnected || !address) {
- setSignature(undefined)
- setExpiryTimestamp(undefined)
- }
- }, [address, isConnected])
-
- // Get expiryTimestamp and signature from localStorage
- useEffect(() => {
- const storedExpiry = localStorage.getItem('expiryTimestamp')
- const storedExpiryTimestamp = storedExpiry ? parseInt(storedExpiry, 10) : null
- if (storedExpiryTimestamp && storedExpiryTimestamp > Date.now()) {
- setExpiryTimestamp(storedExpiryTimestamp)
- const storedSignature = localStorage.getItem('signature')
- if (storedSignature) {
- setSignature(storedSignature)
- }
- }
- }, [address, isConnected])
-
- // Store signature and expiryTimestamp in localStorage
- useEffect(() => {
- if (expiryTimestamp && expiryTimestamp > Date.now()) {
- localStorage.setItem('expiryTimestamp', expiryTimestamp.toString())
- signature && localStorage.setItem('signature', signature)
- }
- }, [expiryTimestamp, signature, address, isConnected])
-
- useEffect(() => {
- if (signMessageData) {
- setSignature(signMessageData)
- }
- }, [signMessageData, address, isConnected])
-
- useEffect(() => {
- const interval = setInterval(() => {
- if (expiryTimestamp) {
- const now = Date.now()
- setValidTimestamp(now < expiryTimestamp)
- }
- }, 300000) // Check every 5 minutes
-
- return () => clearInterval(interval)
- }, [expiryTimestamp, address, isConnected])
-
- const generateSignature = () => {
- const newExpiryTimestamp = Date.now() + 12 * 60 * 60 * 1000 // 12 hours ahead in milliseconds
- signMessage({
- message: newExpiryTimestamp.toString()
- })
- setExpiryTimestamp(newExpiryTimestamp)
- }
-
- // Remove signature and expiryTimestamp from state if they are not from the currently connected account
- useEffect(() => {
- if (expiryTimestamp && signature) {
- const signerAddress = verifyMessage(
- expiryTimestamp.toString(),
- signature
- ).toLowerCase()
- if (signerAddress !== address?.toLowerCase()) {
- setExpiryTimestamp(undefined)
- setSignature(undefined)
- }
- }
- }, [address, expiryTimestamp, signature])
-
- const value: AdminContextType = {
- admin,
- setAdmin,
- allAdmins,
- setAllAdmins,
- expiryTimestamp,
- setExpiryTimestamp,
- generateSignature,
- signature,
- setSignature,
- validTimestamp,
- setValidTimestamp,
- networks,
- setNetworks
- }
-
- // Update admin status based on current address
- useEffect(() => {
- const isAdmin = allAdmins.some(
- (adminAddress) => address && adminAddress.toLowerCase() === address.toLowerCase()
- )
- setAdmin(isAdmin)
- }, [address, allAdmins, isConnected])
-
- return {children}
-}
-
-export const useAdminContext = () => {
- const context = useContext(AdminContext)
- if (context === undefined) {
- throw new Error('AdminContext must be used within an AdminProvider')
- }
- return context
-}
diff --git a/src/context/CountriesContext.tsx b/src/context/CountriesContext.tsx
deleted file mode 100644
index cf2b9782..00000000
--- a/src/context/CountriesContext.tsx
+++ /dev/null
@@ -1,170 +0,0 @@
-import React, {
- createContext,
- useState,
- useEffect,
- useContext,
- ReactNode,
- useCallback
-} from 'react'
-import axios from 'axios'
-import { getApiRoute } from '@/config'
-import { CountryStatsType } from '@/shared/types/dataTypes'
-import { CountryStatsFilters } from '@/shared/types/filters'
-import { GridFilterModel } from '@mui/x-data-grid'
-
-interface CountriesContextType {
- data: CountryStatsType[]
- loading: boolean
- error: any
- currentPage: number
- pageSize: number
- totalItems: number
- searchTerm: string
- sortModel: Record
- filters: CountryStatsFilters
- setCurrentPage: (page: number) => void
- setPageSize: (size: number) => void
- setSearchTerm: (term: string) => void
- setSortModel: (model: Record) => void
- setFilters: (filters: CountryStatsFilters) => void
- setFilter: (filter: GridFilterModel) => void
-}
-
-const CountriesContext = createContext(undefined)
-
-interface CountriesProviderProps {
- children: ReactNode
-}
-
-export const CountriesProvider: React.FC = ({ children }) => {
- // State
- const [data, setData] = useState([])
- const [loading, setLoading] = useState(true)
- const [error, setError] = useState(null)
- const [currentPage, setCurrentPage] = useState(1)
- const [pageSize, setPageSize] = useState(100)
- const [totalItems, setTotalItems] = useState(0)
- const [searchTerm, setSearchTerm] = useState('')
- const [sortModel, setSortModel] = useState>({})
- const [filters, setFilters] = useState({})
-
- // Fetch data
- const fetchCountryStats = useCallback(async () => {
- try {
- const params: Record = {
- page: currentPage,
- pageSize: pageSize
- }
-
- if (searchTerm) {
- params['filters[country][value]'] = searchTerm
- params['filters[country][operator]'] = 'contains'
- }
-
- if (filters && Object.keys(filters).length > 0) {
- Object.entries(filters).forEach(([field, filterData]) => {
- const { value, operator } = filterData
- params[`filters[${field}][value]`] = value
- params[`filters[${field}][operator]`] = operator
- })
- }
-
- if (sortModel && Object.keys(sortModel).length > 0) {
- const [field, order] = Object.entries(sortModel)[0]
- const sortField = field === 'cityWithMostNodes' ? 'cityWithMostNodesCount' : field
- params[`sort[${sortField}]`] = order
- }
-
- const response = await axios.get(getApiRoute('countryStats'), { params })
-
- if (response.data && Array.isArray(response.data.countryStats)) {
- const processedStats = response.data.countryStats.map(
- (country: any, index: number) => ({
- id: country.country,
- index: (currentPage - 1) * pageSize + index + 1,
- country: country.country,
- totalNodes: country.totalNodes,
- citiesWithNodes: country.citiesWithNodes,
- cityWithMostNodes: country.cityWithMostNodes,
- cityWithMostNodesCount: country.cityWithMostNodesCount
- })
- )
- setData(processedStats)
- setTotalItems(response.data.pagination.totalItems)
- } else {
- console.error('Unexpected data structure:', response.data)
- setData([])
- setTotalItems(0)
- }
- } catch (err) {
- console.error('Error fetching country stats:', err)
- setError(err)
- setData([])
- setTotalItems(0)
- } finally {
- setLoading(false)
- }
- }, [currentPage, pageSize, searchTerm, filters, sortModel])
-
- // Effects
- useEffect(() => {
- fetchCountryStats()
- }, [fetchCountryStats])
-
- // Handlers
- const handleSetCurrentPage = (page: number) => {
- setCurrentPage(page)
- }
-
- const handleSetPageSize = (size: number) => {
- setPageSize(size)
- setCurrentPage(1)
- }
-
- const handleSetSearchTerm = (term: string) => {
- setSearchTerm(term)
- setCurrentPage(1)
- }
-
- const handleSetSortModel = (model: Record) => {
- setSortModel(model)
- setCurrentPage(1)
- }
-
- const handleSetFilters = (newFilters: CountryStatsFilters) => {
- setFilters(newFilters)
- setCurrentPage(1)
- }
-
- const handleSetFilter = (filter: GridFilterModel) => {
- // Implementation of setFilter
- }
-
- const value: CountriesContextType = {
- data,
- loading,
- error,
- currentPage,
- pageSize,
- totalItems,
- searchTerm,
- sortModel,
- filters,
- setCurrentPage: handleSetCurrentPage,
- setPageSize: handleSetPageSize,
- setSearchTerm: handleSetSearchTerm,
- setSortModel: handleSetSortModel,
- setFilters: handleSetFilters,
- setFilter: handleSetFilter
- }
-
- return {children}
-}
-
-export const useCountriesContext = () => {
- const context = useContext(CountriesContext)
- if (context === undefined) {
- throw new Error('useCountriesContext must be used within a CountriesProvider')
- }
- return context
-}
diff --git a/src/context/HistoryContext.tsx b/src/context/HistoryContext.tsx
deleted file mode 100644
index 92da5954..00000000
--- a/src/context/HistoryContext.tsx
+++ /dev/null
@@ -1,419 +0,0 @@
-import React, {
- createContext,
- useState,
- useEffect,
- useContext,
- ReactNode,
- useCallback,
- useMemo,
- PropsWithChildren
-} from 'react'
-import {
- getNodeHistory,
- getWeekStats,
- getAllHistoricalWeeklyPeriods,
- PeriodOption,
- RewardsData,
- getAllHistoricalRewards,
- getCurrentWeekStats
-} from '@/services/historyService'
-import { DateRange } from '@/components/PeriodSelect'
-import { NodeData } from '@/shared/types/RowDataType'
-import axios from 'axios'
-
-export interface WeekStatsSource {
- id: number
- week: number
- totalUptime: number
- lastRun: number
- round?: number
- timestamp?: number
-}
-
-export interface HistoryContextType {
- data: any[]
- loading: boolean
- error: any
- currentPage: number
- pageSize: number
- totalItems: number
- nodeId: string
- dateRange: DateRange
- isSearching: boolean
- setNodeId: (id: string) => void
- setDateRange: (range: DateRange) => void
- setCurrentPage: (page: number) => void
- setPageSize: (size: number) => void
- setIsSearching: (isSearching: boolean) => void
- clearHistory: () => void
- fetchHistoryData: () => Promise
- weekStats: WeekStatsSource | null
- loadingWeekStats: boolean
- errorWeekStats: any
- fetchWeekStats: () => Promise
- availablePeriods: PeriodOption[]
- periodsLoading: boolean
- getRewardsForPeriod: (periodId: string | number) => {
- averageReward: number
- totalDistributed: number
- nrEligibleNodes: number
- } | null
- rewardsData: RewardsData[]
- loadingRewards: boolean
- errorRewards: Error | null
- totalProgramDistribution: number
- currentRoundStats: any
- loadingCurrentRound: boolean
- errorCurrentRound: Error | null
- isInitialising: boolean,
- nodesData: NodeData
- loadingNodeData: boolean
- errorNodeData: Error | null
-}
-
-const HistoryContext = createContext(undefined)
-
-export const HistoryProvider: React.FC> = ({ children }) => {
- const [data, setData] = useState([])
- const [loading, setLoading] = useState(false)
- const [error, setError] = useState(null)
- const [currentPage, setCurrentPage] = useState(1)
- const [pageSize, setPageSize] = useState(10)
- const [totalItems, setTotalItems] = useState(0)
- const [nodeId, setNodeId] = useState('')
- const [isSearching, setIsSearching] = useState(false)
- const [dateRange, setDateRange] = useState({
- startDate: null,
- endDate: null
- })
-
- const [weekStats, setWeekStats] = useState(null)
- const [loadingWeekStats, setLoadingWeekStats] = useState(false)
- const [errorWeekStats, setErrorWeekStats] = useState(null)
-
- const [availablePeriods, setAvailablePeriods] = useState([])
- const [periodsLoading, setPeriodsLoading] = useState(true)
-
- const [rewardsData, setRewardsData] = useState([])
- const [loadingRewards, setLoadingRewards] = useState(false)
- const [errorRewards, setErrorRewards] = useState(null)
-
- const [nodesData, setNodesData] = useState({} as NodeData)
- const [loadingNodeData, setLoadingNodeData] = useState(false)
- const [errorNodeData, setErrorNodeData] = useState(null)
-
- const [currentRoundStats, setCurrentRoundStats] = useState(null)
- const [loadingCurrentRound, setLoadingCurrentRound] = useState(false)
- const [errorCurrentRound, setErrorCurrentRound] = useState(null)
- const [isInitialising, setIsInitialising] = useState(true)
-
- useEffect(() => {
- const fetchInitialData = async () => {
- setIsInitialising(true)
- try {
- const doFetchPeriods = async () => {
- setPeriodsLoading(true)
- try {
- const periods = await getAllHistoricalWeeklyPeriods()
- console.log(
- '[HistoryContext] Available periods fetched successfully:',
- periods
- )
- setAvailablePeriods(periods)
- if (periods.length > 0) {
- const mostRecentPeriod = periods[0]
- setDateRange({
- startDate: mostRecentPeriod.startDate,
- endDate: mostRecentPeriod.endDate
- })
- }
- } catch (err) {
- console.error('[HistoryContext] Error fetching available periods:', err)
- } finally {
- setPeriodsLoading(false)
- }
- }
-
- const doFetchRewardsData = async () => {
- setLoadingRewards(true)
- try {
- const data = await getAllHistoricalRewards()
- setRewardsData(data)
- setErrorRewards(null)
- } catch (error) {
- console.error('Error fetching rewards data:', error)
- setErrorRewards(error as Error)
- } finally {
- setLoadingRewards(false)
- }
- }
-
- const doFetchCurrentRound = async () => {
- setLoadingCurrentRound(true)
- try {
- const data = await getCurrentWeekStats()
- console.log(
- '[HistoryContext] Current round stats fetched successfully:',
- data
- )
- setCurrentRoundStats(data)
- setErrorCurrentRound(null)
- } catch (error) {
- console.error('Error fetching current round in initial:', error)
- setErrorCurrentRound(error as Error)
- } finally {
- setLoadingCurrentRound(false)
- }
- }
-
- const doFetchNodeData = async () => {
- if (!nodeId) return
-
- try {
- setLoadingNodeData(true)
-
- const data = await getNodeData(nodeId)
- console.log(
- '[HistoryContext] Node data fetched successfully:',
- data
- )
- if (!data) {
- setErrorNodeData(new Error('Node data is empty or undefined'))
- console.error(
- '[HistoryContext] Node data is empty or undefined for nodeId:',
- nodeId
- )
- } else {
- setNodesData(data)
- setErrorNodeData(null)
- }
- setLoadingNodeData(false)
- } catch (error) {
- console.error('Error fetching node data in initial:', error)
- setErrorNodeData(error as Error)
- }
- }
-
- await Promise.all([doFetchPeriods(), doFetchRewardsData(), doFetchCurrentRound(), doFetchNodeData()])
- } catch (error) {
- console.error(
- '[HistoryContext] Error during initial parallel fetches (Promise.all):',
- error
- )
- } finally {
- setIsInitialising(false)
- }
- }
- fetchInitialData()
- }, [nodeId])
-
- const fetchHistoryData = useCallback(async () => {
- if (!nodeId || !dateRange.startDate || !dateRange.endDate) {
- return
- }
-
- console.log(
- `[HistoryContext] Attempting fetchHistoryData for nodeId: ${nodeId}, page: ${currentPage}, size: ${pageSize}, range: ${dateRange.startDate?.format()} - ${dateRange.endDate?.format()}`
- )
-
- setLoading(true)
- setError(null)
- try {
- const response = await getNodeHistory(
- nodeId,
- currentPage,
- pageSize,
- dateRange.startDate,
- dateRange.endDate
- )
- console.log('[HistoryContext] Received history data:', response)
- setData(response.data || [])
- setTotalItems(response.pagination.totalCount || 0)
- } catch (error) {
- console.error('[HistoryContext] Error during fetchHistoryData:', error)
- setData([])
- setTotalItems(0)
- setError(error)
- } finally {
- setLoading(false)
- }
- }, [nodeId, currentPage, pageSize, dateRange])
-
- const fetchWeekStats = useCallback(async () => {
- if (!nodeId || !dateRange.endDate) {
- return
- }
-
- const targetDate = dateRange.endDate.unix()
-
- setLoadingWeekStats(true)
- setErrorWeekStats(null)
- setWeekStats(null)
- try {
- const stats = await getWeekStats(targetDate)
- setWeekStats(stats)
- } catch (err) {
- console.error('[HistoryContext] Error during fetchWeekStats:', err)
- setWeekStats(null)
- setErrorWeekStats(err)
- } finally {
- setLoadingWeekStats(false)
- }
- }, [nodeId, dateRange])
-
- useEffect(() => {
- if (isSearching && nodeId && dateRange.startDate && dateRange.endDate) {
- const fetchData = async () => {
- await fetchHistoryData()
- await fetchWeekStats()
- setIsSearching(false)
- }
-
- fetchData()
- } else if (isSearching && nodeId && (!dateRange.startDate || !dateRange.endDate)) {
- console.log(
- '[HistoryContext] Waiting for initial date range to be set after periods load.'
- )
- }
- }, [
- isSearching,
- nodeId,
- dateRange,
- currentPage,
- pageSize,
- fetchHistoryData,
- fetchWeekStats
- ])
-
- const clearHistory = useCallback(() => {
- setNodeId('')
- setIsSearching(false)
- setData([])
- setTotalItems(0)
- setCurrentPage(1)
- setError(null)
- setWeekStats(null)
- setErrorWeekStats(null)
- if (availablePeriods.length > 0) {
- const mostRecentPeriod = availablePeriods[0]
- setDateRange({
- startDate: mostRecentPeriod.startDate,
- endDate: mostRecentPeriod.endDate
- })
- } else {
- setDateRange({ startDate: null, endDate: null })
- }
- }, [availablePeriods])
-
- const handleSetDateRange = useCallback(
- (newRange: DateRange) => {
- console.log(
- `[HistoryContext] Setting new date range: ${newRange.startDate?.format('YYYY-MM-DD')} to ${newRange.endDate?.format('YYYY-MM-DD')}`
- )
-
- if (newRange.startDate && newRange.endDate) {
- setDateRange(newRange)
-
- if (nodeId && nodeId.trim() !== '') {
- setIsSearching(true)
- }
- } else {
- console.log('[HistoryContext] Ignoring invalid date range (missing dates)')
- }
- },
- [nodeId]
- )
-
- const getRewardsForPeriod = (
- periodId: string | number
- ): {
- averageReward: number
- totalDistributed: number
- nrEligibleNodes: number
- } | null => {
- if (!periodId || rewardsData.length === 0) return null
-
- const periodIdStr = periodId.toString()
- const periodRewards = rewardsData.find((reward) => reward.date === periodIdStr)
-
- if (!periodRewards) return null
-
- const averageReward = periodRewards.totalAmount / periodRewards.nrEligibleNodes
-
- return {
- averageReward,
- totalDistributed: periodRewards.totalAmount,
- nrEligibleNodes: periodRewards.nrEligibleNodes
- }
- }
-
- const totalProgramDistribution = useMemo(() => {
- if (!Array.isArray(rewardsData) || rewardsData.length === 0) return 0
- return rewardsData.reduce((sum, reward) => {
- return sum + (reward?.totalAmount || 0)
- }, 0)
- }, [rewardsData])
-
- const value: HistoryContextType = {
- data,
- loading,
- error,
- currentPage,
- pageSize,
- totalItems,
- nodeId,
- dateRange,
- isSearching,
- setNodeId,
- setDateRange: handleSetDateRange,
- setCurrentPage,
- setPageSize,
- setIsSearching,
- clearHistory,
- fetchHistoryData,
- weekStats,
- loadingWeekStats,
- errorWeekStats,
- fetchWeekStats,
- availablePeriods,
- periodsLoading,
- getRewardsForPeriod,
- rewardsData,
- loadingRewards,
- errorRewards,
- totalProgramDistribution,
- currentRoundStats,
- loadingCurrentRound,
- errorCurrentRound,
- isInitialising,
- nodesData,
- loadingNodeData,
- errorNodeData
- }
-
- return {children}
-}
-
-export const useHistoryContext = () => {
- const context = useContext(HistoryContext)
- if (context === undefined) {
- throw new Error('useHistoryContext must be used within a HistoryProvider')
- }
- return context
-}
-
-export const getNodeData = async (
- nodeId: string,
-): Promise => {
- try {
- let url = `https://incentive-backend.oceanprotocol.com/nodes?nodeId=${nodeId}`
-
- const response = await axios.get(url)
- console.log('[getNodeDetails] Response from node data API:', response)
- return response?.data?.nodes[0]?._source as NodeData
- } catch (error) {
- console.error('[getNodeDetails] Error fetching node details:', error)
- throw error
- }
-}
-
diff --git a/src/context/MapContext.tsx b/src/context/MapContext.tsx
deleted file mode 100644
index dd7b5517..00000000
--- a/src/context/MapContext.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-import React, {
- createContext,
- useState,
- useEffect,
- useContext,
- ReactNode,
- useMemo
-} from 'react'
-import axios from 'axios'
-import { getApiRoute } from '@/config'
-import { LocationNode } from '../shared/types/locationNodeType'
-
-interface MapContextType {
- data: LocationNode[]
- loading: boolean
- error: any
- totalCountries: number
-}
-
-interface MapProviderProps {
- children: ReactNode
-}
-
-export const MapContext = createContext(undefined)
-
-export const MapProvider: React.FC = ({ children }) => {
- const [data, setData] = useState([])
- const [loading, setLoading] = useState(true)
- const [error, setError] = useState(null)
- const [totalCountries, setTotalCountries] = useState(0)
-
- const fetchUrl = useMemo(() => {
- return getApiRoute('locations')
- }, [])
-
- useEffect(() => {
- const fetchData = async () => {
- setLoading(true)
- try {
- const response = await axios.get(fetchUrl)
- const locationsFromAPI = response.data.locations
- const totalCountries = response.data.totalCountries
-
- const transformedNodes = locationsFromAPI.map((location: any) => ({
- city: location.location,
- lat: location.coordinates.lat,
- lon: location.coordinates.lon,
- country: location.country || location.location,
- count: location.count
- }))
-
- setData(transformedNodes)
- setTotalCountries(totalCountries)
- } catch (err) {
- console.error('Error fetching locations:', err)
- setError(err)
- } finally {
- setLoading(false)
- }
- }
-
- fetchData()
- }, [fetchUrl])
-
- return (
-
- {children}
-
- )
-}
-
-export const useMapContext = () => {
- const context = useContext(MapContext)
- if (context === undefined) {
- throw new Error('useMapContext must be used within a MapProvider')
- }
- return context
-}
diff --git a/src/context/NodesContext.tsx b/src/context/NodesContext.tsx
deleted file mode 100644
index fd1b3216..00000000
--- a/src/context/NodesContext.tsx
+++ /dev/null
@@ -1,448 +0,0 @@
-import React, {
- createContext,
- useState,
- useEffect,
- useContext,
- ReactNode,
- useMemo,
- useCallback
-} from 'react'
-import axios from 'axios'
-import { NodeData } from '@/shared/types/RowDataType'
-import { getApiRoute } from '@/config'
-import { SystemStats } from '@/shared/types/dataTypes'
-import { NodeFilters as OriginalNodeFilters } from '@/shared/types/filters'
-
-type NodeFilters = {
- [key: string]: {
- value: any
- operator: string
- }
-}
-import { GridFilterModel } from '@mui/x-data-grid'
-
-interface RewardHistoryItem {
- date: string
- background: { value: number }
- foreground: { value: number }
- weeklyAmount: number
-}
-
-interface AverageIncentiveDataItem {
- date: string
- foreground: { value: number }
- totalRewards: number
- totalNodes: number
-}
-
-interface NodesContextType {
- data: NodeData[]
- loading: boolean
- error: any
- currentPage: number
- pageSize: number
- totalItems: number
- searchTerm: string
- sortModel: Record
- filters: Record
- nextSearchAfter: any[] | null
- totalNodes: number | null
- totalEligibleNodes: number | null
- totalRewards: number | null
- systemStats: SystemStats
- totalUptime: number | null
- rewardsHistory: RewardHistoryItem[]
- loadingTotalNodes: boolean
- loadingRewardsHistory: boolean
- loadingTotalEligible: boolean
- loadingTotalRewards: boolean
- overallDashboardLoading: boolean
- averageIncentiveData: AverageIncentiveDataItem[]
- setCurrentPage: (page: number) => void
- setPageSize: (size: number) => void
- setSearchTerm: (term: string) => void
- setFilters: (filters: { [key: string]: any }) => void
- setSortModel: (model: { [key: string]: 'asc' | 'desc' }) => void
- setFilter: (filter: GridFilterModel) => void
- fetchRewardsHistory: () => Promise
-}
-
-const NodesContext = createContext(undefined)
-
-interface NodesProviderProps {
- children: ReactNode
-}
-
-export const NodesProvider: React.FC = ({ children }) => {
- const [data, setData] = useState([])
- const [loading, setLoading] = useState(true)
- const [error, setError] = useState(null)
- const [currentPage, setCurrentPage] = useState(1)
- const [pageSize, setPageSize] = useState(100)
- const [totalItems, setTotalItems] = useState