Alexis Bruneteau f825a2392c feat: Implement dark theme for frontend with toggle
Changes:
- Add next-themes dependency for theme management
- Create ThemeProvider wrapper for app root layout
- Set dark mode as default theme
- Create ThemeToggle component with Sun/Moon icons
- Add theme toggle to home page navigation
- Add theme toggle to dashboard header
- App now starts in dark mode with ability to switch to light mode

Styling uses existing Tailwind dark mode variables configured in
tailwind.config.ts and globals.css. All existing components automatically
support dark theme.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 16:35:44 +01:00

352 lines
10 KiB
Python

"""
Bootnode Service - Peer Discovery for PoA Blockchain Validators
This service helps validators discover each other and bootstrap into the network.
It maintains a registry of known peers and provides discovery endpoints.
Features:
- Peer registration endpoint (POST /register_peer)
- Peer discovery endpoint (GET /discover)
- Peer listing endpoint (GET /peers)
- Health check endpoint
- Periodic cleanup of stale peers
"""
import os
import time
import json
import logging
from typing import Dict, List, Optional
from datetime import datetime, timedelta
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel
import asyncio
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# ============================================================================
# Data Models
# ============================================================================
class PeerInfo(BaseModel):
"""Information about a validator peer"""
node_id: str
ip: str
p2p_port: int
rpc_port: int
public_key: Optional[str] = None
class PeerRegistration(BaseModel):
"""Request to register a peer"""
node_id: str
ip: str
p2p_port: int
rpc_port: int
public_key: Optional[str] = None
class PeerDiscoveryResponse(BaseModel):
"""Response from peer discovery"""
peers: List[PeerInfo]
count: int
class HealthResponse(BaseModel):
"""Health check response"""
status: str
timestamp: str
peers_count: int
# ============================================================================
# Bootnode Service
# ============================================================================
class PeerRegistry:
"""In-memory registry of known peers with expiration"""
def __init__(self, peer_timeout_seconds: int = 300):
self.peers: Dict[str, dict] = {} # node_id -> peer info with timestamp
self.peer_timeout = peer_timeout_seconds
def register_peer(self, peer: PeerInfo) -> None:
"""Register or update a peer"""
self.peers[peer.node_id] = {
"info": peer,
"registered_at": time.time(),
"last_heartbeat": time.time()
}
logger.info(
f"Peer registered: {peer.node_id} "
f"({peer.ip}:{peer.p2p_port}, RPC:{peer.rpc_port})"
)
def update_heartbeat(self, node_id: str) -> None:
"""Update heartbeat timestamp for a peer"""
if node_id in self.peers:
self.peers[node_id]["last_heartbeat"] = time.time()
def get_peer(self, node_id: str) -> Optional[PeerInfo]:
"""Get a peer by node_id"""
if node_id in self.peers:
return self.peers[node_id]["info"]
return None
def get_all_peers(self) -> List[PeerInfo]:
"""Get all active peers"""
return [entry["info"] for entry in self.peers.values()]
def get_peers_except(self, exclude_node_id: str) -> List[PeerInfo]:
"""Get all peers except the specified one"""
return [
entry["info"]
for node_id, entry in self.peers.items()
if node_id != exclude_node_id
]
def cleanup_stale_peers(self) -> int:
"""Remove peers that haven't sent heartbeat recently"""
current_time = time.time()
stale_peers = [
node_id for node_id, entry in self.peers.items()
if (current_time - entry["last_heartbeat"]) > self.peer_timeout
]
for node_id in stale_peers:
logger.warning(f"Removing stale peer: {node_id}")
del self.peers[node_id]
return len(stale_peers)
# ============================================================================
# FastAPI Application
# ============================================================================
app = FastAPI(
title="E-Voting Bootnode",
description="Peer discovery service for PoA validators",
version="1.0.0"
)
# Global peer registry
peer_registry = PeerRegistry(peer_timeout_seconds=300)
# ============================================================================
# Health Check
# ============================================================================
@app.get("/health", response_model=HealthResponse)
async def health_check():
"""Health check endpoint"""
return HealthResponse(
status="healthy",
timestamp=datetime.utcnow().isoformat(),
peers_count=len(peer_registry.get_all_peers())
)
# ============================================================================
# Peer Registration
# ============================================================================
@app.post("/register_peer", response_model=PeerDiscoveryResponse)
async def register_peer(peer: PeerRegistration):
"""
Register a peer node with the bootnode.
The peer must provide:
- node_id: Unique identifier (e.g., "validator-1")
- ip: IP address or Docker service name
- p2p_port: Port for P2P communication
- rpc_port: Port for JSON-RPC communication
- public_key: (Optional) Validator's public key for signing
Returns: List of other known peers
"""
try:
# Register the peer
peer_info = PeerInfo(**peer.dict())
peer_registry.register_peer(peer_info)
# Return other known peers
other_peers = peer_registry.get_peers_except(peer.node_id)
logger.info(f"Registration successful. Peer {peer.node_id} now knows {len(other_peers)} peers")
return PeerDiscoveryResponse(
peers=other_peers,
count=len(other_peers)
)
except Exception as e:
logger.error(f"Error registering peer: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Failed to register peer: {str(e)}"
)
# ============================================================================
# Peer Discovery
# ============================================================================
@app.get("/discover", response_model=PeerDiscoveryResponse)
async def discover_peers(node_id: str):
"""
Discover peers currently in the network.
Query Parameters:
- node_id: The requesting peer's node_id (to exclude from results)
Returns: List of all other known peers
"""
try:
# Update heartbeat for the requesting peer
peer_registry.update_heartbeat(node_id)
# Return all peers except the requester
peers = peer_registry.get_peers_except(node_id)
logger.info(f"Discovery request from {node_id}: returning {len(peers)} peers")
return PeerDiscoveryResponse(
peers=peers,
count=len(peers)
)
except Exception as e:
logger.error(f"Error discovering peers: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to discover peers: {str(e)}"
)
# ============================================================================
# Peer Listing (Admin)
# ============================================================================
@app.get("/peers", response_model=PeerDiscoveryResponse)
async def list_all_peers():
"""
List all known peers (admin endpoint).
Returns: All registered peers
"""
peers = peer_registry.get_all_peers()
return PeerDiscoveryResponse(
peers=peers,
count=len(peers)
)
# ============================================================================
# Peer Heartbeat
# ============================================================================
@app.post("/heartbeat")
async def peer_heartbeat(node_id: str):
"""
Send a heartbeat to indicate the peer is still alive.
Query Parameters:
- node_id: The peer's node_id
This keeps the peer in the registry and prevents timeout.
"""
try:
peer_registry.update_heartbeat(node_id)
logger.debug(f"Heartbeat received from {node_id}")
return {
"status": "ok",
"timestamp": datetime.utcnow().isoformat()
}
except Exception as e:
logger.error(f"Error processing heartbeat: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Failed to process heartbeat: {str(e)}"
)
# ============================================================================
# Stats (Admin)
# ============================================================================
@app.get("/stats")
async def get_stats():
"""Get bootnode statistics"""
peers = peer_registry.get_all_peers()
return {
"total_peers": len(peers),
"peers": [
{
"node_id": p.node_id,
"ip": p.ip,
"p2p_port": p.p2p_port,
"rpc_port": p.rpc_port
}
for p in peers
],
"timestamp": datetime.utcnow().isoformat()
}
# ============================================================================
# Background Tasks
# ============================================================================
async def cleanup_stale_peers_task():
"""Periodic task to cleanup stale peers"""
while True:
await asyncio.sleep(60) # Cleanup every 60 seconds
removed_count = peer_registry.cleanup_stale_peers()
if removed_count > 0:
logger.info(f"Cleaned up {removed_count} stale peers")
@app.on_event("startup")
async def startup_event():
"""Start background tasks on application startup"""
logger.info("Bootnode starting up...")
asyncio.create_task(cleanup_stale_peers_task())
logger.info("Cleanup task started")
@app.on_event("shutdown")
async def shutdown_event():
"""Log shutdown"""
logger.info("Bootnode shutting down...")
# ============================================================================
# Main
# ============================================================================
if __name__ == "__main__":
import uvicorn
port = int(os.getenv("BOOTNODE_PORT", "8546"))
host = os.getenv("BOOTNODE_HOST", "0.0.0.0")
logger.info(f"Starting bootnode on {host}:{port}")
uvicorn.run(
app,
host=host,
port=port,
log_level="info"
)