diff --git a/ambassadors/interchained-node-operator-portal/README.md b/ambassadors/interchained-node-operator-portal/README.md new file mode 100644 index 000000000..cf9a4714b --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/README.md @@ -0,0 +1,70 @@ +# Interchained Node Operator Rewards Portal + +This directory contains a full-stack prototype of the Interchained Node Operator +Rewards Portal described in the ambassadors brief. The project is split into a +FastAPI backend powered entirely by Redis (database 6) and a Next.js frontend +with a neon glass aesthetic. + +## Backend + +* Framework: FastAPI +* Data store: Redis DB 6 +* Location: `backend/` + +Key features: + +* Email + password authentication with bearer token sessions. +* Node registration and CRUD endpoints. +* Background monitor that pings registered nodes every 60 seconds to track + uptime, responsiveness, and block height. +* Daily reward distribution job that computes payouts based on uptime scores and + records a per-user history in Redis. + +Run locally with: + +```bash +cd backend +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +uvicorn main:app --reload +``` + +Set the `REDIS_URL` environment variable if Redis is not available at the +default `redis://localhost:6379/6`. + +## Frontend + +The frontend is scaffolded with Next.js and TailwindCSS. It provides a neon +inspired operator dashboard including login, node management, uptime metrics, +and reward visualisations. + +``` +frontend/ +├─ pages/ +│ ├─ index.js +│ ├─ login.js +│ ├─ dashboard.js +│ ├─ nodes.js +│ └─ rewards.js +├─ components/ +│ ├─ NeonCard.js +│ ├─ GlassContainer.js +│ ├─ NodeTable.js +│ ├─ RewardGraph.js +│ └─ StatusBadge.js +└─ styles/ + └─ globals.css +``` + +Install dependencies and run the dev server: + +```bash +cd frontend +npm install +npm run dev +``` + +The frontend expects the FastAPI backend to be available at +`http://localhost:8000` and uses the same Redis instance configured for the +backend to read metrics and reward data. diff --git a/ambassadors/interchained-node-operator-portal/backend/__init__.py b/ambassadors/interchained-node-operator-portal/backend/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ambassadors/interchained-node-operator-portal/backend/auth.py b/ambassadors/interchained-node-operator-portal/backend/auth.py new file mode 100644 index 000000000..e8f300e34 --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/backend/auth.py @@ -0,0 +1,78 @@ +"""Authentication utilities for the Node Operator portal.""" +from __future__ import annotations + +import secrets +from datetime import datetime, timedelta +from typing import Optional + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from passlib.context import CryptContext + +from .models import UserCreate, UserPublic +from .utils.redis_client import get_redis + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +TOKEN_TTL = int(timedelta(days=7).total_seconds()) +security = HTTPBearer(auto_error=False) + + +async def get_user(email: str) -> Optional[dict[str, str]]: + redis = await get_redis() + user_key = f"users:{email}" + user = await redis.hgetall(user_key) + return user or None + + +async def create_user(payload: UserCreate) -> UserPublic: + redis = await get_redis() + user_key = f"users:{payload.email}" + exists = await redis.exists(user_key) + if exists: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User already exists") + + hashed = pwd_context.hash(payload.password) + created_at = datetime.utcnow().isoformat() + await redis.hset( + user_key, + mapping={ + "email": payload.email, + "password": hashed, + "created_at": created_at, + }, + ) + return UserPublic(email=payload.email, created_at=datetime.fromisoformat(created_at)) + + +async def authenticate_user(email: str, password: str) -> Optional[UserPublic]: + user = await get_user(email) + if not user: + return None + if not pwd_context.verify(password, user.get("password", "")): + return None + return UserPublic(email=email, created_at=datetime.fromisoformat(user["created_at"])) + + +async def create_session_token(email: str) -> str: + redis = await get_redis() + token = secrets.token_urlsafe(32) + await redis.setex(f"sessions:{token}", TOKEN_TTL, email) + return token + + +async def resolve_token(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)) -> str: + if credentials is None or credentials.scheme.lower() != "bearer": + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing credentials") + redis = await get_redis() + email = await redis.get(f"sessions:{credentials.credentials}") + if not email: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + return email + + +async def get_current_user(email: str = Depends(resolve_token)) -> UserPublic: + user = await get_user(email) + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive user") + return UserPublic(email=email, created_at=datetime.fromisoformat(user["created_at"])) diff --git a/ambassadors/interchained-node-operator-portal/backend/main.py b/ambassadors/interchained-node-operator-portal/backend/main.py new file mode 100644 index 000000000..7814d6451 --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/backend/main.py @@ -0,0 +1,46 @@ +"""FastAPI entry point for the Node Operator Rewards Portal backend.""" +from __future__ import annotations + +import asyncio +from contextlib import asynccontextmanager +from typing import AsyncIterator + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from .node_monitor import NodeMonitor +from .rewards import RewardDistributor +from .routes import nodes, rewards, users +from .utils.redis_client import close_redis + + +@asynccontextmanager +def lifespan(_: FastAPI) -> AsyncIterator[None]: + monitor = NodeMonitor() + rewards_job = RewardDistributor() + await asyncio.gather(monitor.start(), rewards_job.start()) + try: + yield + finally: + await asyncio.gather(monitor.stop(), rewards_job.stop()) + await close_redis() + + +app = FastAPI(title="Interchained Node Operator Rewards Portal", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(users.router) +app.include_router(nodes.router) +app.include_router(rewards.router) + + +@app.get("/") +async def root() -> dict[str, str]: + return {"status": "ok"} diff --git a/ambassadors/interchained-node-operator-portal/backend/models.py b/ambassadors/interchained-node-operator-portal/backend/models.py new file mode 100644 index 000000000..04c4b44e2 --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/backend/models.py @@ -0,0 +1,64 @@ +"""Pydantic models used across the FastAPI application.""" +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, EmailStr, Field, HttpUrl + + +class UserCreate(BaseModel): + email: EmailStr + password: str = Field(min_length=8) + + +class UserLogin(BaseModel): + email: EmailStr + password: str + + +class UserPublic(BaseModel): + email: EmailStr + created_at: datetime + + +class NodeRegistration(BaseModel): + email: EmailStr + p2p_address: str = Field(description="IP or hostname with port, e.g. node.example:18080") + rpc_url: HttpUrl + wallet_address: str + + +class NodeUpdate(BaseModel): + p2p_address: Optional[str] = None + rpc_url: Optional[HttpUrl] = None + wallet_address: Optional[str] = None + + +class NodeStatus(BaseModel): + email: EmailStr + p2p_address: str + rpc_url: HttpUrl + wallet_address: str + last_seen: Optional[datetime] = None + uptime_score: float = 0.0 + total_checks: int = 0 + successful_checks: int = 0 + latency_ms: Optional[float] = None + block_height: Optional[int] = None + + +class RewardSummary(BaseModel): + date: datetime + rewards: dict[str, float] + pool_balance: float + + +class RewardHistoryItem(BaseModel): + date: datetime + amount: float + + +class RewardHistory(BaseModel): + email: EmailStr + history: list[RewardHistoryItem] diff --git a/ambassadors/interchained-node-operator-portal/backend/node_monitor.py b/ambassadors/interchained-node-operator-portal/backend/node_monitor.py new file mode 100644 index 000000000..f362c5ae3 --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/backend/node_monitor.py @@ -0,0 +1,67 @@ +"""Background task that periodically checks node health metrics.""" +from __future__ import annotations + +import asyncio +from datetime import datetime +from typing import Any + +from .utils.bitcoin_rpc import check_node_health +from .utils.redis_client import get_redis, iter_hash_keys + + +class NodeMonitor: + """Continuously check registered nodes and persist uptime metrics.""" + + def __init__(self, interval_seconds: int = 60) -> None: + self._interval = interval_seconds + self._task: asyncio.Task[Any] | None = None + self._stop_event = asyncio.Event() + + async def start(self) -> None: + if self._task is None: + self._stop_event.clear() + self._task = asyncio.create_task(self._run()) + + async def stop(self) -> None: + if self._task: + self._stop_event.set() + await self._task + self._task = None + + async def _run(self) -> None: + while not self._stop_event.is_set(): + await self._check_all_nodes() + try: + await asyncio.wait_for(self._stop_event.wait(), timeout=self._interval) + except asyncio.TimeoutError: + continue + + async def _check_all_nodes(self) -> None: + redis = await get_redis() + async for key in iter_hash_keys("node:*"): + node = await redis.hgetall(key) + if not node: + continue + email = node.get("email") + if not email: + continue + health = await check_node_health(node.get("p2p_address", ""), node.get("rpc_url", "")) + stats_key = f"uptime:{email}" + total_checks = await redis.hincrby(stats_key, "total_checks", 1) + if health.is_online: + successful_checks = await redis.hincrby(stats_key, "successful_checks", 1) + else: + successful_checks = int(await redis.hget(stats_key, "successful_checks") or 0) + uptime_score = successful_checks / total_checks if total_checks else 0.0 + await redis.hset( + stats_key, + mapping={ + "total_checks": total_checks, + "successful_checks": successful_checks, + "uptime_score": uptime_score, + "last_seen": datetime.utcnow().isoformat(), + "latency_ms": health.latency_ms, + "block_height": health.block_height or 0, + "rpc_responding": int(health.rpc_responding), + }, + ) diff --git a/ambassadors/interchained-node-operator-portal/backend/requirements.txt b/ambassadors/interchained-node-operator-portal/backend/requirements.txt new file mode 100644 index 000000000..a76219c32 --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/backend/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.110.0 +uvicorn[standard]==0.27.1 +redis==5.0.4 +httpx==0.27.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.9 diff --git a/ambassadors/interchained-node-operator-portal/backend/rewards.py b/ambassadors/interchained-node-operator-portal/backend/rewards.py new file mode 100644 index 000000000..df206e9f3 --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/backend/rewards.py @@ -0,0 +1,90 @@ +"""Reward distribution utilities.""" +from __future__ import annotations + +import asyncio +from datetime import datetime, timedelta +from typing import Any + +from .utils.redis_client import get_redis, iter_hash_keys + + +REWARD_PREFIX = "rewards:" +REWARD_HISTORY_PREFIX = "rewards:history:" + + +async def distribute_daily_rewards(date: datetime | None = None) -> dict[str, float]: + redis = await get_redis() + snapshot_date = (date or datetime.utcnow()).date() + date_key = f"{REWARD_PREFIX}{snapshot_date.isoformat()}" + + total_pool_raw = await redis.get("pool:balance") + total_pool = float(total_pool_raw or 0) + if total_pool <= 0: + return {} + + active_nodes: list[tuple[str, float]] = [] + async for key in iter_hash_keys("uptime:*"): + stats = await redis.hgetall(key) + email = key.split(":", 1)[1] + score = float(stats.get("uptime_score", 0)) + if score >= 0.9: + active_nodes.append((email, score)) + + if not active_nodes: + return {} + + total_weight = sum(score for _, score in active_nodes) + if total_weight == 0: + return {} + + rewards: dict[str, float] = {} + for email, score in active_nodes: + share = (score / total_weight) * total_pool + rewards[email] = round(share, 8) + + if rewards: + await redis.hset(date_key, mapping=rewards) + await redis.set("pool:balance", 0) + for email, amount in rewards.items(): + await redis.lpush( + f"{REWARD_HISTORY_PREFIX}{email}", + f"{snapshot_date.isoformat()}:{amount}", + ) + return rewards + + +class RewardDistributor: + """Background job that triggers a distribution once every 24 hours.""" + + def __init__(self, run_at_hour: int = 0, run_at_minute: int = 5) -> None: + self._run_at_hour = run_at_hour + self._run_at_minute = run_at_minute + self._task: asyncio.Task[Any] | None = None + self._stop_event = asyncio.Event() + + async def start(self) -> None: + if self._task is None: + self._stop_event.clear() + self._task = asyncio.create_task(self._loop()) + + async def stop(self) -> None: + if self._task: + self._stop_event.set() + await self._task + self._task = None + + async def _loop(self) -> None: + while not self._stop_event.is_set(): + seconds_until_run = self._seconds_until_next_run() + try: + await asyncio.wait_for(self._stop_event.wait(), timeout=seconds_until_run) + continue + except asyncio.TimeoutError: + await distribute_daily_rewards() + + def _seconds_until_next_run(self) -> float: + now = datetime.utcnow() + next_run = now.replace(hour=self._run_at_hour, minute=self._run_at_minute, second=0, microsecond=0) + if next_run <= now: + next_run = next_run + timedelta(days=1) + return (next_run - now).total_seconds() diff --git a/ambassadors/interchained-node-operator-portal/backend/routes/__init__.py b/ambassadors/interchained-node-operator-portal/backend/routes/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ambassadors/interchained-node-operator-portal/backend/routes/nodes.py b/ambassadors/interchained-node-operator-portal/backend/routes/nodes.py new file mode 100644 index 000000000..a3d7177e2 --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/backend/routes/nodes.py @@ -0,0 +1,76 @@ +"""Node registration and management endpoints.""" +from __future__ import annotations + +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException, status + +from .. import auth +from ..models import NodeRegistration, NodeStatus, NodeUpdate, UserPublic +from ..utils.redis_client import get_redis + +router = APIRouter(prefix="/nodes", tags=["nodes"]) + + +@router.post("/register", response_model=NodeStatus, status_code=status.HTTP_201_CREATED) +async def register_node(payload: NodeRegistration, current_user: UserPublic = Depends(auth.get_current_user)) -> NodeStatus: + if payload.email != current_user.email: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Email mismatch") + + redis = await get_redis() + node_key = f"node:{payload.email}" + await redis.hset( + node_key, + mapping={ + "email": payload.email, + "p2p_address": payload.p2p_address, + "rpc_url": str(payload.rpc_url), + "wallet_address": payload.wallet_address, + "created_at": datetime.utcnow().isoformat(), + }, + ) + return await _build_node_status(payload.email) + + +@router.patch("/me", response_model=NodeStatus) +async def update_node(payload: NodeUpdate, current_user: UserPublic = Depends(auth.get_current_user)) -> NodeStatus: + redis = await get_redis() + node_key = f"node:{current_user.email}" + exists = await redis.exists(node_key) + if not exists: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Node not registered") + + updates = {k: v for k, v in payload.model_dump(exclude_none=True).items()} + if updates: + await redis.hset(node_key, mapping=updates) + return await _build_node_status(current_user.email) + + +@router.get("/me", response_model=NodeStatus) +async def get_node(current_user: UserPublic = Depends(auth.get_current_user)) -> NodeStatus: + return await _build_node_status(current_user.email) + + +async def _build_node_status(email: str) -> NodeStatus: + redis = await get_redis() + node = await redis.hgetall(f"node:{email}") + if not node: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Node not registered") + + stats = await redis.hgetall(f"uptime:{email}") + latency_raw = float(stats.get("latency_ms", 0)) if stats.get("latency_ms") else None + if latency_raw is not None and latency_raw < 0: + latency_raw = None + + return NodeStatus( + email=email, + p2p_address=node.get("p2p_address", ""), + rpc_url=node.get("rpc_url", ""), + wallet_address=node.get("wallet_address", ""), + last_seen=datetime.fromisoformat(stats["last_seen"]) if stats.get("last_seen") else None, + uptime_score=float(stats.get("uptime_score", 0.0)), + total_checks=int(stats.get("total_checks", 0)), + successful_checks=int(stats.get("successful_checks", 0)), + latency_ms=latency_raw, + block_height=int(stats.get("block_height", 0)) if stats.get("block_height") else None, + ) diff --git a/ambassadors/interchained-node-operator-portal/backend/routes/rewards.py b/ambassadors/interchained-node-operator-portal/backend/routes/rewards.py new file mode 100644 index 000000000..363214330 --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/backend/routes/rewards.py @@ -0,0 +1,48 @@ +"""Reward data endpoints.""" +from __future__ import annotations + +from datetime import date, datetime + +from fastapi import APIRouter, Depends + +from .. import auth +from ..models import RewardHistory, RewardHistoryItem, RewardSummary, UserPublic +from ..rewards import distribute_daily_rewards +from ..utils.redis_client import get_redis + +router = APIRouter(prefix="/rewards", tags=["rewards"]) + + +@router.post("/run", response_model=RewardSummary) +async def trigger_rewards(_: UserPublic = Depends(auth.get_current_user)) -> RewardSummary: + rewards = await distribute_daily_rewards() + snapshot = datetime.utcnow() + redis = await get_redis() + pool_balance = float(await redis.get("pool:balance") or 0) + return RewardSummary(date=snapshot, rewards=rewards, pool_balance=pool_balance) + + +@router.get("/today", response_model=RewardSummary) +async def rewards_today(_: UserPublic = Depends(auth.get_current_user)) -> RewardSummary: + today = date.today().isoformat() + redis = await get_redis() + rewards = await redis.hgetall(f"rewards:{today}") + rewards_float = {email: float(amount) for email, amount in rewards.items()} + pool_balance = float(await redis.get("pool:balance") or 0) + return RewardSummary(date=datetime.utcnow(), rewards=rewards_float, pool_balance=pool_balance) + + +@router.get("/history", response_model=RewardHistory) +async def reward_history(current_user: UserPublic = Depends(auth.get_current_user)) -> RewardHistory: + redis = await get_redis() + entries = await redis.lrange(f"rewards:history:{current_user.email}", 0, 30) + history: list[RewardHistoryItem] = [] + for entry in entries: + try: + date_str, amount_str = entry.split(":", 1) + history.append( + RewardHistoryItem(date=datetime.fromisoformat(date_str), amount=float(amount_str)) + ) + except ValueError: + continue + return RewardHistory(email=current_user.email, history=list(reversed(history))) diff --git a/ambassadors/interchained-node-operator-portal/backend/routes/users.py b/ambassadors/interchained-node-operator-portal/backend/routes/users.py new file mode 100644 index 000000000..96f6d80cd --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/backend/routes/users.py @@ -0,0 +1,28 @@ +"""User authentication routes.""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, status + +from .. import auth +from ..models import UserCreate, UserLogin, UserPublic + +router = APIRouter(prefix="/users", tags=["users"]) + + +@router.post("/register", response_model=UserPublic, status_code=status.HTTP_201_CREATED) +async def register_user(payload: UserCreate) -> UserPublic: + return await auth.create_user(payload) + + +@router.post("/login") +async def login_user(payload: UserLogin) -> dict[str, str]: + user = await auth.authenticate_user(payload.email, payload.password) + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") + token = await auth.create_session_token(user.email) + return {"access_token": token, "token_type": "bearer"} + + +@router.get("/me", response_model=UserPublic) +async def get_me(current_user: UserPublic = Depends(auth.get_current_user)) -> UserPublic: + return current_user diff --git a/ambassadors/interchained-node-operator-portal/backend/utils/__init__.py b/ambassadors/interchained-node-operator-portal/backend/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ambassadors/interchained-node-operator-portal/backend/utils/bitcoin_rpc.py b/ambassadors/interchained-node-operator-portal/backend/utils/bitcoin_rpc.py new file mode 100644 index 000000000..8b6296838 --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/backend/utils/bitcoin_rpc.py @@ -0,0 +1,82 @@ +"""Helpers for performing lightweight node health checks.""" +from __future__ import annotations + +import asyncio +import json +import time +from dataclasses import dataclass +from typing import Optional +from urllib.parse import urlparse + +import httpx + + +@dataclass +class NodeHealth: + is_online: bool + latency_ms: float + block_height: Optional[int] + rpc_responding: bool + + +async def _check_tcp_connectivity(host: str, port: int, timeout: float = 2.0) -> float: + start = time.perf_counter() + try: + reader, writer = await asyncio.wait_for( + asyncio.open_connection(host, port), timeout=timeout + ) + except (OSError, asyncio.TimeoutError): + return -1.0 + else: + writer.close() + await writer.wait_closed() + return (time.perf_counter() - start) * 1000 + + +async def _fetch_rpc_height(rpc_url: str, timeout: float = 2.0) -> tuple[bool, Optional[int]]: + try: + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.post( + rpc_url, + json={"jsonrpc": "1.0", "id": "health", "method": "getblockcount", "params": []}, + ) + response.raise_for_status() + payload = response.json() + except (httpx.HTTPError, json.JSONDecodeError): + return False, None + + height = None + if isinstance(payload, dict): + height = payload.get("result") + if isinstance(height, str) and height.isdigit(): + height = int(height) + return True, height if isinstance(height, int) else None + + +async def check_node_health(p2p_address: str, rpc_url: str) -> NodeHealth: + """Check whether a node responds over P2P and RPC interfaces.""" + + latency_ms = -1.0 + rpc_ok = False + block_height: Optional[int] = None + + if ":" in p2p_address: + host, port_str = p2p_address.rsplit(":", 1) + try: + port = int(port_str) + except ValueError: + port = 0 + if host and port: + latency_ms = await _check_tcp_connectivity(host, port) + + parsed = urlparse(rpc_url) + if parsed.scheme and parsed.netloc: + rpc_ok, block_height = await _fetch_rpc_height(rpc_url) + + is_online = latency_ms >= 0 and rpc_ok + return NodeHealth( + is_online=is_online, + latency_ms=latency_ms if latency_ms >= 0 else -1, + block_height=block_height, + rpc_responding=rpc_ok, + ) diff --git a/ambassadors/interchained-node-operator-portal/backend/utils/redis_client.py b/ambassadors/interchained-node-operator-portal/backend/utils/redis_client.py new file mode 100644 index 000000000..958658b2c --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/backend/utils/redis_client.py @@ -0,0 +1,52 @@ +"""Utility helpers for working with Redis connections.""" +from __future__ import annotations + +import os +from functools import lru_cache +from typing import AsyncIterator + +from redis.asyncio import Redis + + +@lru_cache +def _build_redis_client() -> Redis: + """Instantiate a Redis client targeting DB 6 by default. + + The connection URL can be overridden with the ``REDIS_URL`` environment + variable so deployments can supply their own Redis host/port credentials. + """ + + url = os.getenv("REDIS_URL", "redis://localhost:6379/6") + return Redis.from_url(url, decode_responses=True) + + +async def get_redis() -> Redis: + """Return a cached Redis client instance. + + The redis-py asyncio client manages its own connection pool so we can share + the instance safely between requests. The application is responsible for + calling :func:`close_redis` on shutdown to close the pool cleanly. + """ + + return _build_redis_client() + + +async def close_redis() -> None: + """Close the cached Redis connection pool, if one has been instantiated.""" + + client = _build_redis_client() + await client.close() + + +async def iter_hash_keys(prefix: str) -> AsyncIterator[str]: + """Iterate over keys that match the provided hash prefix. + + Parameters + ---------- + prefix: + A glob-compatible prefix, e.g. ``"node:*"``. + """ + + client = await get_redis() + async for key in client.scan_iter(match=prefix): + yield key diff --git a/ambassadors/interchained-node-operator-portal/frontend/.eslintrc.json b/ambassadors/interchained-node-operator-portal/frontend/.eslintrc.json new file mode 100644 index 000000000..97a2bb84e --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/frontend/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next", "next/core-web-vitals"] +} diff --git a/ambassadors/interchained-node-operator-portal/frontend/components/GlassContainer.js b/ambassadors/interchained-node-operator-portal/frontend/components/GlassContainer.js new file mode 100644 index 000000000..7d898831a --- /dev/null +++ b/ambassadors/interchained-node-operator-portal/frontend/components/GlassContainer.js @@ -0,0 +1,7 @@ +export default function GlassContainer({ children, className = '' }) { + return ( +
{title}
+{value}
+ {footer &&{footer}
} +No nodes registered yet.
; + } + + return ( +| Node | +RPC URL | +Latency | +Block Height | +Uptime | +Status | +
|---|---|---|---|---|---|
|
+ {node.p2p_address}
+ {node.email}
+ |
+ {node.rpc_url} | +{node.latency_ms ? `${node.latency_ms.toFixed(0)} ms` : '–'} | +{node.block_height || '–'} | +{(node.uptime_score * 100).toFixed(1)}% | +
+ |
+
No rewards distributed yet.
; + } + + return ( +Loading dashboard...
; + } + + const uptime = node ? (node.uptime_score * 100).toFixed(2) : '0.00'; + const latency = node?.latency_ms ? `${node.latency_ms.toFixed(0)} ms` : '–'; + const rewardToday = Object.values(rewards.rewards || {}).reduce((sum, value) => sum + Number(value), 0); + + return ( +Welcome back
++ Track node uptime, monitor performance, and earn daily rewards for + keeping the Interchained network resilient. Login to access your + personal dashboard. +
+{card.description}
+{status}
} +Loading rewards...
; + } + + const graphData = history.map((entry) => ({ + date: new Date(entry.date).toLocaleDateString(), + amount: entry.amount, + })); + + return ( +