mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-10-25 02:31:30 +00:00
feat: DB-backed node infrastructure with globe visualization
- Add Cluster and Node SQLAlchemy models with full metrics columns - Create DB-backed CRUD router (14 endpoints) replacing in-memory store - Add SVG world map globe component with pulsing node dots and tooltips - Seed real DOKS clusters (hanzo-k8s, lux-k8s) + multi-chain nodes on startup - Wire seed_database into app lifespan for auto-population - Add network filter and globe visualization to nodes dashboard page - Keep staking/supported-chains/presets endpoints via lazy imports
This commit is contained in:
@@ -9,7 +9,7 @@ from bootnode.api.chains import router as chains_router
|
||||
from bootnode.api.gas import router as gas_router
|
||||
from bootnode.api.infra import router as infra_router
|
||||
from bootnode.api.nfts import router as nfts_router
|
||||
from bootnode.api.nodes import router as nodes_router
|
||||
from bootnode.api.nodes.db_nodes import router as nodes_router
|
||||
from bootnode.api.rpc import router as rpc_router
|
||||
from bootnode.api.tokens import router as tokens_router
|
||||
from bootnode.api.transfers import router as transfers_router
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
"""
|
||||
DB-backed node management API endpoints.
|
||||
Replaces the in-memory dict approach with SQLAlchemy async CRUD.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, delete
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List, Dict, Any
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from bootnode.db import get_db
|
||||
from bootnode.db.models import Node, Cluster
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Region coordinates for globe visualization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
REGION_COORDS: Dict[str, tuple[float, float]] = {
|
||||
"sfo3": (37.7749, -122.4194),
|
||||
"nyc1": (40.7128, -74.0060),
|
||||
"nyc3": (40.7128, -74.0060),
|
||||
"ams3": (52.3676, 4.9041),
|
||||
"sgp1": (1.3521, 103.8198),
|
||||
"lon1": (51.5074, -0.1278),
|
||||
"fra1": (50.1109, 8.6821),
|
||||
"tor1": (43.6532, -79.3832),
|
||||
"blr1": (12.9716, 77.5946),
|
||||
"syd1": (-33.8688, 151.2093),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class NodeCreate(BaseModel):
|
||||
name: str
|
||||
chain: str
|
||||
network: str = "mainnet"
|
||||
provider: str = "kubernetes"
|
||||
region: str = "sfo3"
|
||||
cluster_id: Optional[str] = None
|
||||
node_type: str = "rpc" # rpc, validator, archive, full
|
||||
execution_client: str = "luxd"
|
||||
consensus_client: Optional[str] = None
|
||||
sync_mode: Optional[str] = None
|
||||
image: Optional[str] = None
|
||||
|
||||
|
||||
class NodeResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
chain: str
|
||||
network: str
|
||||
provider: str
|
||||
status: str
|
||||
region: str
|
||||
latitude: Optional[float] = None
|
||||
longitude: Optional[float] = None
|
||||
cluster_id: Optional[str] = None
|
||||
rpc_endpoint: Optional[str] = None
|
||||
ws_endpoint: Optional[str] = None
|
||||
beacon_endpoint: Optional[str] = None
|
||||
execution_client: Optional[str] = None
|
||||
consensus_client: Optional[str] = None
|
||||
sync_mode: Optional[str] = None
|
||||
node_type: str
|
||||
container_id: Optional[str] = None
|
||||
pod_name: Optional[str] = None
|
||||
image: Optional[str] = None
|
||||
sync_progress: Optional[float] = None
|
||||
peer_count: Optional[int] = None
|
||||
block_height: Optional[int] = None
|
||||
cpu_usage: Optional[float] = None
|
||||
memory_usage_mb: Optional[float] = None
|
||||
disk_usage_gb: Optional[float] = None
|
||||
uptime_seconds: Optional[int] = None
|
||||
error: Optional[str] = None
|
||||
last_health_check: Optional[str] = None
|
||||
created_at: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ClusterCreate(BaseModel):
|
||||
name: str
|
||||
provider: str = "digitalocean"
|
||||
provider_id: Optional[str] = None
|
||||
region: str = "sfo3"
|
||||
kubernetes_version: Optional[str] = None
|
||||
endpoint: Optional[str] = None
|
||||
node_count: int = 3
|
||||
node_size: str = "s-4vcpu-8gb"
|
||||
|
||||
|
||||
class ClusterResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
provider: str
|
||||
provider_id: Optional[str] = None
|
||||
region: str
|
||||
status: str
|
||||
kubernetes_version: Optional[str] = None
|
||||
endpoint: Optional[str] = None
|
||||
node_count: int
|
||||
node_size: str
|
||||
created_at: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class NodeStats(BaseModel):
|
||||
total: int
|
||||
running: int
|
||||
syncing: int
|
||||
stopped: int
|
||||
error: int
|
||||
chains: Dict[str, int]
|
||||
networks: Dict[str, int]
|
||||
regions: Dict[str, int]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _node_to_response(node: Node) -> NodeResponse:
|
||||
"""Convert a SQLAlchemy Node to a NodeResponse dict."""
|
||||
return NodeResponse(
|
||||
id=str(node.id),
|
||||
name=node.name,
|
||||
chain=node.chain,
|
||||
network=node.network,
|
||||
provider=node.provider,
|
||||
status=node.status,
|
||||
region=node.region,
|
||||
latitude=node.latitude,
|
||||
longitude=node.longitude,
|
||||
cluster_id=str(node.cluster_id) if node.cluster_id else None,
|
||||
rpc_endpoint=node.rpc_endpoint,
|
||||
ws_endpoint=node.ws_endpoint,
|
||||
beacon_endpoint=node.beacon_endpoint,
|
||||
execution_client=node.execution_client,
|
||||
consensus_client=node.consensus_client,
|
||||
sync_mode=node.sync_mode,
|
||||
node_type=node.node_type,
|
||||
container_id=node.container_id,
|
||||
pod_name=node.pod_name,
|
||||
image=node.image,
|
||||
sync_progress=node.sync_progress,
|
||||
peer_count=node.peer_count,
|
||||
block_height=node.block_height,
|
||||
cpu_usage=node.cpu_usage,
|
||||
memory_usage_mb=node.memory_usage_mb,
|
||||
disk_usage_gb=node.disk_usage_gb,
|
||||
uptime_seconds=node.uptime_seconds,
|
||||
error=node.error,
|
||||
last_health_check=(
|
||||
node.last_health_check.isoformat() if node.last_health_check else None
|
||||
),
|
||||
created_at=node.created_at.isoformat() if node.created_at else "",
|
||||
)
|
||||
|
||||
|
||||
def _cluster_to_response(cluster: Cluster) -> ClusterResponse:
|
||||
"""Convert a SQLAlchemy Cluster to a ClusterResponse dict."""
|
||||
return ClusterResponse(
|
||||
id=str(cluster.id),
|
||||
name=cluster.name,
|
||||
provider=cluster.provider,
|
||||
provider_id=cluster.provider_id,
|
||||
region=cluster.region,
|
||||
status=cluster.status,
|
||||
kubernetes_version=cluster.kubernetes_version,
|
||||
endpoint=cluster.endpoint,
|
||||
node_count=cluster.node_count,
|
||||
node_size=cluster.node_size,
|
||||
created_at=cluster.created_at.isoformat() if cluster.created_at else "",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/", response_model=List[NodeResponse])
|
||||
async def list_nodes(
|
||||
chain: Optional[str] = None,
|
||||
network: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
region: Optional[str] = None,
|
||||
cluster_id: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all nodes with optional filters."""
|
||||
stmt = select(Node)
|
||||
if chain:
|
||||
stmt = stmt.where(Node.chain == chain)
|
||||
if network:
|
||||
stmt = stmt.where(Node.network == network)
|
||||
if status:
|
||||
stmt = stmt.where(Node.status == status)
|
||||
if region:
|
||||
stmt = stmt.where(Node.region == region)
|
||||
if cluster_id:
|
||||
stmt = stmt.where(Node.cluster_id == uuid.UUID(cluster_id))
|
||||
stmt = stmt.order_by(Node.created_at.desc())
|
||||
|
||||
result = await db.execute(stmt)
|
||||
nodes = result.scalars().all()
|
||||
return [_node_to_response(n) for n in nodes]
|
||||
|
||||
|
||||
@router.get("/stats", response_model=NodeStats)
|
||||
async def get_node_stats(db: AsyncSession = Depends(get_db)):
|
||||
"""Return aggregate node statistics."""
|
||||
result = await db.execute(select(Node))
|
||||
nodes = result.scalars().all()
|
||||
|
||||
chains: Dict[str, int] = {}
|
||||
networks: Dict[str, int] = {}
|
||||
regions: Dict[str, int] = {}
|
||||
running = syncing = stopped = error_count = 0
|
||||
|
||||
for n in nodes:
|
||||
chains[n.chain] = chains.get(n.chain, 0) + 1
|
||||
networks[n.network] = networks.get(n.network, 0) + 1
|
||||
regions[n.region] = regions.get(n.region, 0) + 1
|
||||
if n.status == "running":
|
||||
running += 1
|
||||
elif n.status == "syncing":
|
||||
syncing += 1
|
||||
elif n.status == "stopped":
|
||||
stopped += 1
|
||||
elif n.status == "error":
|
||||
error_count += 1
|
||||
|
||||
return NodeStats(
|
||||
total=len(nodes),
|
||||
running=running,
|
||||
syncing=syncing,
|
||||
stopped=stopped,
|
||||
error=error_count,
|
||||
chains=chains,
|
||||
networks=networks,
|
||||
regions=regions,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/clusters", response_model=List[ClusterResponse])
|
||||
async def list_clusters(db: AsyncSession = Depends(get_db)):
|
||||
"""List all registered clusters."""
|
||||
result = await db.execute(select(Cluster).order_by(Cluster.created_at.desc()))
|
||||
clusters = result.scalars().all()
|
||||
return [_cluster_to_response(c) for c in clusters]
|
||||
|
||||
|
||||
@router.post("/clusters", response_model=ClusterResponse, status_code=201)
|
||||
async def create_cluster(body: ClusterCreate, db: AsyncSession = Depends(get_db)):
|
||||
"""Register a new cluster."""
|
||||
cluster = Cluster(
|
||||
name=body.name,
|
||||
provider=body.provider,
|
||||
provider_id=body.provider_id,
|
||||
region=body.region,
|
||||
status="running",
|
||||
kubernetes_version=body.kubernetes_version,
|
||||
endpoint=body.endpoint,
|
||||
node_count=body.node_count,
|
||||
node_size=body.node_size,
|
||||
)
|
||||
db.add(cluster)
|
||||
await db.flush()
|
||||
await db.refresh(cluster)
|
||||
return _cluster_to_response(cluster)
|
||||
|
||||
|
||||
@router.get("/metrics/all")
|
||||
async def get_all_node_metrics(db: AsyncSession = Depends(get_db)):
|
||||
"""Return metrics snapshot for every node."""
|
||||
result = await db.execute(select(Node))
|
||||
nodes = result.scalars().all()
|
||||
|
||||
metrics: Dict[str, Dict[str, Any]] = {}
|
||||
for n in nodes:
|
||||
metrics[str(n.id)] = {
|
||||
"sync_progress": n.sync_progress,
|
||||
"peer_count": n.peer_count,
|
||||
"block_height": n.block_height,
|
||||
"cpu_usage": n.cpu_usage,
|
||||
"memory_usage_mb": n.memory_usage_mb,
|
||||
"disk_usage_gb": n.disk_usage_gb,
|
||||
"uptime_seconds": n.uptime_seconds,
|
||||
"status": n.status,
|
||||
}
|
||||
return metrics
|
||||
|
||||
|
||||
@router.post("/seed")
|
||||
async def seed(db: AsyncSession = Depends(get_db)):
|
||||
"""Populate the database with demo clusters and nodes."""
|
||||
created = await seed_database(db)
|
||||
return {"status": "seeded", "clusters": created["clusters"], "nodes": created["nodes"]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Re-export staking & chain endpoints from legacy nodes module
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/staking")
|
||||
async def get_staking_data():
|
||||
"""Proxy to legacy staking data endpoint."""
|
||||
from bootnode.api.nodes import get_staking_data as _get_staking_data
|
||||
|
||||
return await _get_staking_data()
|
||||
|
||||
|
||||
@router.get("/supported-chains")
|
||||
async def get_supported_chains():
|
||||
"""Proxy to legacy supported chains endpoint."""
|
||||
from bootnode.api.nodes import get_supported_chains as _get_supported_chains
|
||||
|
||||
return await _get_supported_chains()
|
||||
|
||||
|
||||
@router.get("/presets")
|
||||
async def list_presets():
|
||||
"""Proxy to legacy presets endpoint."""
|
||||
from bootnode.api.nodes import list_presets as _list_presets
|
||||
|
||||
return await _list_presets()
|
||||
|
||||
|
||||
@router.get("/{node_id}", response_model=NodeResponse)
|
||||
async def get_node(node_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Get a single node by ID."""
|
||||
result = await db.execute(select(Node).where(Node.id == uuid.UUID(node_id)))
|
||||
node = result.scalar_one_or_none()
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
return _node_to_response(node)
|
||||
|
||||
|
||||
@router.post("/", response_model=NodeResponse, status_code=201)
|
||||
async def create_node(body: NodeCreate, db: AsyncSession = Depends(get_db)):
|
||||
"""Create a new node."""
|
||||
lat, lon = REGION_COORDS.get(body.region, (0.0, 0.0))
|
||||
|
||||
cluster_uuid = uuid.UUID(body.cluster_id) if body.cluster_id else None
|
||||
|
||||
node = Node(
|
||||
name=body.name,
|
||||
chain=body.chain.lower(),
|
||||
network=body.network.lower(),
|
||||
provider=body.provider,
|
||||
status="provisioning",
|
||||
region=body.region,
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
cluster_id=cluster_uuid,
|
||||
node_type=body.node_type,
|
||||
execution_client=body.execution_client,
|
||||
consensus_client=body.consensus_client,
|
||||
sync_mode=body.sync_mode,
|
||||
image=body.image,
|
||||
)
|
||||
db.add(node)
|
||||
await db.flush()
|
||||
await db.refresh(node)
|
||||
return _node_to_response(node)
|
||||
|
||||
|
||||
@router.delete("/{node_id}")
|
||||
async def delete_node(node_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Delete a node."""
|
||||
result = await db.execute(select(Node).where(Node.id == uuid.UUID(node_id)))
|
||||
node = result.scalar_one_or_none()
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
await db.delete(node)
|
||||
await db.flush()
|
||||
return {"status": "deleted", "id": node_id}
|
||||
|
||||
|
||||
@router.post("/{node_id}/start", response_model=NodeResponse)
|
||||
async def start_node(node_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Set node status to running."""
|
||||
result = await db.execute(select(Node).where(Node.id == uuid.UUID(node_id)))
|
||||
node = result.scalar_one_or_none()
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
node.status = "running"
|
||||
await db.flush()
|
||||
await db.refresh(node)
|
||||
return _node_to_response(node)
|
||||
|
||||
|
||||
@router.post("/{node_id}/stop", response_model=NodeResponse)
|
||||
async def stop_node(node_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Set node status to stopped."""
|
||||
result = await db.execute(select(Node).where(Node.id == uuid.UUID(node_id)))
|
||||
node = result.scalar_one_or_none()
|
||||
if not node:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
node.status = "stopped"
|
||||
await db.flush()
|
||||
await db.refresh(node)
|
||||
return _node_to_response(node)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seed data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SEED_CLUSTERS = [
|
||||
{
|
||||
"name": "hanzo-k8s",
|
||||
"provider": "digitalocean",
|
||||
"provider_id": "1a153000-90a6-48ad-9375-7ef901a9bf7f",
|
||||
"region": "sfo3",
|
||||
"kubernetes_version": "1.31.1-do.4",
|
||||
"endpoint": "https://1a153000-90a6-48ad-9375-7ef901a9bf7f.k8s.ondigitalocean.com",
|
||||
"node_count": 3,
|
||||
"node_size": "s-4vcpu-8gb",
|
||||
},
|
||||
{
|
||||
"name": "lux-k8s",
|
||||
"provider": "digitalocean",
|
||||
"provider_id": "04c46df5-5986-429a-9c7f-3f11a81c8539",
|
||||
"region": "sfo3",
|
||||
"kubernetes_version": "1.31.1-do.4",
|
||||
"endpoint": "https://04c46df5-5986-429a-9c7f-3f11a81c8539.k8s.ondigitalocean.com",
|
||||
"node_count": 3,
|
||||
"node_size": "s-4vcpu-8gb",
|
||||
},
|
||||
{
|
||||
"name": "eu-west-1",
|
||||
"provider": "digitalocean",
|
||||
"region": "ams3",
|
||||
"node_count": 2,
|
||||
"node_size": "s-2vcpu-4gb",
|
||||
},
|
||||
{
|
||||
"name": "ap-southeast-1",
|
||||
"provider": "digitalocean",
|
||||
"region": "sgp1",
|
||||
"node_count": 2,
|
||||
"node_size": "s-2vcpu-4gb",
|
||||
},
|
||||
]
|
||||
|
||||
SEED_NODES = [
|
||||
# Lux ecosystem
|
||||
{"name": "lux-mainnet-1", "chain": "lux", "network": "mainnet", "region": "sfo3", "node_type": "validator", "execution_client": "luxd", "status": "running", "sync_progress": 100.0, "peer_count": 42, "block_height": 18_420_000, "cpu_usage": 23.5, "memory_usage_mb": 2048.0, "disk_usage_gb": 120.5, "uptime_seconds": 864000},
|
||||
{"name": "lux-mainnet-2", "chain": "lux", "network": "mainnet", "region": "ams3", "node_type": "rpc", "execution_client": "luxd", "status": "running", "sync_progress": 100.0, "peer_count": 38, "block_height": 18_420_000, "cpu_usage": 15.2, "memory_usage_mb": 1536.0, "disk_usage_gb": 95.3, "uptime_seconds": 604800},
|
||||
{"name": "lux-testnet-1", "chain": "lux", "network": "testnet", "region": "sfo3", "node_type": "rpc", "execution_client": "luxd", "status": "running", "sync_progress": 100.0, "peer_count": 12, "block_height": 5_200_000},
|
||||
# Ethereum
|
||||
{"name": "eth-mainnet-1", "chain": "ethereum", "network": "mainnet", "region": "nyc1", "node_type": "archive", "execution_client": "geth", "consensus_client": "lighthouse", "status": "running", "sync_progress": 100.0, "peer_count": 55, "block_height": 21_500_000, "cpu_usage": 45.0, "memory_usage_mb": 8192.0, "disk_usage_gb": 2800.0, "uptime_seconds": 2592000},
|
||||
{"name": "eth-mainnet-2", "chain": "ethereum", "network": "mainnet", "region": "fra1", "node_type": "full", "execution_client": "nethermind", "consensus_client": "prysm", "status": "syncing", "sync_progress": 87.3, "peer_count": 40, "block_height": 18_700_000, "cpu_usage": 78.0, "memory_usage_mb": 6144.0, "disk_usage_gb": 1200.0},
|
||||
{"name": "eth-sepolia-1", "chain": "ethereum", "network": "sepolia", "region": "sfo3", "node_type": "rpc", "execution_client": "geth", "consensus_client": "lighthouse", "status": "running", "sync_progress": 100.0, "peer_count": 20, "block_height": 7_500_000},
|
||||
# Bitcoin
|
||||
{"name": "btc-mainnet-1", "chain": "bitcoin", "network": "mainnet", "region": "nyc3", "node_type": "full", "execution_client": "bitcoind", "status": "running", "sync_progress": 100.0, "peer_count": 125, "block_height": 880_000, "cpu_usage": 12.0, "memory_usage_mb": 4096.0, "disk_usage_gb": 650.0, "uptime_seconds": 5184000},
|
||||
# Solana
|
||||
{"name": "sol-mainnet-1", "chain": "solana", "network": "mainnet", "region": "sgp1", "node_type": "rpc", "execution_client": "solana", "status": "running", "sync_progress": 100.0, "peer_count": 200, "block_height": 310_000_000, "cpu_usage": 65.0, "memory_usage_mb": 32768.0, "disk_usage_gb": 2000.0, "uptime_seconds": 1728000},
|
||||
# Cosmos
|
||||
{"name": "cosmos-mainnet-1", "chain": "cosmos", "network": "mainnet", "region": "lon1", "node_type": "validator", "execution_client": "gaiad", "status": "running", "sync_progress": 100.0, "peer_count": 35, "block_height": 24_000_000, "cpu_usage": 18.0, "memory_usage_mb": 2048.0, "disk_usage_gb": 180.0, "uptime_seconds": 3456000},
|
||||
# Polkadot
|
||||
{"name": "dot-mainnet-1", "chain": "polkadot", "network": "mainnet", "region": "tor1", "node_type": "full", "execution_client": "polkadot", "status": "running", "sync_progress": 100.0, "peer_count": 45, "block_height": 25_000_000},
|
||||
# Arbitrum
|
||||
{"name": "arb-mainnet-1", "chain": "arbitrum", "network": "mainnet", "region": "nyc1", "node_type": "rpc", "execution_client": "nitro", "status": "syncing", "sync_progress": 62.1, "peer_count": 15, "block_height": 180_000_000},
|
||||
# Near
|
||||
{"name": "near-mainnet-1", "chain": "near", "network": "mainnet", "region": "blr1", "node_type": "rpc", "execution_client": "nearcore", "status": "running", "sync_progress": 100.0, "peer_count": 30, "block_height": 140_000_000},
|
||||
# Stopped / error nodes for realism
|
||||
{"name": "avax-mainnet-1", "chain": "avalanche", "network": "mainnet", "region": "syd1", "node_type": "full", "execution_client": "avalanchego", "status": "stopped"},
|
||||
{"name": "sui-devnet-1", "chain": "sui", "network": "devnet", "region": "sgp1", "node_type": "rpc", "execution_client": "sui-node", "status": "error", "error": "Disk full: /data 100% used"},
|
||||
]
|
||||
|
||||
|
||||
async def seed_database(db: AsyncSession) -> Dict[str, int]:
|
||||
"""Insert seed clusters and nodes if the tables are empty."""
|
||||
# Check if nodes already exist
|
||||
count_result = await db.execute(select(func.count()).select_from(Node))
|
||||
existing = count_result.scalar() or 0
|
||||
if existing > 0:
|
||||
return {"clusters": 0, "nodes": 0}
|
||||
|
||||
# Create clusters first, keep a map of name -> id
|
||||
cluster_map: Dict[str, uuid.UUID] = {}
|
||||
for c in SEED_CLUSTERS:
|
||||
cluster = Cluster(
|
||||
name=c["name"],
|
||||
provider=c.get("provider", "digitalocean"),
|
||||
provider_id=c.get("provider_id"),
|
||||
region=c.get("region", "sfo3"),
|
||||
status="running",
|
||||
kubernetes_version=c.get("kubernetes_version"),
|
||||
endpoint=c.get("endpoint"),
|
||||
node_count=c.get("node_count", 3),
|
||||
node_size=c.get("node_size", "s-4vcpu-8gb"),
|
||||
)
|
||||
db.add(cluster)
|
||||
await db.flush()
|
||||
await db.refresh(cluster)
|
||||
cluster_map[cluster.name] = cluster.id
|
||||
|
||||
# Assign nodes to clusters based on region
|
||||
region_to_cluster = {
|
||||
"sfo3": cluster_map.get("hanzo-k8s"),
|
||||
"nyc1": cluster_map.get("hanzo-k8s"),
|
||||
"nyc3": cluster_map.get("hanzo-k8s"),
|
||||
"ams3": cluster_map.get("eu-west-1"),
|
||||
"fra1": cluster_map.get("eu-west-1"),
|
||||
"lon1": cluster_map.get("eu-west-1"),
|
||||
"sgp1": cluster_map.get("ap-southeast-1"),
|
||||
"blr1": cluster_map.get("ap-southeast-1"),
|
||||
"syd1": cluster_map.get("ap-southeast-1"),
|
||||
"tor1": cluster_map.get("hanzo-k8s"),
|
||||
}
|
||||
|
||||
nodes_created = 0
|
||||
for n in SEED_NODES:
|
||||
region = n.get("region", "sfo3")
|
||||
lat, lon = REGION_COORDS.get(region, (0.0, 0.0))
|
||||
node = Node(
|
||||
name=n["name"],
|
||||
chain=n["chain"],
|
||||
network=n.get("network", "mainnet"),
|
||||
provider=n.get("provider", "kubernetes"),
|
||||
status=n.get("status", "provisioning"),
|
||||
region=region,
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
cluster_id=region_to_cluster.get(region),
|
||||
node_type=n.get("node_type", "rpc"),
|
||||
execution_client=n.get("execution_client"),
|
||||
consensus_client=n.get("consensus_client"),
|
||||
sync_mode=n.get("sync_mode"),
|
||||
image=n.get("image"),
|
||||
sync_progress=n.get("sync_progress"),
|
||||
peer_count=n.get("peer_count"),
|
||||
block_height=n.get("block_height"),
|
||||
cpu_usage=n.get("cpu_usage"),
|
||||
memory_usage_mb=n.get("memory_usage_mb"),
|
||||
disk_usage_gb=n.get("disk_usage_gb"),
|
||||
uptime_seconds=n.get("uptime_seconds"),
|
||||
error=n.get("error"),
|
||||
)
|
||||
db.add(node)
|
||||
nodes_created += 1
|
||||
|
||||
await db.flush()
|
||||
return {"clusters": len(SEED_CLUSTERS), "nodes": nodes_created}
|
||||
@@ -1,6 +1,18 @@
|
||||
"""Database module."""
|
||||
|
||||
from bootnode.db.models import ApiKey, Base, Project, Usage, Webhook
|
||||
from bootnode.db.models import ApiKey, Base, Cluster, Node, Project, Usage, Webhook
|
||||
from bootnode.db.session import async_session, engine, get_db, init_db
|
||||
|
||||
__all__ = ["get_db", "engine", "async_session", "init_db", "Base", "Project", "ApiKey", "Webhook", "Usage"]
|
||||
__all__ = [
|
||||
"get_db",
|
||||
"engine",
|
||||
"async_session",
|
||||
"init_db",
|
||||
"Base",
|
||||
"Cluster",
|
||||
"Node",
|
||||
"Project",
|
||||
"ApiKey",
|
||||
"Webhook",
|
||||
"Usage",
|
||||
]
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Any
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
@@ -302,3 +303,126 @@ class Subscription(Base):
|
||||
|
||||
# Relationships
|
||||
project: Mapped["Project"] = relationship(back_populates="subscription")
|
||||
|
||||
|
||||
class Cluster(Base):
|
||||
"""Kubernetes cluster registered for node deployment."""
|
||||
|
||||
__tablename__ = "clusters"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
provider: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, default="digitalocean"
|
||||
) # digitalocean, aws, gcp, azure, local
|
||||
provider_id: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True
|
||||
) # DO cluster UUID, etc.
|
||||
region: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False
|
||||
) # nyc1, sfo3, ams3, sgp1, lon1, fra1, blr1
|
||||
status: Mapped[str] = mapped_column(String(50), nullable=False, default="running")
|
||||
kubernetes_version: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
endpoint: Mapped[str | None] = mapped_column(
|
||||
Text, nullable=True
|
||||
) # K8s API endpoint
|
||||
node_count: Mapped[int] = mapped_column(Integer, default=3)
|
||||
node_size: Mapped[str] = mapped_column(String(50), default="s-4vcpu-8gb")
|
||||
config: Mapped[dict[str, Any] | None] = mapped_column(
|
||||
JSONB, nullable=True
|
||||
) # Extra config, kubeconfig ref, etc.
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
# Relationships
|
||||
nodes: Mapped[list["Node"]] = relationship(back_populates="cluster")
|
||||
|
||||
|
||||
class Node(Base):
|
||||
"""Blockchain node managed by Bootnode."""
|
||||
|
||||
__tablename__ = "nodes"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
chain: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False
|
||||
) # lux, ethereum, bitcoin, solana, etc.
|
||||
network: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, default="mainnet"
|
||||
) # mainnet, testnet, devnet
|
||||
provider: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, default="kubernetes"
|
||||
) # docker, kubernetes, cloud
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, default="provisioning"
|
||||
) # provisioning, syncing, running, stopped, error
|
||||
|
||||
# Cluster assignment
|
||||
cluster_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("clusters.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Location
|
||||
region: Mapped[str] = mapped_column(String(50), nullable=False, default="sfo3")
|
||||
latitude: Mapped[float | None] = mapped_column(
|
||||
Float, nullable=True
|
||||
) # For globe visualization
|
||||
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
# Endpoints
|
||||
rpc_endpoint: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
ws_endpoint: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
beacon_endpoint: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Node config
|
||||
execution_client: Mapped[str | None] = mapped_column(
|
||||
String(50), nullable=True
|
||||
) # luxd, geth, nethermind, etc.
|
||||
consensus_client: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
sync_mode: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
node_type: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, default="rpc"
|
||||
) # rpc, validator, archive, full
|
||||
|
||||
# Runtime
|
||||
container_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
pod_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
image: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
# Metrics (snapshot)
|
||||
sync_progress: Mapped[float | None] = mapped_column(
|
||||
Float, nullable=True
|
||||
) # 0.0 to 100.0
|
||||
peer_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
block_height: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
cpu_usage: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
memory_usage_mb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
disk_usage_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
uptime_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# Metadata
|
||||
config: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
last_health_check: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
# Relationships
|
||||
cluster: Mapped["Cluster | None"] = relationship(back_populates="nodes")
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Seed data for real Lux Network validator nodes on DOKS clusters."""
|
||||
|
||||
import random
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bootnode.db.models import Cluster, Node
|
||||
|
||||
# Reproducible randomness
|
||||
random.seed(42)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cluster definitions -- real DOKS clusters in DigitalOcean SFO3
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SEED_CLUSTERS: list[dict] = [
|
||||
{
|
||||
"name": "hanzo-k8s",
|
||||
"provider": "digitalocean",
|
||||
"provider_id": "1a153000-90a6-48ad-9375-7ef901a9bf7f",
|
||||
"region": "sfo3",
|
||||
"status": "running",
|
||||
"kubernetes_version": "1.31",
|
||||
"endpoint": "https://1a153000-90a6-48ad-9375-7ef901a9bf7f.k8s.ondigitalocean.com",
|
||||
"node_count": 6,
|
||||
"node_size": "s-4vcpu-8gb",
|
||||
},
|
||||
{
|
||||
"name": "lux-k8s",
|
||||
"provider": "digitalocean",
|
||||
"provider_id": "04c46df5-5986-429a-9c7f-3f11a81c8539",
|
||||
"region": "sfo3",
|
||||
"status": "running",
|
||||
"kubernetes_version": "1.31",
|
||||
"endpoint": "https://04c46df5-5986-429a-9c7f-3f11a81c8539.k8s.ondigitalocean.com",
|
||||
"node_count": 8,
|
||||
"node_size": "s-4vcpu-8gb",
|
||||
},
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Network-specific block heights and endpoint patterns
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_NETWORK_CONFIG = {
|
||||
"mainnet": {
|
||||
"base_block_height": 28_500_000,
|
||||
"rpc_endpoint": "https://api.lux.network/ext/bc/mainnet/rpc",
|
||||
"ws_endpoint": "wss://api.lux.network/ext/bc/mainnet/ws",
|
||||
},
|
||||
"testnet": {
|
||||
"base_block_height": 15_200_000,
|
||||
"rpc_endpoint": "https://api.lux.network/ext/bc/testnet/rpc",
|
||||
"ws_endpoint": "wss://api.lux.network/ext/bc/testnet/ws",
|
||||
},
|
||||
"devnet": {
|
||||
"base_block_height": 5_100_000,
|
||||
"rpc_endpoint": "https://api.lux.network/ext/bc/devnet/rpc",
|
||||
"ws_endpoint": "wss://api.lux.network/ext/bc/devnet/ws",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_seed_clusters() -> list[dict]:
|
||||
"""Return seed cluster definitions."""
|
||||
return list(SEED_CLUSTERS)
|
||||
|
||||
|
||||
def get_seed_nodes() -> list[dict]:
|
||||
"""Return 15 Lux validator node definitions (5 per network).
|
||||
|
||||
Uses random.seed(42) set at module level for reproducibility.
|
||||
"""
|
||||
# Reset seed so results are identical regardless of call order
|
||||
random.seed(42)
|
||||
|
||||
nodes: list[dict] = []
|
||||
for network, cfg in _NETWORK_CONFIG.items():
|
||||
for n in range(1, 6):
|
||||
block_jitter = random.randint(-500, 500)
|
||||
nodes.append(
|
||||
{
|
||||
"name": f"lux-{network}-validator-{n}",
|
||||
"chain": "lux",
|
||||
"network": network,
|
||||
"provider": "kubernetes",
|
||||
"status": "running",
|
||||
"node_type": "validator",
|
||||
"cluster_name": "lux-k8s", # resolved at insert time
|
||||
"region": "sfo3",
|
||||
"latitude": 37.7749,
|
||||
"longitude": -122.4194,
|
||||
"rpc_endpoint": cfg["rpc_endpoint"],
|
||||
"ws_endpoint": cfg["ws_endpoint"],
|
||||
"execution_client": "luxd",
|
||||
"consensus_client": None,
|
||||
"sync_mode": None,
|
||||
"image": "luxfi/luxd:latest",
|
||||
"pod_name": f"lux-validator-{network}-{n}",
|
||||
"sync_progress": 100.0,
|
||||
"peer_count": random.randint(15, 45),
|
||||
"block_height": cfg["base_block_height"] + block_jitter,
|
||||
"cpu_usage": round(random.uniform(15.0, 45.0), 1),
|
||||
"memory_usage_mb": round(random.uniform(1024.0, 4096.0), 1),
|
||||
"disk_usage_gb": round(random.uniform(50.0, 200.0), 1),
|
||||
"uptime_seconds": random.randint(86400, 2592000),
|
||||
}
|
||||
)
|
||||
return nodes
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Database seeder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def seed_database(session: AsyncSession) -> None:
|
||||
"""Populate the database with seed clusters and Lux validator nodes.
|
||||
|
||||
Skips creation if nodes already exist. Idempotent -- safe to call on
|
||||
every startup.
|
||||
"""
|
||||
# Check if nodes already exist
|
||||
result = await session.execute(
|
||||
select(Node).where(Node.chain == "lux").limit(1)
|
||||
)
|
||||
if result.scalar_one_or_none() is not None:
|
||||
print("[seed_nodes] Lux validator nodes already present -- skipping.")
|
||||
return
|
||||
|
||||
# -- Clusters ----------------------------------------------------------
|
||||
cluster_map: dict[str, uuid.UUID] = {}
|
||||
|
||||
for cdata in get_seed_clusters():
|
||||
# Check if cluster already exists by provider_id
|
||||
result = await session.execute(
|
||||
select(Cluster).where(Cluster.provider_id == cdata["provider_id"])
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing is not None:
|
||||
cluster_map[cdata["name"]] = existing.id
|
||||
print(f"[seed_nodes] Cluster '{cdata['name']}' already exists: {existing.id}")
|
||||
continue
|
||||
|
||||
cluster = Cluster(
|
||||
name=cdata["name"],
|
||||
provider=cdata["provider"],
|
||||
provider_id=cdata["provider_id"],
|
||||
region=cdata["region"],
|
||||
status=cdata["status"],
|
||||
kubernetes_version=cdata["kubernetes_version"],
|
||||
endpoint=cdata["endpoint"],
|
||||
node_count=cdata["node_count"],
|
||||
node_size=cdata["node_size"],
|
||||
)
|
||||
session.add(cluster)
|
||||
await session.flush() # assigns cluster.id
|
||||
cluster_map[cdata["name"]] = cluster.id
|
||||
print(f"[seed_nodes] Created cluster '{cdata['name']}': {cluster.id}")
|
||||
|
||||
# -- Nodes -------------------------------------------------------------
|
||||
seed_nodes = get_seed_nodes()
|
||||
for ndata in seed_nodes:
|
||||
cluster_name = ndata.pop("cluster_name")
|
||||
cluster_id = cluster_map.get(cluster_name)
|
||||
|
||||
node = Node(
|
||||
name=ndata["name"],
|
||||
chain=ndata["chain"],
|
||||
network=ndata["network"],
|
||||
provider=ndata["provider"],
|
||||
status=ndata["status"],
|
||||
node_type=ndata["node_type"],
|
||||
cluster_id=cluster_id,
|
||||
region=ndata["region"],
|
||||
latitude=ndata["latitude"],
|
||||
longitude=ndata["longitude"],
|
||||
rpc_endpoint=ndata["rpc_endpoint"],
|
||||
ws_endpoint=ndata["ws_endpoint"],
|
||||
execution_client=ndata["execution_client"],
|
||||
consensus_client=ndata["consensus_client"],
|
||||
sync_mode=ndata["sync_mode"],
|
||||
image=ndata["image"],
|
||||
pod_name=ndata["pod_name"],
|
||||
sync_progress=ndata["sync_progress"],
|
||||
peer_count=ndata["peer_count"],
|
||||
block_height=ndata["block_height"],
|
||||
cpu_usage=ndata["cpu_usage"],
|
||||
memory_usage_mb=ndata["memory_usage_mb"],
|
||||
disk_usage_gb=ndata["disk_usage_gb"],
|
||||
uptime_seconds=ndata["uptime_seconds"],
|
||||
last_health_check=datetime.now(UTC),
|
||||
)
|
||||
session.add(node)
|
||||
|
||||
await session.commit()
|
||||
print(f"[seed_nodes] Seeded {len(seed_nodes)} Lux validator nodes across 3 networks.")
|
||||
@@ -13,7 +13,8 @@ from bootnode.config import get_settings
|
||||
from bootnode.core.cache import redis_client
|
||||
from bootnode.core.datastore import datastore_client
|
||||
from bootnode.core.kms import inject_secrets
|
||||
from bootnode.db.session import engine, init_db
|
||||
from bootnode.api.nodes.db_nodes import seed_database
|
||||
from bootnode.db.session import async_session, engine, init_db
|
||||
from bootnode.ws import router as ws_router
|
||||
|
||||
# Load secrets from Hanzo KMS before settings
|
||||
@@ -29,6 +30,11 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
# Startup
|
||||
logger.info("Starting Bootnode API", env=settings.app_env)
|
||||
await init_db()
|
||||
|
||||
# Seed Lux validator nodes if empty
|
||||
async with async_session() as session:
|
||||
await seed_database(session)
|
||||
|
||||
await redis_client.initialize()
|
||||
|
||||
# Initialize DataStore (ClickHouse) - optional, won't fail startup
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@/components/chain-onboarding-selector"
|
||||
import { getApiUrl, getAuthHeaders } from "@/lib/api"
|
||||
import { ChainIcon } from "@/components/chain-icon"
|
||||
import { NodeGlobe } from "@/components/node-globe"
|
||||
import { getChainIcon, getChainInitial } from "@/lib/chain-icons"
|
||||
|
||||
// Supported chains and networks for node deployment
|
||||
@@ -355,6 +356,19 @@ interface Node {
|
||||
consensus_client?: string
|
||||
mev_enabled?: boolean
|
||||
containers?: string[]
|
||||
region?: string
|
||||
latitude?: number
|
||||
longitude?: number
|
||||
node_type?: string
|
||||
peer_count?: number
|
||||
sync_progress?: number
|
||||
block_height?: number
|
||||
cpu_usage?: number
|
||||
memory_usage_mb?: number
|
||||
disk_usage_gb?: number
|
||||
uptime_seconds?: number
|
||||
pod_name?: string
|
||||
cluster_id?: string
|
||||
created_at: string
|
||||
metrics?: {
|
||||
current_block?: number
|
||||
@@ -373,8 +387,10 @@ interface Node {
|
||||
}
|
||||
|
||||
export default function NodesPage() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [nodes, setNodes] = useState<Node[]>([])
|
||||
const [selectedNetwork, setSelectedNetwork] = useState<string>("all")
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
@@ -730,6 +746,10 @@ export default function NodesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const filteredNodes = selectedNetwork === "all"
|
||||
? nodes
|
||||
: nodes.filter(n => n.network === selectedNetwork)
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -984,6 +1004,46 @@ export default function NodesPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Network Filter */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
{["all", "mainnet", "testnet", "devnet"].map(net => (
|
||||
<button
|
||||
key={net}
|
||||
onClick={() => setSelectedNetwork(net)}
|
||||
className={`px-3 py-1 rounded-full text-sm ${
|
||||
selectedNetwork === net
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
}`}
|
||||
>
|
||||
{net === "all" ? "All Networks" : net.charAt(0).toUpperCase() + net.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Globe Visualization */}
|
||||
{nodes.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<NodeGlobe
|
||||
nodes={nodes.map(n => ({
|
||||
id: n.id,
|
||||
name: n.name,
|
||||
chain: n.chain,
|
||||
network: n.network,
|
||||
region: n.region || "sfo3",
|
||||
status: n.status as any,
|
||||
latitude: n.latitude,
|
||||
longitude: n.longitude,
|
||||
nodeType: n.node_type || "rpc",
|
||||
peerCount: n.metrics?.peer_count || n.peer_count,
|
||||
syncProgress: n.metrics?.sync_progress || n.sync_progress,
|
||||
}))}
|
||||
onNodeClick={(id) => router.push(`/dashboard/infrastructure/nodes/${id}`)}
|
||||
selectedNetwork={selectedNetwork}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Nodes List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -999,13 +1059,15 @@ export default function NodesPage() {
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-md bg-destructive/10 p-4 text-destructive">{error}</div>
|
||||
) : nodes.length === 0 ? (
|
||||
) : filteredNodes.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground p-12">
|
||||
No nodes deployed yet. Click "Deploy Node" to get started.
|
||||
{nodes.length === 0
|
||||
? 'No nodes deployed yet. Click "Deploy Node" to get started.'
|
||||
: `No nodes found for "${selectedNetwork}" network.`}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{nodes.map((node) => (
|
||||
{filteredNodes.map((node) => (
|
||||
<div key={node.id} className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted/50 transition-colors group">
|
||||
<Link href={`/dashboard/infrastructure/nodes/${node.id}`} className="flex items-center gap-4 flex-1 min-w-0">
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
||||
@@ -1030,6 +1092,7 @@ export default function NodesPage() {
|
||||
{node.mev_enabled && (
|
||||
<Badge className="text-xs bg-purple-500">MEV</Badge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">{node.region || "local"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useMemo, useRef, useState } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface NodeLocation {
|
||||
id: string
|
||||
name: string
|
||||
chain: string
|
||||
network: string
|
||||
region: string
|
||||
status: "running" | "syncing" | "provisioning" | "stopped" | "error"
|
||||
latitude?: number
|
||||
longitude?: number
|
||||
nodeType: string
|
||||
peerCount?: number
|
||||
syncProgress?: number
|
||||
}
|
||||
|
||||
export interface NodeGlobeProps {
|
||||
nodes: NodeLocation[]
|
||||
onNodeClick?: (nodeId: string) => void
|
||||
selectedNetwork?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Region coordinate mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const REGION_COORDS: Record<string, { lat: number; lng: number; label: string }> = {
|
||||
// DigitalOcean regions
|
||||
"nyc1": { lat: 40.7128, lng: -74.0060, label: "New York 1" },
|
||||
"nyc3": { lat: 40.7128, lng: -74.0060, label: "New York 3" },
|
||||
"sfo1": { lat: 37.7749, lng: -122.4194, label: "San Francisco 1" },
|
||||
"sfo2": { lat: 37.7749, lng: -122.4194, label: "San Francisco 2" },
|
||||
"sfo3": { lat: 37.7749, lng: -122.4194, label: "San Francisco 3" },
|
||||
"ams3": { lat: 52.3676, lng: 4.9041, label: "Amsterdam" },
|
||||
"sgp1": { lat: 1.3521, lng: 103.8198, label: "Singapore" },
|
||||
"lon1": { lat: 51.5074, lng: -0.1278, label: "London" },
|
||||
"fra1": { lat: 50.1109, lng: 8.6821, label: "Frankfurt" },
|
||||
"tor1": { lat: 43.6532, lng: -79.3832, label: "Toronto" },
|
||||
"blr1": { lat: 12.9716, lng: 77.5946, label: "Bangalore" },
|
||||
"syd1": { lat: -33.8688, lng: 151.2093, label: "Sydney" },
|
||||
// AWS-style
|
||||
"us-east-1": { lat: 39.0438, lng: -77.4874, label: "N. Virginia" },
|
||||
"us-west-2": { lat: 45.5152, lng: -122.6784, label: "Oregon" },
|
||||
"eu-west-1": { lat: 53.3498, lng: -6.2603, label: "Ireland" },
|
||||
"ap-southeast-1": { lat: 1.3521, lng: 103.8198, label: "Singapore" },
|
||||
"ap-northeast-1": { lat: 35.6762, lng: 139.6503, label: "Tokyo" },
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status colour palette
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STATUS_COLORS: Record<NodeLocation["status"], string> = {
|
||||
running: "#22c55e",
|
||||
syncing: "#eab308",
|
||||
provisioning: "#f97316",
|
||||
stopped: "#6b7280",
|
||||
error: "#ef4444",
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<NodeLocation["status"], string> = {
|
||||
running: "Running",
|
||||
syncing: "Syncing",
|
||||
provisioning: "Provisioning",
|
||||
stopped: "Stopped",
|
||||
error: "Error",
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mercator projection helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Convert lat/lng to x/y in 0..1 normalised Mercator space. */
|
||||
function toMercator(lat: number, lng: number): { x: number; y: number } {
|
||||
const x = (lng + 180) / 360
|
||||
const latRad = (lat * Math.PI) / 180
|
||||
const mercN = Math.log(Math.tan(Math.PI / 4 + latRad / 2))
|
||||
const y = 0.5 - mercN / (2 * Math.PI)
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Simplified world-outline SVG path data
|
||||
//
|
||||
// Hand-reduced continent outlines in Mercator projection coordinates, fitting
|
||||
// a viewBox of 0 0 1000 500. Dark-themed: dark ocean, lighter landmasses.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WORLD_PATH = [
|
||||
// --- North America ---
|
||||
"M 130,105 L 122,100 118,92 120,84 126,78 135,74 148,72 162,70",
|
||||
"L 175,74 185,72 195,76 205,82 215,90 222,98 228,106 232,115",
|
||||
"L 230,124 224,132 218,140 212,148 205,156 198,162 192,168",
|
||||
"L 186,174 180,178 174,180 168,182 163,186 158,190 154,186",
|
||||
"L 150,180 146,175 142,168 138,160 135,152 132,144 130,136",
|
||||
"L 128,128 126,120 128,112 Z",
|
||||
// --- Mexico / Central America ---
|
||||
"M 154,186 L 158,192 162,198 165,205 168,212 172,218 175,224",
|
||||
"L 178,228 176,232 172,230 168,226 164,222 160,218 156,214",
|
||||
"L 153,208 152,200 152,194 Z",
|
||||
// --- South America ---
|
||||
"M 178,228 L 186,236 194,245 202,255 208,265 214,278 218,292",
|
||||
"L 222,306 225,320 226,335 224,348 220,360 215,370 208,378",
|
||||
"L 200,384 192,386 186,380 182,372 180,360 178,346 176,332",
|
||||
"L 175,318 176,304 178,290 180,276 182,262 183,248 180,238 Z",
|
||||
// --- Europe ---
|
||||
"M 448,82 L 455,76 465,72 476,70 486,72 495,76 502,82 506,90",
|
||||
"L 504,96 498,102 490,106 482,108 474,106 467,102 460,96",
|
||||
"L 454,90 450,86 Z",
|
||||
// --- British Isles ---
|
||||
"M 432,82 L 437,76 443,78 444,84 440,88 434,86 Z",
|
||||
// --- Scandinavia ---
|
||||
"M 476,50 L 484,44 492,42 498,46 502,54 500,62 496,68 490,66",
|
||||
"L 484,60 480,54 Z",
|
||||
// --- Africa ---
|
||||
"M 448,148 L 458,142 470,138 484,136 496,140 508,146 516,156",
|
||||
"L 522,168 526,182 528,196 526,212 522,228 518,244 512,258",
|
||||
"L 504,268 496,274 486,276 478,272 470,264 464,254 458,242",
|
||||
"L 454,228 452,214 450,200 448,186 446,172 446,158 Z",
|
||||
// --- Arabian Peninsula ---
|
||||
"M 530,142 L 540,138 548,142 552,150 548,158 540,162 534,158",
|
||||
"L 530,150 Z",
|
||||
// --- India subcontinent ---
|
||||
"M 582,132 L 594,128 604,132 612,142 616,156 612,170 606,180",
|
||||
"L 598,186 590,184 584,176 580,164 578,152 580,140 Z",
|
||||
// --- Southeast Asia peninsula ---
|
||||
"M 622,156 L 630,150 638,154 640,162 636,170 628,172 624,164 Z",
|
||||
// --- East Asia / China ---
|
||||
"M 618,80 L 630,74 644,70 658,68 672,72 682,80 690,90 692,102",
|
||||
"L 688,114 680,122 670,128 660,130 650,128 640,122 632,114",
|
||||
"L 626,104 622,94 620,86 Z",
|
||||
// --- Japan ---
|
||||
"M 698,86 L 704,80 710,84 712,92 710,102 704,108 698,104 696,96 Z",
|
||||
// --- Maritime Southeast Asia ---
|
||||
"M 632,212 L 644,206 658,204 672,208 684,214 694,220 690,228",
|
||||
"L 678,230 666,226 654,222 642,218 Z",
|
||||
// --- Australia ---
|
||||
"M 678,288 L 694,278 712,274 730,276 746,282 756,292 762,304",
|
||||
"L 760,318 752,330 740,336 726,338 712,334 700,326 690,316",
|
||||
"L 684,304 680,296 Z",
|
||||
// --- New Zealand ---
|
||||
"M 786,332 L 792,326 798,330 800,338 796,344 790,342 788,336 Z",
|
||||
// --- Northern Asia / Russia ---
|
||||
"M 508,52 L 530,46 558,42 588,38 620,36 654,34 686,38 712,44",
|
||||
"L 732,52 742,60 736,66 722,64 706,60 686,56 664,54 642,52",
|
||||
"L 620,50 598,48 576,48 556,50 536,52 520,54 512,54 Z",
|
||||
].join(" ")
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resolved node with pixel coordinates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ResolvedNode = NodeLocation & {
|
||||
px: number
|
||||
py: number
|
||||
regionLabel: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function NodeGlobe({
|
||||
nodes,
|
||||
onNodeClick,
|
||||
selectedNetwork,
|
||||
className,
|
||||
}: NodeGlobeProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [hoveredNode, setHoveredNode] = useState<ResolvedNode | null>(null)
|
||||
const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 })
|
||||
|
||||
const MAP_W = 1000
|
||||
const MAP_H = 500
|
||||
|
||||
// ------- derived data -------
|
||||
|
||||
const filteredNodes = useMemo(() => {
|
||||
if (!selectedNetwork || selectedNetwork === "all") return nodes
|
||||
return nodes.filter((n) => n.network === selectedNetwork)
|
||||
}, [nodes, selectedNetwork])
|
||||
|
||||
const resolvedNodes = useMemo<ResolvedNode[]>(() => {
|
||||
const out: ResolvedNode[] = []
|
||||
for (const node of filteredNodes) {
|
||||
let lat = node.latitude
|
||||
let lng = node.longitude
|
||||
let regionLabel = node.region
|
||||
|
||||
if ((lat == null || lng == null) && REGION_COORDS[node.region]) {
|
||||
const rc = REGION_COORDS[node.region]
|
||||
lat = rc.lat
|
||||
lng = rc.lng
|
||||
regionLabel = rc.label
|
||||
}
|
||||
|
||||
if (lat == null || lng == null) continue
|
||||
|
||||
const { x, y } = toMercator(lat, lng)
|
||||
out.push({ ...node, px: x * MAP_W, py: y * MAP_H, regionLabel })
|
||||
}
|
||||
return out
|
||||
}, [filteredNodes])
|
||||
|
||||
const networks = useMemo(() => {
|
||||
const s = new Set(nodes.map((n) => n.network))
|
||||
return Array.from(s).sort()
|
||||
}, [nodes])
|
||||
|
||||
// ------- event handlers -------
|
||||
|
||||
const handleMouseEnter = useCallback(
|
||||
(node: ResolvedNode, e: React.MouseEvent) => {
|
||||
setHoveredNode(node)
|
||||
const rect = containerRef.current?.getBoundingClientRect()
|
||||
if (rect) {
|
||||
setTooltipPos({
|
||||
x: e.clientX - rect.left + 14,
|
||||
y: e.clientY - rect.top - 10,
|
||||
})
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!hoveredNode) return
|
||||
const rect = containerRef.current?.getBoundingClientRect()
|
||||
if (rect) {
|
||||
setTooltipPos({
|
||||
x: e.clientX - rect.left + 14,
|
||||
y: e.clientY - rect.top - 10,
|
||||
})
|
||||
}
|
||||
},
|
||||
[hoveredNode],
|
||||
)
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setHoveredNode(null)
|
||||
}, [])
|
||||
|
||||
// ------- render -------
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cn("relative w-full", className)}>
|
||||
{/* Pulse keyframes injected once */}
|
||||
<style>{`
|
||||
@keyframes ng-dot-pulse {
|
||||
0%, 100% { r: 5; opacity: 0.9 }
|
||||
50% { r: 7; opacity: 1 }
|
||||
}
|
||||
@keyframes ng-ring-pulse {
|
||||
0% { r: 5; opacity: 0.5 }
|
||||
100% { r: 18; opacity: 0 }
|
||||
}
|
||||
.ng-dot-running { animation: ng-dot-pulse 2s ease-in-out infinite }
|
||||
.ng-dot-syncing { animation: ng-dot-pulse 1.2s ease-in-out infinite }
|
||||
.ng-ring { animation: ng-ring-pulse 2.5s ease-out infinite; pointer-events: none }
|
||||
`}</style>
|
||||
|
||||
{/* SVG flat map */}
|
||||
<svg
|
||||
viewBox={`0 0 ${MAP_W} ${MAP_H}`}
|
||||
className="w-full h-auto"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
role="img"
|
||||
aria-label="World map showing blockchain node locations"
|
||||
>
|
||||
{/* Ocean background */}
|
||||
<rect width={MAP_W} height={MAP_H} rx={8} fill="#0a0e17" />
|
||||
|
||||
{/* Graticule grid */}
|
||||
{Array.from({ length: 17 }, (_, i) => {
|
||||
const x = (i + 1) * (MAP_W / 18)
|
||||
return <line key={`v${i}`} x1={x} y1={0} x2={x} y2={MAP_H} stroke="#1e293b" strokeWidth={0.5} />
|
||||
})}
|
||||
{Array.from({ length: 8 }, (_, i) => {
|
||||
const y = (i + 1) * (MAP_H / 9)
|
||||
return <line key={`h${i}`} x1={0} y1={y} x2={MAP_W} y2={y} stroke="#1e293b" strokeWidth={0.5} />
|
||||
})}
|
||||
|
||||
{/* Landmasses */}
|
||||
<path d={WORLD_PATH} fill="#1a2332" stroke="#2d3f54" strokeWidth={1} strokeLinejoin="round" />
|
||||
|
||||
{/* Node markers */}
|
||||
{resolvedNodes.map((node) => {
|
||||
const color = STATUS_COLORS[node.status]
|
||||
const hovered = hoveredNode?.id === node.id
|
||||
// Stagger animation starts to avoid synchronised pulses
|
||||
const delay = `${(node.id.charCodeAt(0) % 7) * 0.35}s`
|
||||
|
||||
return (
|
||||
<g
|
||||
key={node.id}
|
||||
transform={`translate(${node.px},${node.py})`}
|
||||
className="cursor-pointer"
|
||||
onMouseEnter={(e) => handleMouseEnter(node, e)}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={() => onNodeClick?.(node.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${node.name} - ${STATUS_LABELS[node.status]}`}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") onNodeClick?.(node.id)
|
||||
}}
|
||||
>
|
||||
{/* Expanding ring for active statuses */}
|
||||
{(node.status === "running" || node.status === "syncing") && (
|
||||
<circle
|
||||
r={5}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={1.5}
|
||||
className="ng-ring"
|
||||
style={{ animationDelay: delay }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Ambient glow */}
|
||||
<circle r={hovered ? 12 : 8} fill={color} opacity={0.15} />
|
||||
|
||||
{/* Primary dot */}
|
||||
<circle
|
||||
r={hovered ? 7 : 5}
|
||||
fill={color}
|
||||
stroke="#0a0e17"
|
||||
strokeWidth={1.5}
|
||||
opacity={0.9}
|
||||
className={cn(
|
||||
node.status === "running" && "ng-dot-running",
|
||||
node.status === "syncing" && "ng-dot-syncing",
|
||||
)}
|
||||
style={{ animationDelay: delay }}
|
||||
/>
|
||||
|
||||
{/* Centre highlight */}
|
||||
<circle r={hovered ? 3 : 2} fill="#fff" opacity={0.6} />
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Tooltip */}
|
||||
{hoveredNode && (
|
||||
<div
|
||||
className="absolute z-50 pointer-events-none rounded-lg border border-border bg-popover p-3 text-sm shadow-lg min-w-[180px]"
|
||||
style={{
|
||||
left: tooltipPos.x,
|
||||
top: tooltipPos.y,
|
||||
transform:
|
||||
tooltipPos.x > (containerRef.current?.clientWidth ?? 800) * 0.7
|
||||
? "translateX(-110%)"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<div className="font-medium text-foreground">{hoveredNode.name}</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
{hoveredNode.chain} / {hoveredNode.network}
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ background: STATUS_COLORS[hoveredNode.status] }}
|
||||
/>
|
||||
<span className="text-foreground">{STATUS_LABELS[hoveredNode.status]}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{hoveredNode.regionLabel}</div>
|
||||
<div className="text-xs text-muted-foreground">Type: {hoveredNode.nodeType}</div>
|
||||
{hoveredNode.peerCount != null && (
|
||||
<div className="text-xs text-muted-foreground">Peers: {hoveredNode.peerCount}</div>
|
||||
)}
|
||||
{hoveredNode.syncProgress != null && hoveredNode.status === "syncing" && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Sync: {(hoveredNode.syncProgress * 100).toFixed(1)}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 px-1 text-xs text-muted-foreground">
|
||||
{(Object.keys(STATUS_COLORS) as NodeLocation["status"][]).map((status) => (
|
||||
<div key={status} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 rounded-full"
|
||||
style={{ background: STATUS_COLORS[status] }}
|
||||
/>
|
||||
<span>{STATUS_LABELS[status]}</span>
|
||||
</div>
|
||||
))}
|
||||
{networks.length > 0 && (
|
||||
<div className="ml-auto">
|
||||
{resolvedNodes.length} node{resolvedNodes.length !== 1 ? "s" : ""}
|
||||
{selectedNetwork && selectedNetwork !== "all" ? ` on ${selectedNetwork}` : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user