mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-09-30 20:46:32 +00:00
feat: add Lux validator fleet management system
Full-stack fleet lifecycle management for Lux blockchain validators deployed via Helm to DOKS clusters. Backend (Python/FastAPI): - HelmDeployer: async helm/kubectl CLI wrapper with temp file cleanup, per-release locking, and command injection protection - LuxFleetManager: fleet CRUD, scale, rolling restart, node health probes via luxd RPC (health + C-chain block height) - Fleet API: authenticated REST endpoints (ApiKeyDep) with RFC 1123 input validation, kubeconfig error sanitization - Pydantic models: 3 networks (mainnet/testnet/devnet), fleet status enums, helm values translation - 110 unit tests (0.30s, all passing) Frontend (TypeScript/Next.js): - Fleet dashboard: cluster selector, stats banner, fleet cards with scale/restart/destroy operations, node status table, create dialog - Interactive SVG world map: zero-dep Mercator projection with DOKS region markers, pulsing animations, P2P mesh lines, click-to-zoom - DOKS region data: 11 regions with coordinates and API client - Dark theme throughout
This commit is contained in:
@@ -16,6 +16,7 @@ from bootnode.api.transfers import router as transfers_router
|
||||
from bootnode.api.wallets import router as wallets_router
|
||||
from bootnode.api.webhooks import router as webhooks_router
|
||||
from bootnode.api.team import router as team_router
|
||||
from bootnode.api.lux import router as lux_router
|
||||
from bootnode.api.zap import router as zap_router
|
||||
|
||||
router = APIRouter()
|
||||
@@ -36,3 +37,4 @@ router.include_router(gas_router, prefix="/gas", tags=["Gas Manager"])
|
||||
router.include_router(zap_router, prefix="/zap", tags=["ZAP Protocol"])
|
||||
router.include_router(infra_router, prefix="/infra", tags=["Infrastructure"])
|
||||
router.include_router(team_router, prefix="/team", tags=["Team"])
|
||||
router.include_router(lux_router, prefix="/lux", tags=["Lux Fleet"])
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
"""Lux Fleet API -- REST endpoints for managing Lux validator fleets.
|
||||
|
||||
Provides CRUD operations for Lux blockchain validator fleets deployed
|
||||
via Helm charts to DOKS clusters.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
||||
|
||||
from bootnode.api.deps import ApiKeyDep
|
||||
from bootnode.core.chains.lux import LuxFleetManager
|
||||
from bootnode.core.chains.lux_models import (
|
||||
LuxFleetCreate,
|
||||
LuxFleetResponse,
|
||||
LuxFleetStats,
|
||||
LuxFleetStatus,
|
||||
LuxFleetSummary,
|
||||
LuxFleetUpdate,
|
||||
LuxNetwork,
|
||||
NETWORK_CONFIGS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# In-memory fleet registry (source of truth is Helm releases on clusters)
|
||||
_fleets: dict[str, dict] = {}
|
||||
|
||||
# Singleton manager
|
||||
_manager = LuxFleetManager()
|
||||
|
||||
# K8s name validation: RFC 1123 DNS label
|
||||
_K8S_NAME_RE = re.compile(r"^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?$")
|
||||
|
||||
|
||||
def _validate_k8s_name(name: str, field: str = "name") -> None:
|
||||
"""Validate a string is a valid K8s name (RFC 1123 DNS label)."""
|
||||
if not _K8S_NAME_RE.match(name):
|
||||
raise HTTPException(400, f"Invalid {field}: must match RFC 1123 DNS label")
|
||||
|
||||
|
||||
async def _get_kubeconfig(cluster_id: str) -> str:
|
||||
"""Fetch kubeconfig for a DOKS cluster via the infra API."""
|
||||
try:
|
||||
from bootnode.api.infra import do_get_kubeconfig, get_do_client
|
||||
client = get_do_client()
|
||||
if not client:
|
||||
raise HTTPException(500, "DigitalOcean client not configured")
|
||||
return await do_get_kubeconfig(client, cluster_id)
|
||||
except ImportError:
|
||||
raise HTTPException(500, "Infrastructure module not available")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.error("Failed to fetch kubeconfig for cluster %s", cluster_id)
|
||||
raise HTTPException(502, "Failed to fetch kubeconfig for cluster")
|
||||
|
||||
|
||||
# ---- Fleet CRUD ----
|
||||
|
||||
@router.post("/fleets", response_model=LuxFleetResponse)
|
||||
async def create_fleet(
|
||||
params: LuxFleetCreate,
|
||||
background_tasks: BackgroundTasks,
|
||||
_api_key: ApiKeyDep,
|
||||
) -> LuxFleetResponse:
|
||||
"""Create a new Lux validator fleet on a DOKS cluster."""
|
||||
fleet_id = f"{params.name}-{params.network.value}"
|
||||
if fleet_id in _fleets:
|
||||
raise HTTPException(409, f"Fleet {fleet_id} already exists")
|
||||
|
||||
kubeconfig = await _get_kubeconfig(params.cluster_id)
|
||||
result = await _manager.create(kubeconfig, params)
|
||||
|
||||
_fleets[fleet_id] = {
|
||||
"name": params.name,
|
||||
"cluster_id": params.cluster_id,
|
||||
"network": params.network.value,
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/fleets", response_model=list[LuxFleetSummary])
|
||||
async def list_fleets(_api_key: ApiKeyDep, cluster_id: Optional[str] = None) -> list[LuxFleetSummary]:
|
||||
"""List all Lux fleets, optionally filtered by cluster."""
|
||||
if cluster_id:
|
||||
kubeconfig = await _get_kubeconfig(cluster_id)
|
||||
return await _manager.list_fleets(kubeconfig, cluster_id)
|
||||
|
||||
# List across all known clusters
|
||||
all_fleets = []
|
||||
cluster_ids = {f["cluster_id"] for f in _fleets.values()}
|
||||
for cid in cluster_ids:
|
||||
try:
|
||||
kubeconfig = await _get_kubeconfig(cid)
|
||||
fleets = await _manager.list_fleets(kubeconfig, cid)
|
||||
all_fleets.extend(fleets)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to list fleets on cluster %s: %s", cid, e)
|
||||
return all_fleets
|
||||
|
||||
|
||||
@router.get("/fleets/{cluster_id}/{network}", response_model=LuxFleetResponse)
|
||||
async def get_fleet(cluster_id: str, network: LuxNetwork, _api_key: ApiKeyDep) -> LuxFleetResponse:
|
||||
"""Get full status of a fleet including pods and endpoints."""
|
||||
kubeconfig = await _get_kubeconfig(cluster_id)
|
||||
|
||||
# Find fleet name from registry
|
||||
name = f"luxd-{network.value}"
|
||||
for fid, fdata in _fleets.items():
|
||||
if fdata["cluster_id"] == cluster_id and fdata["network"] == network.value:
|
||||
name = fdata["name"]
|
||||
break
|
||||
|
||||
return await _manager.get_status(kubeconfig, name, network, cluster_id)
|
||||
|
||||
|
||||
@router.patch("/fleets/{cluster_id}/{network}", response_model=LuxFleetResponse)
|
||||
async def update_fleet(
|
||||
cluster_id: str,
|
||||
network: LuxNetwork,
|
||||
params: LuxFleetUpdate,
|
||||
_api_key: ApiKeyDep,
|
||||
) -> LuxFleetResponse:
|
||||
"""Update a fleet (helm upgrade). Only provided fields are changed."""
|
||||
kubeconfig = await _get_kubeconfig(cluster_id)
|
||||
|
||||
name = f"luxd-{network.value}"
|
||||
for fid, fdata in _fleets.items():
|
||||
if fdata["cluster_id"] == cluster_id and fdata["network"] == network.value:
|
||||
name = fdata["name"]
|
||||
break
|
||||
|
||||
return await _manager.update(kubeconfig, name, network, cluster_id, params)
|
||||
|
||||
|
||||
@router.delete("/fleets/{cluster_id}/{network}")
|
||||
async def destroy_fleet(cluster_id: str, network: LuxNetwork, _api_key: ApiKeyDep) -> dict:
|
||||
"""Destroy a fleet (helm uninstall). PVCs are preserved."""
|
||||
kubeconfig = await _get_kubeconfig(cluster_id)
|
||||
success = await _manager.destroy(kubeconfig, network)
|
||||
|
||||
# Remove from registry
|
||||
to_remove = [
|
||||
fid for fid, fdata in _fleets.items()
|
||||
if fdata["cluster_id"] == cluster_id and fdata["network"] == network.value
|
||||
]
|
||||
for fid in to_remove:
|
||||
del _fleets[fid]
|
||||
|
||||
return {"success": success, "message": f"Fleet {network.value} destroyed on {cluster_id}"}
|
||||
|
||||
|
||||
# ---- Fleet operations ----
|
||||
|
||||
@router.post("/fleets/{cluster_id}/{network}/scale", response_model=LuxFleetResponse)
|
||||
async def scale_fleet(
|
||||
cluster_id: str,
|
||||
network: LuxNetwork,
|
||||
replicas: int,
|
||||
_api_key: ApiKeyDep,
|
||||
) -> LuxFleetResponse:
|
||||
"""Scale fleet to the specified replica count."""
|
||||
if replicas < 1 or replicas > 20:
|
||||
raise HTTPException(400, "Replicas must be between 1 and 20")
|
||||
|
||||
kubeconfig = await _get_kubeconfig(cluster_id)
|
||||
name = f"luxd-{network.value}"
|
||||
return await _manager.scale(kubeconfig, name, network, cluster_id, replicas)
|
||||
|
||||
|
||||
@router.post("/fleets/{cluster_id}/{network}/restart")
|
||||
async def restart_fleet(
|
||||
cluster_id: str,
|
||||
network: LuxNetwork,
|
||||
background_tasks: BackgroundTasks,
|
||||
_api_key: ApiKeyDep,
|
||||
) -> dict:
|
||||
"""Rolling restart all pods (one at a time, OnDelete strategy)."""
|
||||
kubeconfig = await _get_kubeconfig(cluster_id)
|
||||
|
||||
async def _do_restart():
|
||||
try:
|
||||
restarted = await _manager.rolling_restart(kubeconfig, network)
|
||||
logger.info("Rolling restart complete: %s", restarted)
|
||||
except Exception as e:
|
||||
logger.error("Rolling restart failed: %s", e)
|
||||
|
||||
background_tasks.add_task(_do_restart)
|
||||
return {"message": f"Rolling restart initiated for {network.value}", "status": "started"}
|
||||
|
||||
|
||||
@router.get("/fleets/{cluster_id}/{network}/pods/{pod_name}/logs")
|
||||
async def get_pod_logs(
|
||||
cluster_id: str,
|
||||
network: LuxNetwork,
|
||||
pod_name: str,
|
||||
_api_key: ApiKeyDep,
|
||||
tail: int = 100,
|
||||
) -> dict:
|
||||
"""Get logs from a specific pod."""
|
||||
_validate_k8s_name(pod_name, "pod_name")
|
||||
if tail < 1 or tail > 10000:
|
||||
raise HTTPException(400, "tail must be between 1 and 10000")
|
||||
kubeconfig = await _get_kubeconfig(cluster_id)
|
||||
logs = await _manager.get_pod_logs(kubeconfig, network, pod_name, tail=tail)
|
||||
return {"pod": pod_name, "lines": logs.splitlines()}
|
||||
|
||||
|
||||
@router.get("/fleets/{cluster_id}/{network}/pods/{pod_name}/health")
|
||||
async def get_pod_health(
|
||||
cluster_id: str,
|
||||
network: LuxNetwork,
|
||||
pod_name: str,
|
||||
_api_key: ApiKeyDep,
|
||||
) -> dict:
|
||||
"""Probe a luxd node's health via RPC."""
|
||||
_validate_k8s_name(pod_name, "pod_name")
|
||||
kubeconfig = await _get_kubeconfig(cluster_id)
|
||||
return await _manager.probe_node_health(kubeconfig, network, pod_name)
|
||||
|
||||
|
||||
# ---- Stats ----
|
||||
|
||||
@router.get("/stats", response_model=LuxFleetStats)
|
||||
async def get_stats(_api_key: ApiKeyDep) -> LuxFleetStats:
|
||||
"""Get aggregate fleet statistics."""
|
||||
total_fleets = len(_fleets)
|
||||
fleets_by_network: dict[str, int] = {}
|
||||
for fdata in _fleets.values():
|
||||
net = fdata["network"]
|
||||
fleets_by_network[net] = fleets_by_network.get(net, 0) + 1
|
||||
|
||||
return LuxFleetStats(
|
||||
total_fleets=total_fleets,
|
||||
fleets_by_network=fleets_by_network,
|
||||
)
|
||||
|
||||
|
||||
# ---- Network info ----
|
||||
|
||||
@router.get("/networks")
|
||||
async def list_networks() -> dict:
|
||||
"""List available Lux networks and their configurations."""
|
||||
return {
|
||||
"networks": {
|
||||
net.value: {
|
||||
"network_id": cfg.network_id,
|
||||
"chain_id": cfg.chain_id,
|
||||
"http_port": cfg.http_port,
|
||||
"staking_port": cfg.staking_port,
|
||||
"namespace": cfg.namespace,
|
||||
}
|
||||
for net, cfg in NETWORK_CONFIGS.items()
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,10 @@ class Settings(BaseSettings):
|
||||
deploy_k8s_namespace: str = "bootnode"
|
||||
deploy_k8s_context: str = ""
|
||||
|
||||
# Helm / Lux Fleet
|
||||
helm_chart_path: str = "/opt/charts/lux"
|
||||
helm_binary: str = "helm"
|
||||
|
||||
@property
|
||||
def is_production(self) -> bool:
|
||||
return self.app_env == "production"
|
||||
|
||||
@@ -3,4 +3,10 @@
|
||||
from bootnode.core.chains.registry import Chain, ChainRegistry, ChainType, Network
|
||||
from bootnode.core.chains.rpc import RPCClient
|
||||
|
||||
__all__ = ["ChainRegistry", "Chain", "Network", "ChainType", "RPCClient"]
|
||||
__all__ = [
|
||||
"ChainRegistry",
|
||||
"Chain",
|
||||
"Network",
|
||||
"ChainType",
|
||||
"RPCClient",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
"""Lux fleet manager -- business logic for validator fleet lifecycle.
|
||||
|
||||
Orchestrates Lux validator fleet deployments via HelmDeployer.
|
||||
Each fleet operation:
|
||||
1. Fetches kubeconfig for the target cluster
|
||||
2. Creates a HelmDeployer pointed at that cluster
|
||||
3. Runs the Helm operation
|
||||
4. Probes individual pod health via luxd RPC
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from bootnode.core.chains.lux_models import (
|
||||
NETWORK_CONFIGS,
|
||||
LuxFleetCreate,
|
||||
LuxFleetResponse,
|
||||
LuxFleetStatus,
|
||||
LuxFleetSummary,
|
||||
LuxFleetUpdate,
|
||||
LuxNetwork,
|
||||
LuxNodeInfo,
|
||||
LuxNodeStatus,
|
||||
fleet_create_to_helm_values,
|
||||
fleet_update_to_helm_values,
|
||||
)
|
||||
from bootnode.core.deploy.helm import HelmDeployer, HelmError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Chart path defaults to the devops repo checkout
|
||||
DEFAULT_CHART_PATH = Path("/opt/charts/lux")
|
||||
|
||||
# Network-specific values files
|
||||
NETWORK_VALUES_FILES: dict[str, str] = {
|
||||
"mainnet": "values-mainnet.yaml",
|
||||
"testnet": "values-testnet.yaml",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class LuxFleetManager:
|
||||
"""Manages Lux validator fleet lifecycle via Helm."""
|
||||
|
||||
chart_path: Path = DEFAULT_CHART_PATH
|
||||
|
||||
def _release_name(self, network: LuxNetwork) -> str:
|
||||
return f"luxd-{network.value}"
|
||||
|
||||
def _namespace(self, network: LuxNetwork) -> str:
|
||||
return NETWORK_CONFIGS[network].namespace
|
||||
|
||||
def _fleet_id(self, name: str, network: LuxNetwork) -> str:
|
||||
return f"{name}-{network.value}"
|
||||
|
||||
async def _get_deployer(self, kubeconfig_data: str) -> HelmDeployer:
|
||||
"""Create a HelmDeployer from raw kubeconfig YAML.
|
||||
|
||||
The returned deployer's kubeconfig temp file is tracked for cleanup.
|
||||
Call deployer.cleanup_temp_files() when done.
|
||||
"""
|
||||
tmp = tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".kubeconfig", delete=False
|
||||
)
|
||||
tmp.write(kubeconfig_data)
|
||||
tmp.flush()
|
||||
tmp.close()
|
||||
kc_path = Path(tmp.name)
|
||||
deployer = HelmDeployer(
|
||||
chart_path=self.chart_path,
|
||||
kubeconfig_path=kc_path,
|
||||
)
|
||||
# Track kubeconfig path for cleanup alongside helm values files
|
||||
if not hasattr(deployer, "_temp_files"):
|
||||
deployer._temp_files = []
|
||||
deployer._temp_files.append(kc_path)
|
||||
return deployer
|
||||
|
||||
def _get_values_files(self, network: LuxNetwork) -> list[Path]:
|
||||
"""Get the network-specific values file if one exists."""
|
||||
filename = NETWORK_VALUES_FILES.get(network.value)
|
||||
if filename:
|
||||
full_path = self.chart_path / filename
|
||||
if full_path.exists():
|
||||
return [full_path]
|
||||
return []
|
||||
|
||||
# ---- Fleet lifecycle ----
|
||||
|
||||
async def create(
|
||||
self,
|
||||
kubeconfig: str,
|
||||
params: LuxFleetCreate,
|
||||
) -> LuxFleetResponse:
|
||||
"""Create a new Lux validator fleet."""
|
||||
deployer = await self._get_deployer(kubeconfig)
|
||||
try:
|
||||
release_name = self._release_name(params.network)
|
||||
namespace = self._namespace(params.network)
|
||||
values = fleet_create_to_helm_values(params)
|
||||
values_files = self._get_values_files(params.network)
|
||||
|
||||
logger.info(
|
||||
"Creating Lux fleet: release=%s namespace=%s network=%s replicas=%d",
|
||||
release_name, namespace, params.network.value, params.replicas,
|
||||
)
|
||||
|
||||
try:
|
||||
release = await deployer.install(
|
||||
release_name=release_name,
|
||||
namespace=namespace,
|
||||
values=values,
|
||||
values_files=values_files,
|
||||
wait=False,
|
||||
create_namespace=True,
|
||||
)
|
||||
status = LuxFleetStatus.DEPLOYING
|
||||
except HelmError as e:
|
||||
logger.error("Fleet creation failed: %s", e)
|
||||
return LuxFleetResponse(
|
||||
id=self._fleet_id(params.name, params.network),
|
||||
name=params.name,
|
||||
cluster_id=params.cluster_id,
|
||||
network=params.network,
|
||||
status=LuxFleetStatus.ERROR,
|
||||
replicas=params.replicas,
|
||||
namespace=namespace,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
return LuxFleetResponse(
|
||||
id=self._fleet_id(params.name, params.network),
|
||||
name=params.name,
|
||||
cluster_id=params.cluster_id,
|
||||
network=params.network,
|
||||
status=status,
|
||||
replicas=params.replicas,
|
||||
namespace=namespace,
|
||||
helm_revision=release.revision,
|
||||
image=params.image if params.image else None,
|
||||
)
|
||||
finally:
|
||||
deployer.cleanup_temp_files()
|
||||
|
||||
async def update(
|
||||
self,
|
||||
kubeconfig: str,
|
||||
name: str,
|
||||
network: LuxNetwork,
|
||||
cluster_id: str,
|
||||
params: LuxFleetUpdate,
|
||||
) -> LuxFleetResponse:
|
||||
"""Update an existing fleet (helm upgrade)."""
|
||||
deployer = await self._get_deployer(kubeconfig)
|
||||
try:
|
||||
release_name = self._release_name(network)
|
||||
namespace = self._namespace(network)
|
||||
values = fleet_update_to_helm_values(params)
|
||||
|
||||
logger.info("Updating fleet: release=%s values=%s", release_name, values)
|
||||
|
||||
try:
|
||||
release = await deployer.upgrade(
|
||||
release_name=release_name,
|
||||
namespace=namespace,
|
||||
values=values,
|
||||
reuse_values=True,
|
||||
)
|
||||
except HelmError as e:
|
||||
logger.error("Fleet update failed: %s", e)
|
||||
return LuxFleetResponse(
|
||||
id=self._fleet_id(name, network),
|
||||
name=name,
|
||||
cluster_id=cluster_id,
|
||||
network=network,
|
||||
status=LuxFleetStatus.ERROR,
|
||||
replicas=params.replicas or 0,
|
||||
namespace=namespace,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
# Get pod status after upgrade
|
||||
pods = await deployer.get_pods(namespace, "app=luxd")
|
||||
ready_count = sum(1 for p in pods if p.ready)
|
||||
total = len(pods)
|
||||
|
||||
return LuxFleetResponse(
|
||||
id=self._fleet_id(name, network),
|
||||
name=name,
|
||||
cluster_id=cluster_id,
|
||||
network=network,
|
||||
status=LuxFleetStatus.UPDATING,
|
||||
replicas=total,
|
||||
ready_replicas=ready_count,
|
||||
namespace=namespace,
|
||||
helm_revision=release.revision,
|
||||
)
|
||||
finally:
|
||||
deployer.cleanup_temp_files()
|
||||
|
||||
async def scale(
|
||||
self,
|
||||
kubeconfig: str,
|
||||
name: str,
|
||||
network: LuxNetwork,
|
||||
cluster_id: str,
|
||||
replicas: int,
|
||||
) -> LuxFleetResponse:
|
||||
"""Scale a fleet's replica count."""
|
||||
return await self.update(
|
||||
kubeconfig=kubeconfig,
|
||||
name=name,
|
||||
network=network,
|
||||
cluster_id=cluster_id,
|
||||
params=LuxFleetUpdate(replicas=replicas),
|
||||
)
|
||||
|
||||
async def destroy(
|
||||
self,
|
||||
kubeconfig: str,
|
||||
network: LuxNetwork,
|
||||
) -> bool:
|
||||
"""Destroy a fleet (helm uninstall). PVCs persist."""
|
||||
deployer = await self._get_deployer(kubeconfig)
|
||||
try:
|
||||
release_name = self._release_name(network)
|
||||
namespace = self._namespace(network)
|
||||
return await deployer.uninstall(release_name, namespace)
|
||||
finally:
|
||||
deployer.cleanup_temp_files()
|
||||
|
||||
async def get_status(
|
||||
self,
|
||||
kubeconfig: str,
|
||||
name: str,
|
||||
network: LuxNetwork,
|
||||
cluster_id: str,
|
||||
) -> LuxFleetResponse:
|
||||
"""Get full status of a fleet including pods and endpoints."""
|
||||
deployer = await self._get_deployer(kubeconfig)
|
||||
try:
|
||||
release_name = self._release_name(network)
|
||||
namespace = self._namespace(network)
|
||||
net_cfg = NETWORK_CONFIGS[network]
|
||||
|
||||
# Get Helm release status
|
||||
try:
|
||||
release = await deployer.status(release_name, namespace)
|
||||
except HelmError:
|
||||
return LuxFleetResponse(
|
||||
id=self._fleet_id(name, network),
|
||||
name=name,
|
||||
cluster_id=cluster_id,
|
||||
network=network,
|
||||
status=LuxFleetStatus.ERROR,
|
||||
replicas=0,
|
||||
namespace=namespace,
|
||||
error="Helm release not found",
|
||||
)
|
||||
|
||||
# Get pods
|
||||
pods = await deployer.get_pods(namespace, "app=luxd")
|
||||
ready_count = sum(1 for p in pods if p.ready)
|
||||
total = len(pods)
|
||||
|
||||
# Get services for endpoint discovery
|
||||
services = await deployer.get_services(namespace)
|
||||
rpc_endpoint = None
|
||||
staking_endpoint = None
|
||||
for svc in services:
|
||||
for ext_ip in svc.get("external_ips", []):
|
||||
if ext_ip:
|
||||
for port in svc.get("ports", []):
|
||||
if port.get("port") == net_cfg.http_port:
|
||||
rpc_endpoint = f"http://{ext_ip}:{net_cfg.http_port}"
|
||||
elif port.get("port") == net_cfg.staking_port:
|
||||
staking_endpoint = f"{ext_ip}:{net_cfg.staking_port}"
|
||||
|
||||
# Build node info
|
||||
nodes = []
|
||||
for i, pod in enumerate(sorted(pods, key=lambda p: p.name)):
|
||||
node_status = LuxNodeStatus.HEALTHY if pod.ready else LuxNodeStatus.UNHEALTHY
|
||||
if pod.status == "Pending":
|
||||
node_status = LuxNodeStatus.PENDING
|
||||
elif pod.status == "Init":
|
||||
node_status = LuxNodeStatus.INIT
|
||||
|
||||
nodes.append(LuxNodeInfo(
|
||||
pod_name=pod.name,
|
||||
pod_index=i,
|
||||
status=node_status,
|
||||
http_port=net_cfg.http_port,
|
||||
staking_port=net_cfg.staking_port,
|
||||
))
|
||||
|
||||
# Determine fleet status
|
||||
if total == 0:
|
||||
fleet_status = LuxFleetStatus.DEPLOYING
|
||||
elif ready_count == total:
|
||||
fleet_status = LuxFleetStatus.RUNNING
|
||||
elif ready_count > 0:
|
||||
fleet_status = LuxFleetStatus.DEGRADED
|
||||
else:
|
||||
fleet_status = LuxFleetStatus.ERROR
|
||||
|
||||
return LuxFleetResponse(
|
||||
id=self._fleet_id(name, network),
|
||||
name=name,
|
||||
cluster_id=cluster_id,
|
||||
network=network,
|
||||
status=fleet_status,
|
||||
replicas=total,
|
||||
ready_replicas=ready_count,
|
||||
namespace=namespace,
|
||||
helm_revision=release.revision,
|
||||
rpc_endpoint=rpc_endpoint,
|
||||
staking_endpoint=staking_endpoint,
|
||||
nodes=nodes,
|
||||
)
|
||||
finally:
|
||||
deployer.cleanup_temp_files()
|
||||
|
||||
async def list_fleets(
|
||||
self,
|
||||
kubeconfig: str,
|
||||
cluster_id: str,
|
||||
) -> list[LuxFleetSummary]:
|
||||
"""List all Lux fleets on a cluster."""
|
||||
deployer = await self._get_deployer(kubeconfig)
|
||||
try:
|
||||
releases = await deployer.list_releases(
|
||||
all_namespaces=True,
|
||||
filter_pattern="^luxd-",
|
||||
)
|
||||
|
||||
fleets = []
|
||||
for r in releases:
|
||||
# Infer network from release name (luxd-mainnet -> mainnet)
|
||||
network_str = r.name.removeprefix("luxd-")
|
||||
try:
|
||||
network = LuxNetwork(network_str)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
pods = await deployer.get_pods(r.namespace, "app=luxd")
|
||||
ready = sum(1 for p in pods if p.ready)
|
||||
total = len(pods)
|
||||
|
||||
if total == 0:
|
||||
status = LuxFleetStatus.DEPLOYING
|
||||
elif ready == total:
|
||||
status = LuxFleetStatus.RUNNING
|
||||
elif ready > 0:
|
||||
status = LuxFleetStatus.DEGRADED
|
||||
else:
|
||||
status = LuxFleetStatus.ERROR
|
||||
|
||||
fleets.append(LuxFleetSummary(
|
||||
id=f"{cluster_id}:{r.name}",
|
||||
name=r.name,
|
||||
network=network,
|
||||
status=status,
|
||||
replicas=total,
|
||||
ready_replicas=ready,
|
||||
cluster_id=cluster_id,
|
||||
created_at=r.updated or "",
|
||||
))
|
||||
|
||||
return fleets
|
||||
finally:
|
||||
deployer.cleanup_temp_files()
|
||||
|
||||
async def get_pod_logs(
|
||||
self,
|
||||
kubeconfig: str,
|
||||
network: LuxNetwork,
|
||||
pod_name: str,
|
||||
tail: int = 100,
|
||||
) -> str:
|
||||
"""Get logs from a specific pod."""
|
||||
deployer = await self._get_deployer(kubeconfig)
|
||||
try:
|
||||
namespace = self._namespace(network)
|
||||
return await deployer.get_pod_logs(pod_name, namespace, tail=tail)
|
||||
finally:
|
||||
deployer.cleanup_temp_files()
|
||||
|
||||
async def rolling_restart(
|
||||
self,
|
||||
kubeconfig: str,
|
||||
network: LuxNetwork,
|
||||
) -> list[str]:
|
||||
"""Rolling restart: delete pods one at a time (OnDelete strategy)."""
|
||||
deployer = await self._get_deployer(kubeconfig)
|
||||
try:
|
||||
namespace = self._namespace(network)
|
||||
pods = await deployer.get_pods(namespace, "app=luxd")
|
||||
restarted = []
|
||||
|
||||
for pod in sorted(pods, key=lambda p: p.name):
|
||||
logger.info("Rolling restart: deleting %s", pod.name)
|
||||
await deployer.delete_pod(pod.name, namespace)
|
||||
restarted.append(pod.name)
|
||||
# Wait for pod to come back ready (up to 5 min)
|
||||
for _ in range(60):
|
||||
await asyncio.sleep(5)
|
||||
new_pods = await deployer.get_pods(namespace, "app=luxd")
|
||||
replaced = next((p for p in new_pods if p.name == pod.name), None)
|
||||
if replaced and replaced.ready:
|
||||
break
|
||||
|
||||
return restarted
|
||||
finally:
|
||||
deployer.cleanup_temp_files()
|
||||
|
||||
async def probe_node_health(
|
||||
self,
|
||||
kubeconfig: str,
|
||||
network: LuxNetwork,
|
||||
pod_name: str,
|
||||
) -> dict:
|
||||
"""Probe a luxd node's health via kubectl exec + wget."""
|
||||
deployer = await self._get_deployer(kubeconfig)
|
||||
try:
|
||||
namespace = self._namespace(network)
|
||||
net_cfg = NETWORK_CONFIGS[network]
|
||||
port = net_cfg.http_port
|
||||
|
||||
results: dict = {}
|
||||
|
||||
# Health check
|
||||
try:
|
||||
health = await deployer.exec_pod(
|
||||
pod_name, namespace,
|
||||
["wget", "-qO-", f"http://localhost:{port}/ext/health"],
|
||||
)
|
||||
results["health"] = json.loads(health)
|
||||
results["healthy"] = results["health"].get("healthy", False)
|
||||
except Exception:
|
||||
results["healthy"] = False
|
||||
|
||||
# Block height
|
||||
try:
|
||||
rpc_resp = await deployer.exec_pod(
|
||||
pod_name, namespace,
|
||||
[
|
||||
"wget", "-qO-",
|
||||
"--header=Content-Type: application/json",
|
||||
f"--post-data={{\"jsonrpc\":\"2.0\",\"method\":\"eth_blockNumber\",\"params\":[],\"id\":1}}",
|
||||
f"http://localhost:{port}/ext/bc/C/rpc",
|
||||
],
|
||||
)
|
||||
data = json.loads(rpc_resp)
|
||||
results["c_chain_height"] = int(data.get("result", "0x0"), 16)
|
||||
except Exception:
|
||||
results["c_chain_height"] = None
|
||||
|
||||
return results
|
||||
finally:
|
||||
deployer.cleanup_temp_files()
|
||||
@@ -0,0 +1,444 @@
|
||||
"""Shared Pydantic models for Lux fleet management.
|
||||
|
||||
This module is the single source of truth for data structures shared between:
|
||||
- HelmDeployer (core/deploy/helm.py)
|
||||
- Lux Fleet API (api/lux/__init__.py)
|
||||
- Lux Chain Manager (core/chains/lux.py)
|
||||
- Dashboard (web/app/dashboard/infrastructure/lux/)
|
||||
|
||||
All four components import from here. No model duplication.
|
||||
|
||||
The models map 1:1 to the Helm chart values in lux/devops/charts/lux/values.yaml
|
||||
so that API request bodies can be translated directly to helm --set flags or
|
||||
values override files without lossy conversion.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enums
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LuxNetwork(str, Enum):
|
||||
"""Lux network identifier. Maps to values.network in the Helm chart."""
|
||||
|
||||
MAINNET = "mainnet"
|
||||
TESTNET = "testnet"
|
||||
DEVNET = "devnet"
|
||||
|
||||
|
||||
class LuxFleetStatus(str, Enum):
|
||||
"""Lifecycle state of a fleet (Helm release)."""
|
||||
|
||||
PENDING = "pending" # Request accepted, not yet applied
|
||||
DEPLOYING = "deploying" # helm install/upgrade in progress
|
||||
RUNNING = "running" # All pods healthy
|
||||
DEGRADED = "degraded" # Some pods not ready
|
||||
UPDATING = "updating" # Rolling update in progress
|
||||
ERROR = "error" # Helm or K8s error
|
||||
DESTROYING = "destroying" # helm uninstall in progress
|
||||
DESTROYED = "destroyed" # Release removed
|
||||
|
||||
|
||||
class LuxNodeStatus(str, Enum):
|
||||
"""Status of an individual luxd pod within a fleet."""
|
||||
|
||||
PENDING = "pending"
|
||||
INIT = "init" # Init container running
|
||||
STARTING = "starting" # luxd process starting
|
||||
BOOTSTRAPPING = "bootstrapping" # RLP import / chain sync
|
||||
HEALTHY = "healthy" # Health endpoint responding
|
||||
UNHEALTHY = "unhealthy" # Failed liveness/readiness
|
||||
TERMINATED = "terminated"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helm chart value sub-models
|
||||
# These mirror the YAML structure in charts/lux/values.yaml exactly.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LuxNetworkConfig(BaseModel):
|
||||
"""Per-network constants. Read-only reference data, not user input."""
|
||||
|
||||
network_id: int
|
||||
chain_id: int
|
||||
http_port: int
|
||||
staking_port: int
|
||||
namespace: str
|
||||
|
||||
|
||||
# Canonical network parameters. Must match values.yaml .networks.*
|
||||
NETWORK_CONFIGS: dict[LuxNetwork, LuxNetworkConfig] = {
|
||||
LuxNetwork.MAINNET: LuxNetworkConfig(
|
||||
network_id=1,
|
||||
chain_id=96369,
|
||||
http_port=9630,
|
||||
staking_port=9631,
|
||||
namespace="lux-mainnet",
|
||||
),
|
||||
LuxNetwork.TESTNET: LuxNetworkConfig(
|
||||
network_id=2,
|
||||
chain_id=96368,
|
||||
http_port=9640,
|
||||
staking_port=9641,
|
||||
namespace="lux-testnet",
|
||||
),
|
||||
LuxNetwork.DEVNET: LuxNetworkConfig(
|
||||
network_id=3,
|
||||
chain_id=96370,
|
||||
http_port=9650,
|
||||
staking_port=9651,
|
||||
namespace="lux-devnet",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class RLPImportConfig(BaseModel):
|
||||
"""C-chain RLP block import settings. Maps to values.bootstrap.rlpImport."""
|
||||
|
||||
enabled: bool = False
|
||||
base_url: str = ""
|
||||
rlp_filename: str = ""
|
||||
multi_part: bool = False
|
||||
parts: list[str] = Field(default_factory=list)
|
||||
min_height: int = 1
|
||||
timeout: int = 7200
|
||||
|
||||
|
||||
class BootstrapConfig(BaseModel):
|
||||
"""Bootstrap node configuration. Maps to values.bootstrap."""
|
||||
|
||||
node_ids: list[str] = Field(default_factory=list)
|
||||
use_hostnames: bool = True
|
||||
external_ips: list[str] = Field(default_factory=list)
|
||||
rlp_import: RLPImportConfig = Field(default_factory=RLPImportConfig)
|
||||
|
||||
@field_validator("node_ids")
|
||||
@classmethod
|
||||
def validate_node_ids(cls, v: list[str]) -> list[str]:
|
||||
for nid in v:
|
||||
if not nid.startswith("NodeID-"):
|
||||
raise ValueError(f"Invalid node ID format: {nid!r} (must start with 'NodeID-')")
|
||||
return v
|
||||
|
||||
|
||||
class ConsensusConfig(BaseModel):
|
||||
"""Consensus tuning. Maps to values.consensus."""
|
||||
|
||||
sample_size: int = 5
|
||||
quorum_size: int = 4
|
||||
sybil_protection_enabled: bool = False
|
||||
require_validator_to_connect: bool = False
|
||||
allow_private_ips: bool = True
|
||||
|
||||
|
||||
class ChainTrackingConfig(BaseModel):
|
||||
"""Subnet/chain tracking. Maps to values.chainTracking."""
|
||||
|
||||
track_all_chains: bool = True
|
||||
tracked_chains: list[str] = Field(default_factory=list)
|
||||
aliases: list[str] = Field(
|
||||
default_factory=lambda: ["zoo", "hanzo", "spc", "pars"],
|
||||
)
|
||||
|
||||
|
||||
class ImageConfig(BaseModel):
|
||||
"""Container image reference. Maps to values.image."""
|
||||
|
||||
repository: str = "registry.digitalocean.com/hanzo/bootnode"
|
||||
tag: str = "luxd-v1.23.11"
|
||||
pull_policy: str = "Always"
|
||||
|
||||
|
||||
class ResourceSpec(BaseModel):
|
||||
"""K8s resource request/limit pair."""
|
||||
|
||||
memory: str = "1Gi"
|
||||
cpu: str = "500m"
|
||||
|
||||
|
||||
class ResourceConfig(BaseModel):
|
||||
"""Maps to values.resources."""
|
||||
|
||||
requests: ResourceSpec = Field(default_factory=lambda: ResourceSpec(memory="1Gi", cpu="500m"))
|
||||
limits: ResourceSpec = Field(default_factory=lambda: ResourceSpec(memory="4Gi", cpu="2"))
|
||||
|
||||
|
||||
class StorageConfig(BaseModel):
|
||||
"""Maps to values.storage."""
|
||||
|
||||
size: str = "100Gi"
|
||||
storage_class: str = "do-block-storage"
|
||||
|
||||
|
||||
class NodeServicesConfig(BaseModel):
|
||||
"""Maps to values.nodeServices."""
|
||||
|
||||
enabled: bool = True
|
||||
type: str = "LoadBalancer"
|
||||
annotations: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class APIConfig(BaseModel):
|
||||
"""Maps to values.api."""
|
||||
|
||||
admin_enabled: bool = True
|
||||
metrics_enabled: bool = True
|
||||
index_enabled: bool = True
|
||||
http_allowed_hosts: str = "*"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API request/response models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LuxFleetCreate(BaseModel):
|
||||
"""Request body: create a new Lux fleet (Helm release).
|
||||
|
||||
Used by: POST /lux/fleets
|
||||
Consumed by: HelmDeployer, LuxChainManager
|
||||
"""
|
||||
|
||||
name: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=63,
|
||||
pattern=r"^[a-z][a-z0-9-]*$",
|
||||
description="Helm release name. Must be a valid K8s name.",
|
||||
)
|
||||
cluster_id: str = Field(
|
||||
...,
|
||||
description="Target K8s cluster ID (from /infra/clusters).",
|
||||
)
|
||||
network: LuxNetwork = LuxNetwork.DEVNET
|
||||
replicas: int = Field(default=5, ge=1, le=20)
|
||||
|
||||
# Optional overrides -- None means use chart defaults
|
||||
image: Optional[ImageConfig] = None
|
||||
bootstrap: Optional[BootstrapConfig] = None
|
||||
consensus: Optional[ConsensusConfig] = None
|
||||
chain_tracking: Optional[ChainTrackingConfig] = None
|
||||
resources: Optional[ResourceConfig] = None
|
||||
storage: Optional[StorageConfig] = None
|
||||
node_services: Optional[NodeServicesConfig] = None
|
||||
api: Optional[APIConfig] = None
|
||||
log_level: str = "info"
|
||||
db_type: str = "badgerdb"
|
||||
|
||||
@field_validator("replicas")
|
||||
@classmethod
|
||||
def quorum_safety(cls, v: int) -> int:
|
||||
"""Warn-level: consensus requires >= 4 for BFT quorum of 5."""
|
||||
# Validation only; warning handled at the API layer.
|
||||
return v
|
||||
|
||||
|
||||
class LuxFleetUpdate(BaseModel):
|
||||
"""Request body: update (helm upgrade) an existing fleet.
|
||||
|
||||
Used by: PATCH /lux/fleets/{fleet_id}
|
||||
Only non-None fields are applied as overrides.
|
||||
"""
|
||||
|
||||
replicas: Optional[int] = Field(default=None, ge=1, le=20)
|
||||
image: Optional[ImageConfig] = None
|
||||
consensus: Optional[ConsensusConfig] = None
|
||||
chain_tracking: Optional[ChainTrackingConfig] = None
|
||||
resources: Optional[ResourceConfig] = None
|
||||
log_level: Optional[str] = None
|
||||
|
||||
|
||||
class LuxNodeInfo(BaseModel):
|
||||
"""Status of a single luxd pod within a fleet.
|
||||
|
||||
Populated by LuxChainManager from K8s pod status + RPC probes.
|
||||
"""
|
||||
|
||||
pod_name: str
|
||||
pod_index: int
|
||||
node_id: Optional[str] = None
|
||||
status: LuxNodeStatus = LuxNodeStatus.PENDING
|
||||
external_ip: Optional[str] = None
|
||||
http_port: int = 9650
|
||||
staking_port: int = 9651
|
||||
# RPC-derived metrics (populated lazily)
|
||||
is_bootstrapped: Optional[bool] = None
|
||||
peers: Optional[int] = None
|
||||
c_chain_height: Optional[int] = None
|
||||
p_chain_height: Optional[int] = None
|
||||
uptime_pct: Optional[float] = None
|
||||
# K8s resource usage
|
||||
cpu_usage: Optional[float] = None
|
||||
memory_usage_mb: Optional[float] = None
|
||||
|
||||
|
||||
class LuxFleetResponse(BaseModel):
|
||||
"""Response body: full fleet state.
|
||||
|
||||
Used by: GET /lux/fleets/{fleet_id}, POST /lux/fleets
|
||||
"""
|
||||
|
||||
id: str = Field(description="Fleet ID (= Helm release name + network).")
|
||||
name: str
|
||||
cluster_id: str
|
||||
network: LuxNetwork
|
||||
status: LuxFleetStatus = LuxFleetStatus.PENDING
|
||||
replicas: int
|
||||
ready_replicas: int = 0
|
||||
image: ImageConfig = Field(default_factory=ImageConfig)
|
||||
namespace: str = ""
|
||||
helm_revision: int = 0
|
||||
rpc_endpoint: Optional[str] = None
|
||||
staking_endpoint: Optional[str] = None
|
||||
nodes: list[LuxNodeInfo] = Field(default_factory=list)
|
||||
created_at: str = Field(default_factory=lambda: datetime.utcnow().isoformat() + "Z")
|
||||
updated_at: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class LuxFleetSummary(BaseModel):
|
||||
"""Lightweight fleet summary for list endpoints.
|
||||
|
||||
Used by: GET /lux/fleets
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
network: LuxNetwork
|
||||
status: LuxFleetStatus
|
||||
replicas: int
|
||||
ready_replicas: int = 0
|
||||
cluster_id: str
|
||||
created_at: str
|
||||
|
||||
|
||||
class LuxFleetStats(BaseModel):
|
||||
"""Aggregate fleet statistics.
|
||||
|
||||
Used by: GET /lux/stats
|
||||
"""
|
||||
|
||||
total_fleets: int = 0
|
||||
total_nodes: int = 0
|
||||
healthy_nodes: int = 0
|
||||
fleets_by_network: dict[str, int] = Field(default_factory=dict)
|
||||
fleets_by_status: dict[str, int] = Field(default_factory=dict)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helm values translation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def fleet_create_to_helm_values(req: LuxFleetCreate) -> dict:
|
||||
"""Convert a LuxFleetCreate request into a flat dict suitable for
|
||||
helm install --set or a values override YAML file.
|
||||
|
||||
This is the canonical translation layer. HelmDeployer calls this;
|
||||
no other component should build Helm values dicts directly.
|
||||
"""
|
||||
vals: dict = {
|
||||
"network": req.network.value,
|
||||
"replicas": req.replicas,
|
||||
"logLevel": req.log_level,
|
||||
"dbType": req.db_type,
|
||||
}
|
||||
|
||||
if req.image:
|
||||
vals["image.repository"] = req.image.repository
|
||||
vals["image.tag"] = req.image.tag
|
||||
vals["image.pullPolicy"] = req.image.pull_policy
|
||||
|
||||
if req.bootstrap:
|
||||
b = req.bootstrap
|
||||
vals["bootstrap.useHostnames"] = b.use_hostnames
|
||||
if b.node_ids:
|
||||
# Helm list: pass as JSON or comma-separated via --set-json
|
||||
vals["bootstrap.nodeIDs"] = b.node_ids
|
||||
if b.external_ips:
|
||||
vals["bootstrap.externalIPs"] = b.external_ips
|
||||
r = b.rlp_import
|
||||
vals["bootstrap.rlpImport.enabled"] = r.enabled
|
||||
vals["bootstrap.rlpImport.baseUrl"] = r.base_url
|
||||
vals["bootstrap.rlpImport.rlpFilename"] = r.rlp_filename
|
||||
vals["bootstrap.rlpImport.multiPart"] = r.multi_part
|
||||
if r.parts:
|
||||
vals["bootstrap.rlpImport.parts"] = r.parts
|
||||
vals["bootstrap.rlpImport.minHeight"] = r.min_height
|
||||
vals["bootstrap.rlpImport.timeout"] = r.timeout
|
||||
|
||||
if req.consensus:
|
||||
c = req.consensus
|
||||
vals["consensus.sampleSize"] = c.sample_size
|
||||
vals["consensus.quorumSize"] = c.quorum_size
|
||||
vals["consensus.sybilProtectionEnabled"] = c.sybil_protection_enabled
|
||||
vals["consensus.requireValidatorToConnect"] = c.require_validator_to_connect
|
||||
vals["consensus.allowPrivateIPs"] = c.allow_private_ips
|
||||
|
||||
if req.chain_tracking:
|
||||
ct = req.chain_tracking
|
||||
vals["chainTracking.trackAllChains"] = ct.track_all_chains
|
||||
if ct.tracked_chains:
|
||||
vals["chainTracking.trackedChains"] = ct.tracked_chains
|
||||
if ct.aliases:
|
||||
vals["chainTracking.aliases"] = ct.aliases
|
||||
|
||||
if req.resources:
|
||||
vals["resources.requests.memory"] = req.resources.requests.memory
|
||||
vals["resources.requests.cpu"] = req.resources.requests.cpu
|
||||
vals["resources.limits.memory"] = req.resources.limits.memory
|
||||
vals["resources.limits.cpu"] = req.resources.limits.cpu
|
||||
|
||||
if req.storage:
|
||||
vals["storage.size"] = req.storage.size
|
||||
vals["storage.storageClass"] = req.storage.storage_class
|
||||
|
||||
if req.node_services:
|
||||
vals["nodeServices.enabled"] = req.node_services.enabled
|
||||
vals["nodeServices.type"] = req.node_services.type
|
||||
|
||||
if req.api:
|
||||
vals["api.adminEnabled"] = req.api.admin_enabled
|
||||
vals["api.metricsEnabled"] = req.api.metrics_enabled
|
||||
vals["api.indexEnabled"] = req.api.index_enabled
|
||||
vals["api.httpAllowedHosts"] = req.api.http_allowed_hosts
|
||||
|
||||
return vals
|
||||
|
||||
|
||||
def fleet_update_to_helm_values(req: LuxFleetUpdate) -> dict:
|
||||
"""Convert a LuxFleetUpdate into a partial Helm values dict.
|
||||
|
||||
Only non-None fields are included.
|
||||
"""
|
||||
vals: dict = {}
|
||||
|
||||
if req.replicas is not None:
|
||||
vals["replicas"] = req.replicas
|
||||
if req.log_level is not None:
|
||||
vals["logLevel"] = req.log_level
|
||||
if req.image:
|
||||
vals["image.repository"] = req.image.repository
|
||||
vals["image.tag"] = req.image.tag
|
||||
vals["image.pullPolicy"] = req.image.pull_policy
|
||||
if req.consensus:
|
||||
c = req.consensus
|
||||
vals["consensus.sampleSize"] = c.sample_size
|
||||
vals["consensus.quorumSize"] = c.quorum_size
|
||||
vals["consensus.sybilProtectionEnabled"] = c.sybil_protection_enabled
|
||||
if req.chain_tracking:
|
||||
ct = req.chain_tracking
|
||||
vals["chainTracking.trackAllChains"] = ct.track_all_chains
|
||||
if ct.tracked_chains:
|
||||
vals["chainTracking.trackedChains"] = ct.tracked_chains
|
||||
if req.resources:
|
||||
vals["resources.requests.memory"] = req.resources.requests.memory
|
||||
vals["resources.requests.cpu"] = req.resources.requests.cpu
|
||||
vals["resources.limits.memory"] = req.resources.limits.memory
|
||||
vals["resources.limits.cpu"] = req.resources.limits.cpu
|
||||
|
||||
return vals
|
||||
@@ -21,6 +21,7 @@ from bootnode.core.deploy.base import DeployTarget, ServiceStatus, ServiceType
|
||||
from bootnode.core.deploy.docker import DockerDeployer
|
||||
from bootnode.core.deploy.factory import clear_deployer_cache, get_deployer
|
||||
from bootnode.core.deploy.kubernetes import KubernetesDeployer
|
||||
from bootnode.core.deploy.helm import HelmDeployer
|
||||
from bootnode.core.deploy.process import ProcessDeployer
|
||||
|
||||
__all__ = [
|
||||
@@ -30,6 +31,7 @@ __all__ = [
|
||||
"ServiceType",
|
||||
# Deployers
|
||||
"DockerDeployer",
|
||||
"HelmDeployer",
|
||||
"KubernetesDeployer",
|
||||
"ProcessDeployer",
|
||||
# Factory
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
"""Helm CLI deployer for Lux validator fleets.
|
||||
|
||||
Wraps the helm and kubectl CLIs via asyncio subprocess for deploying
|
||||
Helm releases to remote DOKS clusters. NOT a DeployTarget subclass --
|
||||
different interface (Helm release-oriented, not ServiceType-oriented).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HelmReleaseStatus(str, Enum):
|
||||
DEPLOYED = "deployed"
|
||||
FAILED = "failed"
|
||||
PENDING_INSTALL = "pending-install"
|
||||
PENDING_UPGRADE = "pending-upgrade"
|
||||
PENDING_ROLLBACK = "pending-rollback"
|
||||
SUPERSEDED = "superseded"
|
||||
UNINSTALLED = "uninstalled"
|
||||
UNINSTALLING = "uninstalling"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class HelmError(Exception):
|
||||
"""Raised when a helm/kubectl command fails."""
|
||||
|
||||
def __init__(self, message: str, stderr: str = "", returncode: int = 1):
|
||||
super().__init__(message)
|
||||
self.stderr = stderr
|
||||
self.returncode = returncode
|
||||
|
||||
|
||||
@dataclass
|
||||
class HelmRelease:
|
||||
name: str
|
||||
namespace: str
|
||||
revision: int
|
||||
status: HelmReleaseStatus
|
||||
chart: str = ""
|
||||
app_version: str = ""
|
||||
updated: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class PodInfo:
|
||||
name: str
|
||||
ready: bool
|
||||
status: str
|
||||
restarts: int = 0
|
||||
node: str = ""
|
||||
ip: str = ""
|
||||
age: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class HelmDeployer:
|
||||
"""Deploys Helm charts to K8s clusters via CLI.
|
||||
|
||||
Each instance is bound to a specific kubeconfig (cluster).
|
||||
Create per-request via LuxFleetManager._get_deployer().
|
||||
"""
|
||||
|
||||
chart_path: Path
|
||||
kubeconfig_path: Optional[Path] = None
|
||||
kube_context: Optional[str] = None
|
||||
helm_binary: str = "helm"
|
||||
kubectl_binary: str = "kubectl"
|
||||
_locks: dict = field(default_factory=dict, repr=False)
|
||||
|
||||
def _get_lock(self, release_name: str) -> asyncio.Lock:
|
||||
if release_name not in self._locks:
|
||||
self._locks[release_name] = asyncio.Lock()
|
||||
return self._locks[release_name]
|
||||
|
||||
def _base_helm_args(self) -> list[str]:
|
||||
args = [self.helm_binary]
|
||||
if self.kubeconfig_path:
|
||||
args += ["--kubeconfig", str(self.kubeconfig_path)]
|
||||
if self.kube_context:
|
||||
args += ["--kube-context", self.kube_context]
|
||||
return args
|
||||
|
||||
def _base_kubectl_args(self) -> list[str]:
|
||||
args = [self.kubectl_binary]
|
||||
if self.kubeconfig_path:
|
||||
args += ["--kubeconfig", str(self.kubeconfig_path)]
|
||||
if self.kube_context:
|
||||
args += ["--context", self.kube_context]
|
||||
return args
|
||||
|
||||
async def _run(self, args: list[str], timeout: int = 300) -> str:
|
||||
logger.debug("Running: %s", " ".join(args))
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(), timeout=timeout
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
raise HelmError(f"Command timed out after {timeout}s: {' '.join(args[:3])}")
|
||||
|
||||
out = stdout.decode().strip()
|
||||
err = stderr.decode().strip()
|
||||
|
||||
if proc.returncode != 0:
|
||||
raise HelmError(
|
||||
f"Command failed (rc={proc.returncode}): {err or out}",
|
||||
stderr=err,
|
||||
returncode=proc.returncode,
|
||||
)
|
||||
return out
|
||||
|
||||
def _write_values_file(self, values: dict) -> Path:
|
||||
"""Write a values dict to a temp YAML file for helm -f.
|
||||
|
||||
Temp files are tracked and cleaned up via cleanup_temp_files().
|
||||
"""
|
||||
tmp = tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".yaml", prefix="helm-values-", delete=False
|
||||
)
|
||||
yaml.safe_dump(values, tmp, default_flow_style=False)
|
||||
tmp.flush()
|
||||
tmp.close()
|
||||
path = Path(tmp.name)
|
||||
if not hasattr(self, "_temp_files"):
|
||||
self._temp_files: list[Path] = []
|
||||
self._temp_files.append(path)
|
||||
return path
|
||||
|
||||
def cleanup_temp_files(self) -> None:
|
||||
"""Remove all temp files created by this deployer."""
|
||||
for f in getattr(self, "_temp_files", []):
|
||||
try:
|
||||
f.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
self._temp_files = []
|
||||
|
||||
# ---- Helm operations ----
|
||||
|
||||
async def install(
|
||||
self,
|
||||
release_name: str,
|
||||
namespace: str,
|
||||
values: dict | None = None,
|
||||
values_files: list[Path] | None = None,
|
||||
wait: bool = False,
|
||||
create_namespace: bool = True,
|
||||
timeout: int = 600,
|
||||
) -> HelmRelease:
|
||||
"""helm install."""
|
||||
async with self._get_lock(release_name):
|
||||
args = self._base_helm_args() + [
|
||||
"upgrade", "--install", release_name, str(self.chart_path),
|
||||
"--namespace", namespace,
|
||||
"--output", "json",
|
||||
]
|
||||
if create_namespace:
|
||||
args.append("--create-namespace")
|
||||
if wait:
|
||||
args += ["--wait", "--timeout", f"{timeout}s"]
|
||||
|
||||
# Network-specific values files first (lower priority)
|
||||
for vf in (values_files or []):
|
||||
args += ["-f", str(vf)]
|
||||
|
||||
# Computed values override (highest priority)
|
||||
if values:
|
||||
vf_path = self._write_values_file(values)
|
||||
args += ["-f", str(vf_path)]
|
||||
|
||||
out = await self._run(args, timeout=timeout + 30)
|
||||
|
||||
try:
|
||||
data = json.loads(out)
|
||||
info = data.get("info", {})
|
||||
return HelmRelease(
|
||||
name=release_name,
|
||||
namespace=namespace,
|
||||
revision=data.get("version", 1),
|
||||
status=HelmReleaseStatus(info.get("status", "unknown")),
|
||||
chart=data.get("chart", {}).get("metadata", {}).get("name", ""),
|
||||
app_version=data.get("chart", {}).get("metadata", {}).get("appVersion", ""),
|
||||
updated=info.get("last_deployed", ""),
|
||||
)
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
# Fallback: command succeeded but output wasn't JSON
|
||||
return HelmRelease(
|
||||
name=release_name,
|
||||
namespace=namespace,
|
||||
revision=1,
|
||||
status=HelmReleaseStatus.DEPLOYED,
|
||||
)
|
||||
|
||||
async def upgrade(
|
||||
self,
|
||||
release_name: str,
|
||||
namespace: str,
|
||||
values: dict | None = None,
|
||||
values_files: list[Path] | None = None,
|
||||
reuse_values: bool = True,
|
||||
wait: bool = False,
|
||||
timeout: int = 600,
|
||||
) -> HelmRelease:
|
||||
"""helm upgrade (existing release)."""
|
||||
async with self._get_lock(release_name):
|
||||
args = self._base_helm_args() + [
|
||||
"upgrade", release_name, str(self.chart_path),
|
||||
"--namespace", namespace,
|
||||
"--output", "json",
|
||||
]
|
||||
if reuse_values:
|
||||
args.append("--reuse-values")
|
||||
if wait:
|
||||
args += ["--wait", "--timeout", f"{timeout}s"]
|
||||
|
||||
for vf in (values_files or []):
|
||||
args += ["-f", str(vf)]
|
||||
|
||||
if values:
|
||||
vf_path = self._write_values_file(values)
|
||||
args += ["-f", str(vf_path)]
|
||||
|
||||
out = await self._run(args, timeout=timeout + 30)
|
||||
|
||||
try:
|
||||
data = json.loads(out)
|
||||
info = data.get("info", {})
|
||||
return HelmRelease(
|
||||
name=release_name,
|
||||
namespace=namespace,
|
||||
revision=data.get("version", 1),
|
||||
status=HelmReleaseStatus(info.get("status", "unknown")),
|
||||
updated=info.get("last_deployed", ""),
|
||||
)
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
return HelmRelease(
|
||||
name=release_name,
|
||||
namespace=namespace,
|
||||
revision=1,
|
||||
status=HelmReleaseStatus.DEPLOYED,
|
||||
)
|
||||
|
||||
async def uninstall(self, release_name: str, namespace: str) -> bool:
|
||||
"""helm uninstall."""
|
||||
async with self._get_lock(release_name):
|
||||
args = self._base_helm_args() + [
|
||||
"uninstall", release_name,
|
||||
"--namespace", namespace,
|
||||
]
|
||||
try:
|
||||
await self._run(args)
|
||||
return True
|
||||
except HelmError as e:
|
||||
if "not found" in e.stderr.lower():
|
||||
return True
|
||||
raise
|
||||
|
||||
async def rollback(
|
||||
self, release_name: str, namespace: str, revision: int = 0
|
||||
) -> HelmRelease:
|
||||
"""helm rollback (0 = previous revision)."""
|
||||
async with self._get_lock(release_name):
|
||||
args = self._base_helm_args() + [
|
||||
"rollback", release_name, str(revision),
|
||||
"--namespace", namespace,
|
||||
]
|
||||
await self._run(args)
|
||||
return await self.status(release_name, namespace)
|
||||
|
||||
async def status(self, release_name: str, namespace: str) -> HelmRelease:
|
||||
"""helm status."""
|
||||
args = self._base_helm_args() + [
|
||||
"status", release_name,
|
||||
"--namespace", namespace,
|
||||
"--output", "json",
|
||||
]
|
||||
out = await self._run(args)
|
||||
data = json.loads(out)
|
||||
info = data.get("info", {})
|
||||
return HelmRelease(
|
||||
name=data.get("name", release_name),
|
||||
namespace=namespace,
|
||||
revision=data.get("version", 0),
|
||||
status=HelmReleaseStatus(info.get("status", "unknown")),
|
||||
updated=info.get("last_deployed", ""),
|
||||
)
|
||||
|
||||
async def list_releases(
|
||||
self,
|
||||
namespace: str = "",
|
||||
all_namespaces: bool = False,
|
||||
filter_pattern: str = "",
|
||||
) -> list[HelmRelease]:
|
||||
"""helm list."""
|
||||
args = self._base_helm_args() + ["list", "--output", "json"]
|
||||
if all_namespaces:
|
||||
args.append("--all-namespaces")
|
||||
elif namespace:
|
||||
args += ["--namespace", namespace]
|
||||
if filter_pattern:
|
||||
args += ["--filter", filter_pattern]
|
||||
|
||||
out = await self._run(args)
|
||||
releases = []
|
||||
for r in json.loads(out or "[]"):
|
||||
releases.append(HelmRelease(
|
||||
name=r.get("name", ""),
|
||||
namespace=r.get("namespace", ""),
|
||||
revision=int(r.get("revision", "0")),
|
||||
status=HelmReleaseStatus(r.get("status", "unknown")),
|
||||
chart=r.get("chart", ""),
|
||||
app_version=r.get("app_version", ""),
|
||||
updated=r.get("updated", ""),
|
||||
))
|
||||
return releases
|
||||
|
||||
# ---- Kubectl operations ----
|
||||
|
||||
async def get_pods(
|
||||
self, namespace: str, label_selector: str = ""
|
||||
) -> list[PodInfo]:
|
||||
"""kubectl get pods."""
|
||||
args = self._base_kubectl_args() + [
|
||||
"get", "pods",
|
||||
"--namespace", namespace,
|
||||
"--output", "json",
|
||||
]
|
||||
if label_selector:
|
||||
args += ["-l", label_selector]
|
||||
|
||||
out = await self._run(args)
|
||||
data = json.loads(out)
|
||||
pods = []
|
||||
for item in data.get("items", []):
|
||||
meta = item.get("metadata", {})
|
||||
status = item.get("status", {})
|
||||
conditions = {c["type"]: c["status"] for c in status.get("conditions", [])}
|
||||
container_statuses = status.get("containerStatuses", [])
|
||||
restarts = sum(cs.get("restartCount", 0) for cs in container_statuses)
|
||||
|
||||
pods.append(PodInfo(
|
||||
name=meta.get("name", ""),
|
||||
ready=conditions.get("Ready") == "True",
|
||||
status=status.get("phase", "Unknown"),
|
||||
restarts=restarts,
|
||||
node=item.get("spec", {}).get("nodeName", ""),
|
||||
ip=status.get("podIP", ""),
|
||||
))
|
||||
return pods
|
||||
|
||||
async def get_services(self, namespace: str, label_selector: str = "") -> list[dict]:
|
||||
"""kubectl get services, returns raw dicts."""
|
||||
args = self._base_kubectl_args() + [
|
||||
"get", "services",
|
||||
"--namespace", namespace,
|
||||
"--output", "json",
|
||||
]
|
||||
if label_selector:
|
||||
args += ["-l", label_selector]
|
||||
|
||||
out = await self._run(args)
|
||||
data = json.loads(out)
|
||||
services = []
|
||||
for item in data.get("items", []):
|
||||
meta = item.get("metadata", {})
|
||||
spec = item.get("spec", {})
|
||||
lb_status = item.get("status", {}).get("loadBalancer", {})
|
||||
external_ips = [
|
||||
ing.get("ip", ing.get("hostname", ""))
|
||||
for ing in lb_status.get("ingress", [])
|
||||
]
|
||||
services.append({
|
||||
"name": meta.get("name", ""),
|
||||
"type": spec.get("type", ""),
|
||||
"cluster_ip": spec.get("clusterIP", ""),
|
||||
"external_ips": external_ips,
|
||||
"ports": [
|
||||
{"name": p.get("name", ""), "port": p.get("port"), "target_port": p.get("targetPort")}
|
||||
for p in spec.get("ports", [])
|
||||
],
|
||||
})
|
||||
return services
|
||||
|
||||
async def get_pod_logs(
|
||||
self,
|
||||
pod_name: str,
|
||||
namespace: str,
|
||||
tail: int = 100,
|
||||
container: str = "",
|
||||
) -> str:
|
||||
"""kubectl logs."""
|
||||
args = self._base_kubectl_args() + [
|
||||
"logs", pod_name,
|
||||
"--namespace", namespace,
|
||||
f"--tail={tail}",
|
||||
]
|
||||
if container:
|
||||
args += ["-c", container]
|
||||
return await self._run(args, timeout=30)
|
||||
|
||||
async def delete_pod(self, pod_name: str, namespace: str) -> bool:
|
||||
"""kubectl delete pod (for OnDelete rolling update)."""
|
||||
args = self._base_kubectl_args() + [
|
||||
"delete", "pod", pod_name,
|
||||
"--namespace", namespace,
|
||||
]
|
||||
try:
|
||||
await self._run(args)
|
||||
return True
|
||||
except HelmError:
|
||||
return False
|
||||
|
||||
_K8S_NAME_RE = __import__("re").compile(r"^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?$")
|
||||
|
||||
async def exec_pod(
|
||||
self, pod_name: str, namespace: str, command: list[str], timeout: int = 30
|
||||
) -> str:
|
||||
"""kubectl exec for running commands in a pod.
|
||||
|
||||
Validates pod_name and namespace to prevent injection.
|
||||
"""
|
||||
if not self._K8S_NAME_RE.match(pod_name):
|
||||
raise ValueError(f"Invalid pod name: {pod_name}")
|
||||
if not self._K8S_NAME_RE.match(namespace):
|
||||
raise ValueError(f"Invalid namespace: {namespace}")
|
||||
args = self._base_kubectl_args() + [
|
||||
"exec", pod_name,
|
||||
"--namespace", namespace,
|
||||
"--",
|
||||
] + command
|
||||
return await self._run(args, timeout=timeout)
|
||||
@@ -0,0 +1,906 @@
|
||||
"""Comprehensive tests for Lux fleet management.
|
||||
|
||||
Covers:
|
||||
- lux_models.py: Pydantic models, validators, enums, helm value translation
|
||||
- helm.py: HelmDeployer internals (mocked subprocess)
|
||||
- lux.py: LuxFleetManager helper methods (mocked HelmDeployer)
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from bootnode.core.chains.lux_models import (
|
||||
APIConfig,
|
||||
BootstrapConfig,
|
||||
ChainTrackingConfig,
|
||||
ConsensusConfig,
|
||||
ImageConfig,
|
||||
LuxFleetCreate,
|
||||
LuxFleetStatus,
|
||||
LuxFleetUpdate,
|
||||
LuxNetwork,
|
||||
LuxNetworkConfig,
|
||||
LuxNodeStatus,
|
||||
NETWORK_CONFIGS,
|
||||
NodeServicesConfig,
|
||||
RLPImportConfig,
|
||||
ResourceConfig,
|
||||
ResourceSpec,
|
||||
StorageConfig,
|
||||
fleet_create_to_helm_values,
|
||||
fleet_update_to_helm_values,
|
||||
)
|
||||
from bootnode.core.deploy.helm import (
|
||||
HelmDeployer,
|
||||
HelmError,
|
||||
HelmRelease,
|
||||
HelmReleaseStatus,
|
||||
PodInfo,
|
||||
)
|
||||
from bootnode.core.chains.lux import (
|
||||
LuxFleetManager,
|
||||
NETWORK_VALUES_FILES,
|
||||
)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 1. lux_models.py -- pure unit tests
|
||||
# =========================================================================
|
||||
|
||||
class TestLuxNetworkEnum:
|
||||
"""LuxNetwork enum values."""
|
||||
|
||||
def test_values(self):
|
||||
assert LuxNetwork.MAINNET.value == "mainnet"
|
||||
assert LuxNetwork.TESTNET.value == "testnet"
|
||||
assert LuxNetwork.DEVNET.value == "devnet"
|
||||
|
||||
def test_from_string(self):
|
||||
assert LuxNetwork("mainnet") == LuxNetwork.MAINNET
|
||||
assert LuxNetwork("testnet") == LuxNetwork.TESTNET
|
||||
assert LuxNetwork("devnet") == LuxNetwork.DEVNET
|
||||
|
||||
def test_member_count(self):
|
||||
assert len(LuxNetwork) == 3
|
||||
|
||||
|
||||
class TestLuxFleetStatusEnum:
|
||||
"""LuxFleetStatus enum values."""
|
||||
|
||||
def test_all_values(self):
|
||||
expected = {
|
||||
"pending", "deploying", "running", "degraded",
|
||||
"updating", "error", "destroying", "destroyed",
|
||||
}
|
||||
actual = {s.value for s in LuxFleetStatus}
|
||||
assert actual == expected
|
||||
|
||||
def test_member_count(self):
|
||||
assert len(LuxFleetStatus) == 8
|
||||
|
||||
|
||||
class TestLuxNodeStatusEnum:
|
||||
"""LuxNodeStatus enum values."""
|
||||
|
||||
def test_all_values(self):
|
||||
expected = {
|
||||
"pending", "init", "starting", "bootstrapping",
|
||||
"healthy", "unhealthy", "terminated",
|
||||
}
|
||||
actual = {s.value for s in LuxNodeStatus}
|
||||
assert actual == expected
|
||||
|
||||
def test_member_count(self):
|
||||
assert len(LuxNodeStatus) == 7
|
||||
|
||||
|
||||
class TestNetworkConfigs:
|
||||
"""NETWORK_CONFIGS canonical reference data."""
|
||||
|
||||
def test_all_networks_present(self):
|
||||
assert set(NETWORK_CONFIGS.keys()) == {
|
||||
LuxNetwork.MAINNET,
|
||||
LuxNetwork.TESTNET,
|
||||
LuxNetwork.DEVNET,
|
||||
}
|
||||
|
||||
def test_mainnet_config(self):
|
||||
cfg = NETWORK_CONFIGS[LuxNetwork.MAINNET]
|
||||
assert cfg.network_id == 1
|
||||
assert cfg.chain_id == 96369
|
||||
assert cfg.http_port == 9630
|
||||
assert cfg.staking_port == 9631
|
||||
assert cfg.namespace == "lux-mainnet"
|
||||
|
||||
def test_testnet_config(self):
|
||||
cfg = NETWORK_CONFIGS[LuxNetwork.TESTNET]
|
||||
assert cfg.network_id == 2
|
||||
assert cfg.chain_id == 96368
|
||||
assert cfg.http_port == 9640
|
||||
assert cfg.staking_port == 9641
|
||||
assert cfg.namespace == "lux-testnet"
|
||||
|
||||
def test_devnet_config(self):
|
||||
cfg = NETWORK_CONFIGS[LuxNetwork.DEVNET]
|
||||
assert cfg.network_id == 3
|
||||
assert cfg.chain_id == 96370
|
||||
assert cfg.http_port == 9650
|
||||
assert cfg.staking_port == 9651
|
||||
assert cfg.namespace == "lux-devnet"
|
||||
|
||||
def test_unique_network_ids(self):
|
||||
ids = [c.network_id for c in NETWORK_CONFIGS.values()]
|
||||
assert len(ids) == len(set(ids))
|
||||
|
||||
def test_unique_chain_ids(self):
|
||||
ids = [c.chain_id for c in NETWORK_CONFIGS.values()]
|
||||
assert len(ids) == len(set(ids))
|
||||
|
||||
def test_unique_namespaces(self):
|
||||
ns = [c.namespace for c in NETWORK_CONFIGS.values()]
|
||||
assert len(ns) == len(set(ns))
|
||||
|
||||
|
||||
class TestBootstrapConfig:
|
||||
"""BootstrapConfig validator tests."""
|
||||
|
||||
def test_valid_node_ids(self):
|
||||
cfg = BootstrapConfig(node_ids=["NodeID-abc123", "NodeID-xyz789"])
|
||||
assert len(cfg.node_ids) == 2
|
||||
|
||||
def test_empty_node_ids(self):
|
||||
cfg = BootstrapConfig(node_ids=[])
|
||||
assert cfg.node_ids == []
|
||||
|
||||
def test_invalid_node_id_format(self):
|
||||
with pytest.raises(ValueError, match="Invalid node ID format"):
|
||||
BootstrapConfig(node_ids=["bad-id"])
|
||||
|
||||
def test_invalid_node_id_mixed(self):
|
||||
"""One valid + one invalid should fail."""
|
||||
with pytest.raises(ValueError, match="Invalid node ID format"):
|
||||
BootstrapConfig(node_ids=["NodeID-good", "missing-prefix"])
|
||||
|
||||
def test_defaults(self):
|
||||
cfg = BootstrapConfig()
|
||||
assert cfg.node_ids == []
|
||||
assert cfg.use_hostnames is True
|
||||
assert cfg.external_ips == []
|
||||
assert cfg.rlp_import.enabled is False
|
||||
|
||||
|
||||
class TestLuxFleetCreate:
|
||||
"""LuxFleetCreate validation tests."""
|
||||
|
||||
def _valid_params(self, **overrides):
|
||||
defaults = {"name": "my-fleet", "cluster_id": "cluster-abc"}
|
||||
defaults.update(overrides)
|
||||
return defaults
|
||||
|
||||
def test_valid_minimal(self):
|
||||
req = LuxFleetCreate(**self._valid_params())
|
||||
assert req.name == "my-fleet"
|
||||
assert req.cluster_id == "cluster-abc"
|
||||
assert req.network == LuxNetwork.DEVNET
|
||||
assert req.replicas == 5
|
||||
|
||||
def test_valid_name_lowercase_with_hyphens(self):
|
||||
req = LuxFleetCreate(**self._valid_params(name="fleet-01"))
|
||||
assert req.name == "fleet-01"
|
||||
|
||||
def test_valid_name_single_char(self):
|
||||
req = LuxFleetCreate(**self._valid_params(name="a"))
|
||||
assert req.name == "a"
|
||||
|
||||
def test_invalid_name_uppercase(self):
|
||||
with pytest.raises(ValueError):
|
||||
LuxFleetCreate(**self._valid_params(name="MyFleet"))
|
||||
|
||||
def test_invalid_name_starts_with_digit(self):
|
||||
with pytest.raises(ValueError):
|
||||
LuxFleetCreate(**self._valid_params(name="1fleet"))
|
||||
|
||||
def test_invalid_name_starts_with_hyphen(self):
|
||||
with pytest.raises(ValueError):
|
||||
LuxFleetCreate(**self._valid_params(name="-fleet"))
|
||||
|
||||
def test_invalid_name_underscore(self):
|
||||
with pytest.raises(ValueError):
|
||||
LuxFleetCreate(**self._valid_params(name="my_fleet"))
|
||||
|
||||
def test_invalid_name_empty(self):
|
||||
with pytest.raises(ValueError):
|
||||
LuxFleetCreate(**self._valid_params(name=""))
|
||||
|
||||
def test_invalid_name_too_long(self):
|
||||
with pytest.raises(ValueError):
|
||||
LuxFleetCreate(**self._valid_params(name="a" * 64))
|
||||
|
||||
def test_name_max_length_63(self):
|
||||
req = LuxFleetCreate(**self._valid_params(name="a" * 63))
|
||||
assert len(req.name) == 63
|
||||
|
||||
def test_replicas_default(self):
|
||||
req = LuxFleetCreate(**self._valid_params())
|
||||
assert req.replicas == 5
|
||||
|
||||
def test_replicas_min_1(self):
|
||||
req = LuxFleetCreate(**self._valid_params(replicas=1))
|
||||
assert req.replicas == 1
|
||||
|
||||
def test_replicas_max_20(self):
|
||||
req = LuxFleetCreate(**self._valid_params(replicas=20))
|
||||
assert req.replicas == 20
|
||||
|
||||
def test_replicas_below_min(self):
|
||||
with pytest.raises(ValueError):
|
||||
LuxFleetCreate(**self._valid_params(replicas=0))
|
||||
|
||||
def test_replicas_above_max(self):
|
||||
with pytest.raises(ValueError):
|
||||
LuxFleetCreate(**self._valid_params(replicas=21))
|
||||
|
||||
def test_all_networks_accepted(self):
|
||||
for net in LuxNetwork:
|
||||
req = LuxFleetCreate(**self._valid_params(network=net))
|
||||
assert req.network == net
|
||||
|
||||
def test_optional_overrides_default_none(self):
|
||||
req = LuxFleetCreate(**self._valid_params())
|
||||
assert req.image is None
|
||||
assert req.bootstrap is None
|
||||
assert req.consensus is None
|
||||
assert req.chain_tracking is None
|
||||
assert req.resources is None
|
||||
assert req.storage is None
|
||||
assert req.node_services is None
|
||||
assert req.api is None
|
||||
|
||||
def test_log_level_default(self):
|
||||
req = LuxFleetCreate(**self._valid_params())
|
||||
assert req.log_level == "info"
|
||||
|
||||
def test_db_type_default(self):
|
||||
req = LuxFleetCreate(**self._valid_params())
|
||||
assert req.db_type == "badgerdb"
|
||||
|
||||
|
||||
class TestLuxFleetUpdate:
|
||||
"""LuxFleetUpdate partial update tests."""
|
||||
|
||||
def test_all_none_by_default(self):
|
||||
req = LuxFleetUpdate()
|
||||
assert req.replicas is None
|
||||
assert req.image is None
|
||||
assert req.consensus is None
|
||||
assert req.chain_tracking is None
|
||||
assert req.resources is None
|
||||
assert req.log_level is None
|
||||
|
||||
def test_replicas_bounds(self):
|
||||
req = LuxFleetUpdate(replicas=10)
|
||||
assert req.replicas == 10
|
||||
|
||||
def test_replicas_min(self):
|
||||
req = LuxFleetUpdate(replicas=1)
|
||||
assert req.replicas == 1
|
||||
|
||||
def test_replicas_max(self):
|
||||
req = LuxFleetUpdate(replicas=20)
|
||||
assert req.replicas == 20
|
||||
|
||||
def test_replicas_below_min(self):
|
||||
with pytest.raises(ValueError):
|
||||
LuxFleetUpdate(replicas=0)
|
||||
|
||||
def test_replicas_above_max(self):
|
||||
with pytest.raises(ValueError):
|
||||
LuxFleetUpdate(replicas=21)
|
||||
|
||||
|
||||
class TestFleetCreateToHelmValues:
|
||||
"""fleet_create_to_helm_values() translation tests."""
|
||||
|
||||
def _minimal_req(self, **overrides):
|
||||
defaults = {"name": "test", "cluster_id": "c1"}
|
||||
defaults.update(overrides)
|
||||
return LuxFleetCreate(**defaults)
|
||||
|
||||
def test_minimal_devnet(self):
|
||||
req = self._minimal_req()
|
||||
vals = fleet_create_to_helm_values(req)
|
||||
assert vals["network"] == "devnet"
|
||||
assert vals["replicas"] == 5
|
||||
assert vals["logLevel"] == "info"
|
||||
assert vals["dbType"] == "badgerdb"
|
||||
|
||||
def test_mainnet(self):
|
||||
req = self._minimal_req(network=LuxNetwork.MAINNET)
|
||||
vals = fleet_create_to_helm_values(req)
|
||||
assert vals["network"] == "mainnet"
|
||||
|
||||
def test_testnet(self):
|
||||
req = self._minimal_req(network=LuxNetwork.TESTNET)
|
||||
vals = fleet_create_to_helm_values(req)
|
||||
assert vals["network"] == "testnet"
|
||||
|
||||
def test_custom_replicas(self):
|
||||
req = self._minimal_req(replicas=10)
|
||||
vals = fleet_create_to_helm_values(req)
|
||||
assert vals["replicas"] == 10
|
||||
|
||||
def test_no_image_keys_when_none(self):
|
||||
req = self._minimal_req()
|
||||
vals = fleet_create_to_helm_values(req)
|
||||
assert "image.repository" not in vals
|
||||
assert "image.tag" not in vals
|
||||
|
||||
def test_image_override(self):
|
||||
req = self._minimal_req(image=ImageConfig(
|
||||
repository="ghcr.io/test/luxd",
|
||||
tag="v2.0.0",
|
||||
pull_policy="IfNotPresent",
|
||||
))
|
||||
vals = fleet_create_to_helm_values(req)
|
||||
assert vals["image.repository"] == "ghcr.io/test/luxd"
|
||||
assert vals["image.tag"] == "v2.0.0"
|
||||
assert vals["image.pullPolicy"] == "IfNotPresent"
|
||||
|
||||
def test_bootstrap_with_node_ids(self):
|
||||
req = self._minimal_req(bootstrap=BootstrapConfig(
|
||||
node_ids=["NodeID-aaa", "NodeID-bbb"],
|
||||
use_hostnames=False,
|
||||
))
|
||||
vals = fleet_create_to_helm_values(req)
|
||||
assert vals["bootstrap.useHostnames"] is False
|
||||
assert vals["bootstrap.nodeIDs"] == ["NodeID-aaa", "NodeID-bbb"]
|
||||
|
||||
def test_bootstrap_rlp_import(self):
|
||||
req = self._minimal_req(bootstrap=BootstrapConfig(
|
||||
rlp_import=RLPImportConfig(
|
||||
enabled=True,
|
||||
base_url="https://example.com",
|
||||
rlp_filename="chain.rlp",
|
||||
timeout=3600,
|
||||
),
|
||||
))
|
||||
vals = fleet_create_to_helm_values(req)
|
||||
assert vals["bootstrap.rlpImport.enabled"] is True
|
||||
assert vals["bootstrap.rlpImport.baseUrl"] == "https://example.com"
|
||||
assert vals["bootstrap.rlpImport.rlpFilename"] == "chain.rlp"
|
||||
assert vals["bootstrap.rlpImport.timeout"] == 3600
|
||||
|
||||
def test_consensus_override(self):
|
||||
req = self._minimal_req(consensus=ConsensusConfig(
|
||||
sample_size=10,
|
||||
quorum_size=8,
|
||||
sybil_protection_enabled=True,
|
||||
))
|
||||
vals = fleet_create_to_helm_values(req)
|
||||
assert vals["consensus.sampleSize"] == 10
|
||||
assert vals["consensus.quorumSize"] == 8
|
||||
assert vals["consensus.sybilProtectionEnabled"] is True
|
||||
|
||||
def test_chain_tracking_override(self):
|
||||
req = self._minimal_req(chain_tracking=ChainTrackingConfig(
|
||||
track_all_chains=False,
|
||||
tracked_chains=["X", "P", "C"],
|
||||
aliases=["zoo"],
|
||||
))
|
||||
vals = fleet_create_to_helm_values(req)
|
||||
assert vals["chainTracking.trackAllChains"] is False
|
||||
assert vals["chainTracking.trackedChains"] == ["X", "P", "C"]
|
||||
assert vals["chainTracking.aliases"] == ["zoo"]
|
||||
|
||||
def test_resources_override(self):
|
||||
req = self._minimal_req(resources=ResourceConfig(
|
||||
requests=ResourceSpec(memory="2Gi", cpu="1"),
|
||||
limits=ResourceSpec(memory="8Gi", cpu="4"),
|
||||
))
|
||||
vals = fleet_create_to_helm_values(req)
|
||||
assert vals["resources.requests.memory"] == "2Gi"
|
||||
assert vals["resources.requests.cpu"] == "1"
|
||||
assert vals["resources.limits.memory"] == "8Gi"
|
||||
assert vals["resources.limits.cpu"] == "4"
|
||||
|
||||
def test_storage_override(self):
|
||||
req = self._minimal_req(storage=StorageConfig(
|
||||
size="500Gi",
|
||||
storage_class="premium-rwo",
|
||||
))
|
||||
vals = fleet_create_to_helm_values(req)
|
||||
assert vals["storage.size"] == "500Gi"
|
||||
assert vals["storage.storageClass"] == "premium-rwo"
|
||||
|
||||
def test_node_services_override(self):
|
||||
req = self._minimal_req(node_services=NodeServicesConfig(
|
||||
enabled=False,
|
||||
type="ClusterIP",
|
||||
))
|
||||
vals = fleet_create_to_helm_values(req)
|
||||
assert vals["nodeServices.enabled"] is False
|
||||
assert vals["nodeServices.type"] == "ClusterIP"
|
||||
|
||||
def test_api_override(self):
|
||||
req = self._minimal_req(api=APIConfig(
|
||||
admin_enabled=False,
|
||||
metrics_enabled=True,
|
||||
index_enabled=False,
|
||||
http_allowed_hosts="127.0.0.1",
|
||||
))
|
||||
vals = fleet_create_to_helm_values(req)
|
||||
assert vals["api.adminEnabled"] is False
|
||||
assert vals["api.metricsEnabled"] is True
|
||||
assert vals["api.indexEnabled"] is False
|
||||
assert vals["api.httpAllowedHosts"] == "127.0.0.1"
|
||||
|
||||
def test_all_three_networks_produce_correct_network_key(self):
|
||||
for net in LuxNetwork:
|
||||
req = self._minimal_req(network=net)
|
||||
vals = fleet_create_to_helm_values(req)
|
||||
assert vals["network"] == net.value
|
||||
|
||||
|
||||
class TestFleetUpdateToHelmValues:
|
||||
"""fleet_update_to_helm_values() partial translation tests."""
|
||||
|
||||
def test_empty_update_produces_empty_dict(self):
|
||||
req = LuxFleetUpdate()
|
||||
vals = fleet_update_to_helm_values(req)
|
||||
assert vals == {}
|
||||
|
||||
def test_replicas_only(self):
|
||||
req = LuxFleetUpdate(replicas=3)
|
||||
vals = fleet_update_to_helm_values(req)
|
||||
assert vals == {"replicas": 3}
|
||||
|
||||
def test_log_level_only(self):
|
||||
req = LuxFleetUpdate(log_level="debug")
|
||||
vals = fleet_update_to_helm_values(req)
|
||||
assert vals == {"logLevel": "debug"}
|
||||
|
||||
def test_image_only(self):
|
||||
req = LuxFleetUpdate(image=ImageConfig(
|
||||
repository="test", tag="v1", pull_policy="Never",
|
||||
))
|
||||
vals = fleet_update_to_helm_values(req)
|
||||
assert vals["image.repository"] == "test"
|
||||
assert vals["image.tag"] == "v1"
|
||||
assert vals["image.pullPolicy"] == "Never"
|
||||
assert "replicas" not in vals
|
||||
assert "logLevel" not in vals
|
||||
|
||||
def test_consensus_only(self):
|
||||
req = LuxFleetUpdate(consensus=ConsensusConfig(
|
||||
sample_size=7,
|
||||
quorum_size=5,
|
||||
sybil_protection_enabled=True,
|
||||
))
|
||||
vals = fleet_update_to_helm_values(req)
|
||||
assert vals["consensus.sampleSize"] == 7
|
||||
assert vals["consensus.quorumSize"] == 5
|
||||
assert vals["consensus.sybilProtectionEnabled"] is True
|
||||
assert "replicas" not in vals
|
||||
|
||||
def test_resources_only(self):
|
||||
req = LuxFleetUpdate(resources=ResourceConfig())
|
||||
vals = fleet_update_to_helm_values(req)
|
||||
assert "resources.requests.memory" in vals
|
||||
assert "resources.limits.cpu" in vals
|
||||
assert "replicas" not in vals
|
||||
|
||||
def test_chain_tracking_only(self):
|
||||
req = LuxFleetUpdate(chain_tracking=ChainTrackingConfig(
|
||||
track_all_chains=True,
|
||||
))
|
||||
vals = fleet_update_to_helm_values(req)
|
||||
assert vals["chainTracking.trackAllChains"] is True
|
||||
|
||||
def test_multiple_fields(self):
|
||||
req = LuxFleetUpdate(replicas=8, log_level="warn")
|
||||
vals = fleet_update_to_helm_values(req)
|
||||
assert vals["replicas"] == 8
|
||||
assert vals["logLevel"] == "warn"
|
||||
assert len(vals) == 2
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 2. helm.py -- HelmDeployer tests (mock subprocess)
|
||||
# =========================================================================
|
||||
|
||||
class TestHelmReleaseStatusEnum:
|
||||
"""HelmReleaseStatus enum values."""
|
||||
|
||||
def test_all_values(self):
|
||||
expected = {
|
||||
"deployed", "failed", "pending-install", "pending-upgrade",
|
||||
"pending-rollback", "superseded", "uninstalled", "uninstalling",
|
||||
"unknown",
|
||||
}
|
||||
actual = {s.value for s in HelmReleaseStatus}
|
||||
assert actual == expected
|
||||
|
||||
def test_member_count(self):
|
||||
assert len(HelmReleaseStatus) == 9
|
||||
|
||||
|
||||
class TestHelmRelease:
|
||||
"""HelmRelease dataclass creation."""
|
||||
|
||||
def test_basic_creation(self):
|
||||
r = HelmRelease(
|
||||
name="luxd-devnet",
|
||||
namespace="lux-devnet",
|
||||
revision=3,
|
||||
status=HelmReleaseStatus.DEPLOYED,
|
||||
)
|
||||
assert r.name == "luxd-devnet"
|
||||
assert r.namespace == "lux-devnet"
|
||||
assert r.revision == 3
|
||||
assert r.status == HelmReleaseStatus.DEPLOYED
|
||||
assert r.chart == ""
|
||||
assert r.app_version == ""
|
||||
assert r.updated == ""
|
||||
|
||||
def test_full_creation(self):
|
||||
r = HelmRelease(
|
||||
name="luxd-mainnet",
|
||||
namespace="lux-mainnet",
|
||||
revision=7,
|
||||
status=HelmReleaseStatus.DEPLOYED,
|
||||
chart="lux-0.1.0",
|
||||
app_version="1.23.11",
|
||||
updated="2026-02-10T00:00:00Z",
|
||||
)
|
||||
assert r.chart == "lux-0.1.0"
|
||||
assert r.app_version == "1.23.11"
|
||||
assert r.updated == "2026-02-10T00:00:00Z"
|
||||
|
||||
|
||||
class TestHelmError:
|
||||
"""HelmError exception."""
|
||||
|
||||
def test_basic(self):
|
||||
e = HelmError("deploy failed")
|
||||
assert str(e) == "deploy failed"
|
||||
assert e.stderr == ""
|
||||
assert e.returncode == 1
|
||||
|
||||
def test_with_details(self):
|
||||
e = HelmError("timeout", stderr="connection refused", returncode=2)
|
||||
assert e.stderr == "connection refused"
|
||||
assert e.returncode == 2
|
||||
|
||||
|
||||
class TestPodInfo:
|
||||
"""PodInfo dataclass."""
|
||||
|
||||
def test_creation(self):
|
||||
p = PodInfo(name="luxd-0", ready=True, status="Running")
|
||||
assert p.name == "luxd-0"
|
||||
assert p.ready is True
|
||||
assert p.restarts == 0
|
||||
assert p.node == ""
|
||||
assert p.ip == ""
|
||||
|
||||
|
||||
class TestHelmDeployerBaseArgs:
|
||||
"""HelmDeployer._base_helm_args() tests."""
|
||||
|
||||
def test_no_kubeconfig(self):
|
||||
d = HelmDeployer(chart_path=Path("/charts/lux"))
|
||||
args = d._base_helm_args()
|
||||
assert args == ["helm"]
|
||||
|
||||
def test_with_kubeconfig(self):
|
||||
d = HelmDeployer(
|
||||
chart_path=Path("/charts/lux"),
|
||||
kubeconfig_path=Path("/tmp/kube.conf"),
|
||||
)
|
||||
args = d._base_helm_args()
|
||||
assert args == ["helm", "--kubeconfig", "/tmp/kube.conf"]
|
||||
|
||||
def test_with_context(self):
|
||||
d = HelmDeployer(
|
||||
chart_path=Path("/charts/lux"),
|
||||
kube_context="my-ctx",
|
||||
)
|
||||
args = d._base_helm_args()
|
||||
assert args == ["helm", "--kube-context", "my-ctx"]
|
||||
|
||||
def test_with_kubeconfig_and_context(self):
|
||||
d = HelmDeployer(
|
||||
chart_path=Path("/charts/lux"),
|
||||
kubeconfig_path=Path("/tmp/kc"),
|
||||
kube_context="ctx",
|
||||
)
|
||||
args = d._base_helm_args()
|
||||
assert args == [
|
||||
"helm",
|
||||
"--kubeconfig", "/tmp/kc",
|
||||
"--kube-context", "ctx",
|
||||
]
|
||||
|
||||
def test_custom_binary(self):
|
||||
d = HelmDeployer(
|
||||
chart_path=Path("/charts/lux"),
|
||||
helm_binary="/usr/local/bin/helm3",
|
||||
)
|
||||
args = d._base_helm_args()
|
||||
assert args[0] == "/usr/local/bin/helm3"
|
||||
|
||||
|
||||
class TestHelmDeployerBaseKubectlArgs:
|
||||
"""HelmDeployer._base_kubectl_args() tests."""
|
||||
|
||||
def test_no_config(self):
|
||||
d = HelmDeployer(chart_path=Path("/charts/lux"))
|
||||
args = d._base_kubectl_args()
|
||||
assert args == ["kubectl"]
|
||||
|
||||
def test_with_kubeconfig(self):
|
||||
d = HelmDeployer(
|
||||
chart_path=Path("/charts/lux"),
|
||||
kubeconfig_path=Path("/tmp/kc"),
|
||||
)
|
||||
args = d._base_kubectl_args()
|
||||
assert args == ["kubectl", "--kubeconfig", "/tmp/kc"]
|
||||
|
||||
def test_with_context(self):
|
||||
d = HelmDeployer(
|
||||
chart_path=Path("/charts/lux"),
|
||||
kube_context="ctx",
|
||||
)
|
||||
args = d._base_kubectl_args()
|
||||
# kubectl uses --context not --kube-context
|
||||
assert args == ["kubectl", "--context", "ctx"]
|
||||
|
||||
|
||||
class TestHelmDeployerWriteValues:
|
||||
"""HelmDeployer._write_values_file() tests."""
|
||||
|
||||
def test_creates_valid_yaml(self):
|
||||
d = HelmDeployer(chart_path=Path("/charts/lux"))
|
||||
values = {
|
||||
"network": "devnet",
|
||||
"replicas": 5,
|
||||
"logLevel": "info",
|
||||
"nested": {"key": "value"},
|
||||
}
|
||||
path = d._write_values_file(values)
|
||||
try:
|
||||
assert path.exists()
|
||||
assert path.suffix == ".yaml"
|
||||
assert "helm-values-" in path.name
|
||||
content = yaml.safe_load(path.read_text())
|
||||
assert content["network"] == "devnet"
|
||||
assert content["replicas"] == 5
|
||||
assert content["logLevel"] == "info"
|
||||
assert content["nested"]["key"] == "value"
|
||||
finally:
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
def test_empty_dict(self):
|
||||
d = HelmDeployer(chart_path=Path("/charts/lux"))
|
||||
path = d._write_values_file({})
|
||||
try:
|
||||
assert path.exists()
|
||||
content = yaml.safe_load(path.read_text())
|
||||
# yaml.safe_load returns None for empty doc
|
||||
assert content is None or content == {}
|
||||
finally:
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
def test_list_values(self):
|
||||
d = HelmDeployer(chart_path=Path("/charts/lux"))
|
||||
values = {"nodeIDs": ["NodeID-a", "NodeID-b"]}
|
||||
path = d._write_values_file(values)
|
||||
try:
|
||||
content = yaml.safe_load(path.read_text())
|
||||
assert content["nodeIDs"] == ["NodeID-a", "NodeID-b"]
|
||||
finally:
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
class TestHelmDeployerLock:
|
||||
"""HelmDeployer._get_lock() tests."""
|
||||
|
||||
def test_same_release_returns_same_lock(self):
|
||||
d = HelmDeployer(chart_path=Path("/charts/lux"))
|
||||
lock1 = d._get_lock("luxd-devnet")
|
||||
lock2 = d._get_lock("luxd-devnet")
|
||||
assert lock1 is lock2
|
||||
|
||||
def test_different_releases_return_different_locks(self):
|
||||
d = HelmDeployer(chart_path=Path("/charts/lux"))
|
||||
lock1 = d._get_lock("luxd-devnet")
|
||||
lock2 = d._get_lock("luxd-mainnet")
|
||||
assert lock1 is not lock2
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 3. lux.py -- LuxFleetManager tests (mock HelmDeployer)
|
||||
# =========================================================================
|
||||
|
||||
class TestLuxFleetManagerReleaseName:
|
||||
"""LuxFleetManager._release_name() tests."""
|
||||
|
||||
def test_mainnet(self):
|
||||
mgr = LuxFleetManager()
|
||||
assert mgr._release_name(LuxNetwork.MAINNET) == "luxd-mainnet"
|
||||
|
||||
def test_testnet(self):
|
||||
mgr = LuxFleetManager()
|
||||
assert mgr._release_name(LuxNetwork.TESTNET) == "luxd-testnet"
|
||||
|
||||
def test_devnet(self):
|
||||
mgr = LuxFleetManager()
|
||||
assert mgr._release_name(LuxNetwork.DEVNET) == "luxd-devnet"
|
||||
|
||||
|
||||
class TestLuxFleetManagerNamespace:
|
||||
"""LuxFleetManager._namespace() tests."""
|
||||
|
||||
def test_mainnet(self):
|
||||
mgr = LuxFleetManager()
|
||||
assert mgr._namespace(LuxNetwork.MAINNET) == "lux-mainnet"
|
||||
|
||||
def test_testnet(self):
|
||||
mgr = LuxFleetManager()
|
||||
assert mgr._namespace(LuxNetwork.TESTNET) == "lux-testnet"
|
||||
|
||||
def test_devnet(self):
|
||||
mgr = LuxFleetManager()
|
||||
assert mgr._namespace(LuxNetwork.DEVNET) == "lux-devnet"
|
||||
|
||||
|
||||
class TestLuxFleetManagerFleetId:
|
||||
"""LuxFleetManager._fleet_id() tests."""
|
||||
|
||||
def test_format(self):
|
||||
mgr = LuxFleetManager()
|
||||
assert mgr._fleet_id("my-fleet", LuxNetwork.DEVNET) == "my-fleet-devnet"
|
||||
assert mgr._fleet_id("prod", LuxNetwork.MAINNET) == "prod-mainnet"
|
||||
|
||||
|
||||
class TestLuxFleetManagerGetValuesFiles:
|
||||
"""LuxFleetManager._get_values_files() tests."""
|
||||
|
||||
def test_mainnet_returns_file_if_exists(self, tmp_path):
|
||||
chart_dir = tmp_path / "charts" / "lux"
|
||||
chart_dir.mkdir(parents=True)
|
||||
values_file = chart_dir / "values-mainnet.yaml"
|
||||
values_file.write_text("network: mainnet\n")
|
||||
|
||||
mgr = LuxFleetManager(chart_path=chart_dir)
|
||||
files = mgr._get_values_files(LuxNetwork.MAINNET)
|
||||
assert len(files) == 1
|
||||
assert files[0] == values_file
|
||||
|
||||
def test_testnet_returns_file_if_exists(self, tmp_path):
|
||||
chart_dir = tmp_path / "charts" / "lux"
|
||||
chart_dir.mkdir(parents=True)
|
||||
values_file = chart_dir / "values-testnet.yaml"
|
||||
values_file.write_text("network: testnet\n")
|
||||
|
||||
mgr = LuxFleetManager(chart_path=chart_dir)
|
||||
files = mgr._get_values_files(LuxNetwork.TESTNET)
|
||||
assert len(files) == 1
|
||||
assert files[0] == values_file
|
||||
|
||||
def test_devnet_returns_empty_list(self, tmp_path):
|
||||
"""Devnet has no network-specific values file in NETWORK_VALUES_FILES."""
|
||||
chart_dir = tmp_path / "charts" / "lux"
|
||||
chart_dir.mkdir(parents=True)
|
||||
|
||||
mgr = LuxFleetManager(chart_path=chart_dir)
|
||||
files = mgr._get_values_files(LuxNetwork.DEVNET)
|
||||
assert files == []
|
||||
|
||||
def test_mainnet_returns_empty_if_file_missing(self, tmp_path):
|
||||
chart_dir = tmp_path / "charts" / "lux"
|
||||
chart_dir.mkdir(parents=True)
|
||||
# Do not create the values-mainnet.yaml file
|
||||
|
||||
mgr = LuxFleetManager(chart_path=chart_dir)
|
||||
files = mgr._get_values_files(LuxNetwork.MAINNET)
|
||||
assert files == []
|
||||
|
||||
|
||||
class TestNetworkValuesFiles:
|
||||
"""NETWORK_VALUES_FILES constant."""
|
||||
|
||||
def test_mainnet_has_values_file(self):
|
||||
assert "mainnet" in NETWORK_VALUES_FILES
|
||||
assert NETWORK_VALUES_FILES["mainnet"] == "values-mainnet.yaml"
|
||||
|
||||
def test_testnet_has_values_file(self):
|
||||
assert "testnet" in NETWORK_VALUES_FILES
|
||||
assert NETWORK_VALUES_FILES["testnet"] == "values-testnet.yaml"
|
||||
|
||||
def test_devnet_has_no_values_file(self):
|
||||
assert "devnet" not in NETWORK_VALUES_FILES
|
||||
|
||||
|
||||
class TestLuxFleetManagerGetDeployer:
|
||||
"""LuxFleetManager._get_deployer() creates HelmDeployer from kubeconfig."""
|
||||
|
||||
async def test_creates_deployer_with_kubeconfig(self):
|
||||
mgr = LuxFleetManager(chart_path=Path("/opt/charts/lux"))
|
||||
kubeconfig_yaml = "apiVersion: v1\nkind: Config\nclusters: []\n"
|
||||
deployer = await mgr._get_deployer(kubeconfig_yaml)
|
||||
assert isinstance(deployer, HelmDeployer)
|
||||
assert deployer.chart_path == Path("/opt/charts/lux")
|
||||
assert deployer.kubeconfig_path is not None
|
||||
assert deployer.kubeconfig_path.exists()
|
||||
content = deployer.kubeconfig_path.read_text()
|
||||
assert "apiVersion: v1" in content
|
||||
# Cleanup
|
||||
deployer.kubeconfig_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Sub-model default value tests
|
||||
# =========================================================================
|
||||
|
||||
class TestSubModelDefaults:
|
||||
"""Verify defaults of sub-models match chart expectations."""
|
||||
|
||||
def test_rlp_import_defaults(self):
|
||||
r = RLPImportConfig()
|
||||
assert r.enabled is False
|
||||
assert r.base_url == ""
|
||||
assert r.rlp_filename == ""
|
||||
assert r.multi_part is False
|
||||
assert r.parts == []
|
||||
assert r.min_height == 1
|
||||
assert r.timeout == 7200
|
||||
|
||||
def test_consensus_defaults(self):
|
||||
c = ConsensusConfig()
|
||||
assert c.sample_size == 5
|
||||
assert c.quorum_size == 4
|
||||
assert c.sybil_protection_enabled is False
|
||||
assert c.require_validator_to_connect is False
|
||||
assert c.allow_private_ips is True
|
||||
|
||||
def test_chain_tracking_defaults(self):
|
||||
ct = ChainTrackingConfig()
|
||||
assert ct.track_all_chains is True
|
||||
assert ct.tracked_chains == []
|
||||
assert ct.aliases == ["zoo", "hanzo", "spc", "pars"]
|
||||
|
||||
def test_image_defaults(self):
|
||||
img = ImageConfig()
|
||||
assert img.repository == "registry.digitalocean.com/hanzo/bootnode"
|
||||
assert img.tag == "luxd-v1.23.11"
|
||||
assert img.pull_policy == "Always"
|
||||
|
||||
def test_resource_defaults(self):
|
||||
r = ResourceConfig()
|
||||
assert r.requests.memory == "1Gi"
|
||||
assert r.requests.cpu == "500m"
|
||||
assert r.limits.memory == "4Gi"
|
||||
assert r.limits.cpu == "2"
|
||||
|
||||
def test_storage_defaults(self):
|
||||
s = StorageConfig()
|
||||
assert s.size == "100Gi"
|
||||
assert s.storage_class == "do-block-storage"
|
||||
|
||||
def test_node_services_defaults(self):
|
||||
ns = NodeServicesConfig()
|
||||
assert ns.enabled is True
|
||||
assert ns.type == "LoadBalancer"
|
||||
assert ns.annotations == {}
|
||||
|
||||
def test_api_defaults(self):
|
||||
a = APIConfig()
|
||||
assert a.admin_enabled is True
|
||||
assert a.metrics_enabled is True
|
||||
assert a.index_enabled is True
|
||||
assert a.http_allowed_hosts == "*"
|
||||
@@ -0,0 +1,232 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useMemo } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { FleetMap, type FleetRegion } from "@/components/lux/fleet-map"
|
||||
import { DOKS_REGIONS, type LuxFleet } from "@/components/lux/region-data"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Globe, Plus, RefreshCw, Zap } from "lucide-react"
|
||||
|
||||
/** Convert LuxFleet[] to FleetRegion[] for the map component */
|
||||
function fleetsToRegions(fleets: LuxFleet[]): FleetRegion[] {
|
||||
// Group fleets by region
|
||||
const byRegion = new Map<string, LuxFleet[]>()
|
||||
for (const f of fleets) {
|
||||
const existing = byRegion.get(f.region) ?? []
|
||||
existing.push(f)
|
||||
byRegion.set(f.region, existing)
|
||||
}
|
||||
|
||||
const seenCoords = new Set<string>()
|
||||
const regions: FleetRegion[] = []
|
||||
|
||||
// Active regions
|
||||
for (const [regionId, regionFleets] of byRegion) {
|
||||
const r = DOKS_REGIONS[regionId]
|
||||
if (!r) continue
|
||||
const nodeCount = regionFleets.reduce((s, f) => s + f.validators, 0)
|
||||
const healthPcts = regionFleets.filter((f) => f.healthPct != null).map((f) => f.healthPct!)
|
||||
const avgHealth = healthPcts.length > 0 ? healthPcts.reduce((s, v) => s + v, 0) / healthPcts.length : 100
|
||||
const healthyNodes = Math.round((avgHealth / 100) * nodeCount)
|
||||
const networks = regionFleets.some((f) => f.name.includes("testnet"))
|
||||
? ["mainnet", "testnet"]
|
||||
: ["mainnet"]
|
||||
|
||||
seenCoords.add(`${r.lat},${r.lng}`)
|
||||
regions.push({
|
||||
slug: regionId,
|
||||
name: r.name,
|
||||
lat: r.lat,
|
||||
lon: r.lng,
|
||||
active: true,
|
||||
nodeCount,
|
||||
networks,
|
||||
healthyNodes,
|
||||
})
|
||||
}
|
||||
|
||||
// Inactive regions
|
||||
for (const [regionId, r] of Object.entries(DOKS_REGIONS)) {
|
||||
const key = `${r.lat},${r.lng}`
|
||||
if (!seenCoords.has(key)) {
|
||||
seenCoords.add(key)
|
||||
regions.push({
|
||||
slug: regionId,
|
||||
name: r.name,
|
||||
lat: r.lat,
|
||||
lon: r.lng,
|
||||
active: false,
|
||||
nodeCount: 0,
|
||||
networks: [],
|
||||
healthyNodes: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return regions
|
||||
}
|
||||
|
||||
// Demo fleets for development -- replaced by live API in production
|
||||
const DEMO_FLEETS: LuxFleet[] = [
|
||||
{ id: "f1", name: "lux-mainnet-us-east", region: "nyc1", status: "running", validators: 12, blockHeight: 8_421_003, healthPct: 99.8 },
|
||||
{ id: "f2", name: "lux-mainnet-us-west", region: "sfo3", status: "running", validators: 8, blockHeight: 8_420_998, healthPct: 99.5 },
|
||||
{ id: "f3", name: "lux-mainnet-eu", region: "fra1", status: "running", validators: 10, blockHeight: 8_421_001, healthPct: 100 },
|
||||
{ id: "f4", name: "lux-mainnet-london", region: "lon1", status: "running", validators: 6, blockHeight: 8_421_000, healthPct: 98.2 },
|
||||
{ id: "f5", name: "lux-mainnet-apac", region: "sgp1", status: "deploying", validators: 4, blockHeight: 8_418_200, healthPct: 72 },
|
||||
{ id: "f6", name: "lux-testnet-toronto", region: "tor1", status: "running", validators: 3, blockHeight: 2_100_450, healthPct: 100 },
|
||||
{ id: "f7", name: "lux-mainnet-sydney", region: "syd1", status: "degraded", validators: 2, blockHeight: 8_419_500, healthPct: 45 },
|
||||
]
|
||||
|
||||
export default function LuxFleetMapPage() {
|
||||
const [useLive, setUseLive] = useState(false)
|
||||
const [demoFleets, setDemoFleets] = useState(DEMO_FLEETS)
|
||||
const [selectedRegion, setSelectedRegion] = useState<string | null>(null)
|
||||
|
||||
// Available regions not yet running a fleet
|
||||
const availableRegions = useMemo(() => {
|
||||
const activeRegions = new Set(demoFleets.map((f) => f.region))
|
||||
return Object.entries(DOKS_REGIONS)
|
||||
.filter(([id]) => !activeRegions.has(id))
|
||||
.map(([id, r]) => ({ id, ...r }))
|
||||
}, [demoFleets])
|
||||
|
||||
function addRandomFleet() {
|
||||
if (availableRegions.length === 0) return
|
||||
const target = availableRegions[Math.floor(Math.random() * availableRegions.length)]
|
||||
const newFleet: LuxFleet = {
|
||||
id: `f${Date.now()}`,
|
||||
name: `lux-fleet-${target.id}`,
|
||||
region: target.id,
|
||||
status: "deploying",
|
||||
validators: Math.floor(Math.random() * 8) + 2,
|
||||
blockHeight: 8_000_000 + Math.floor(Math.random() * 500_000),
|
||||
healthPct: 60 + Math.random() * 30,
|
||||
}
|
||||
setDemoFleets((prev) => [...prev, newFleet])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Globe className="h-6 w-6" />
|
||||
Lux Fleet Map
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Real-time geolocated validator nodes across DOKS regions
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setUseLive((v) => !v)}
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
||||
{useLive ? "Demo Mode" : "Live Mode"}
|
||||
</Button>
|
||||
{!useLive && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addRandomFleet}
|
||||
disabled={availableRegions.length === 0}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||
Add Fleet
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map */}
|
||||
<FleetMap
|
||||
regions={fleetsToRegions(useLive ? [] : demoFleets)}
|
||||
onRegionClick={setSelectedRegion}
|
||||
/>
|
||||
|
||||
{/* Active Fleets Table */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Zap className="h-4 w-4" />
|
||||
Active Fleets
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{(useLive ? [] : demoFleets).map((fleet) => (
|
||||
<div
|
||||
key={fleet.id}
|
||||
className={cn(
|
||||
"flex items-center justify-between rounded-lg border p-3 text-sm transition-colors",
|
||||
selectedRegion === fleet.region && "border-green-500/30 bg-green-500/5"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-mono text-xs font-medium">{fleet.name}</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{DOKS_REGIONS[fleet.region]?.name ?? fleet.region}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs tabular-nums">{fleet.validators}v</span>
|
||||
<Badge
|
||||
variant={
|
||||
fleet.status === "running" ? "success" :
|
||||
fleet.status === "deploying" ? "warning" :
|
||||
fleet.status === "degraded" ? "destructive" :
|
||||
"secondary"
|
||||
}
|
||||
className="text-[10px] px-1.5"
|
||||
>
|
||||
{fleet.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{useLive && (
|
||||
<p className="text-xs text-muted-foreground text-center py-4">
|
||||
Polling /v1/lux/fleets every 10s...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Available Regions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{availableRegions.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className={cn(
|
||||
"flex items-center justify-between rounded-lg border px-3 py-2 text-xs transition-colors cursor-pointer hover:border-gray-400",
|
||||
selectedRegion === r.id && "border-blue-500/30 bg-blue-500/5"
|
||||
)}
|
||||
onClick={() => setSelectedRegion(r.id)}
|
||||
>
|
||||
<span className="font-mono">{r.id}</span>
|
||||
<span className="text-muted-foreground">{r.country}</span>
|
||||
</div>
|
||||
))}
|
||||
{availableRegions.length === 0 && (
|
||||
<p className="col-span-2 text-xs text-muted-foreground text-center py-4">
|
||||
All regions have active fleets
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,958 @@
|
||||
"use client"
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select } from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import {
|
||||
Plus,
|
||||
Loader2,
|
||||
RefreshCcw,
|
||||
Trash2,
|
||||
RotateCcw,
|
||||
Scale,
|
||||
Shield,
|
||||
Globe,
|
||||
Heart,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Circle,
|
||||
AlertTriangle,
|
||||
} from "lucide-react"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type FleetStatus = "running" | "degraded" | "error" | "deploying" | "stopped"
|
||||
type NetworkType = "mainnet" | "testnet" | "devnet"
|
||||
|
||||
interface FleetNode {
|
||||
pod_name: string
|
||||
status: string
|
||||
ready: boolean
|
||||
external_ip: string | null
|
||||
c_chain_height: number | null
|
||||
peers: number | null
|
||||
node_id: string | null
|
||||
healthy: boolean | null
|
||||
}
|
||||
|
||||
interface LuxFleetSummary {
|
||||
cluster_id: string
|
||||
network: NetworkType
|
||||
name: string
|
||||
status: FleetStatus
|
||||
replicas: number
|
||||
ready_replicas: number
|
||||
helm_revision: number
|
||||
image_tag: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface LuxFleetResponse extends LuxFleetSummary {
|
||||
nodes: FleetNode[]
|
||||
}
|
||||
|
||||
interface LuxFleetStats {
|
||||
total_fleets: number
|
||||
total_nodes: number
|
||||
healthy_nodes: number
|
||||
}
|
||||
|
||||
interface Cluster {
|
||||
id: string
|
||||
name: string
|
||||
region: string
|
||||
}
|
||||
|
||||
interface NetworkConfig {
|
||||
network: NetworkType
|
||||
chain_id: string
|
||||
default_image: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants & helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
if (typeof window === "undefined") return {}
|
||||
const token = localStorage.getItem("bootnode_token")
|
||||
const apiKey = localStorage.getItem("bootnode_api_key")
|
||||
if (token) return { Authorization: `Bearer ${token}` }
|
||||
if (apiKey) return { "X-API-Key": apiKey }
|
||||
return {}
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<FleetStatus, string> = {
|
||||
running: "bg-green-500/15 text-green-500 border-green-500/30",
|
||||
degraded: "bg-yellow-500/15 text-yellow-500 border-yellow-500/30",
|
||||
error: "bg-red-500/15 text-red-500 border-red-500/30",
|
||||
deploying: "bg-blue-500/15 text-blue-500 border-blue-500/30",
|
||||
stopped: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30",
|
||||
}
|
||||
|
||||
const NETWORK_COLORS: Record<NetworkType, string> = {
|
||||
mainnet: "border-green-500/50 text-green-400",
|
||||
testnet: "border-blue-500/50 text-blue-400",
|
||||
devnet: "border-orange-500/50 text-orange-400",
|
||||
}
|
||||
|
||||
function statusBadge(status: FleetStatus) {
|
||||
return (
|
||||
<Badge variant="outline" className={`${STATUS_COLORS[status]} capitalize`}>
|
||||
{status === "running" && <Circle className="mr-1 h-2 w-2 fill-green-500" />}
|
||||
{status === "degraded" && <AlertTriangle className="mr-1 h-3 w-3" />}
|
||||
{status === "error" && <Circle className="mr-1 h-2 w-2 fill-red-500" />}
|
||||
{status === "deploying" && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
|
||||
{status}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function LuxFleetsPage() {
|
||||
// Data
|
||||
const [clusters, setClusters] = useState<Cluster[]>([])
|
||||
const [selectedCluster, setSelectedCluster] = useState<string>("")
|
||||
const [fleets, setFleets] = useState<LuxFleetSummary[]>([])
|
||||
const [fleetDetails, setFleetDetails] = useState<Record<string, LuxFleetResponse>>({})
|
||||
const [stats, setStats] = useState<LuxFleetStats | null>(null)
|
||||
const [networks, setNetworks] = useState<NetworkConfig[]>([])
|
||||
|
||||
// UI
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [expandedFleet, setExpandedFleet] = useState<string | null>(null)
|
||||
|
||||
// Create dialog
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
const [formName, setFormName] = useState("")
|
||||
const [formNetwork, setFormNetwork] = useState<NetworkType>("testnet")
|
||||
const [formReplicas, setFormReplicas] = useState(5)
|
||||
const [formImageTag, setFormImageTag] = useState("luxd-v1.23.11")
|
||||
const [formChainTracking, setFormChainTracking] = useState(true)
|
||||
|
||||
// Confirm dialogs
|
||||
const [confirmRestart, setConfirmRestart] = useState<string | null>(null)
|
||||
const [confirmDestroy, setConfirmDestroy] = useState<string | null>(null)
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||
|
||||
// Scale dropdown
|
||||
const [scaleTarget, setScaleTarget] = useState<string | null>(null)
|
||||
const [scaleValue, setScaleValue] = useState(5)
|
||||
|
||||
// ------- API helpers ------------------------------------------------------
|
||||
|
||||
async function apiFetch(path: string, opts?: RequestInit) {
|
||||
const res = await fetch(`${API_URL}${path}`, {
|
||||
...opts,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getAuthHeaders(),
|
||||
...(opts?.headers || {}),
|
||||
},
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ detail: res.statusText }))
|
||||
throw new Error(body.detail || `HTTP ${res.status}`)
|
||||
}
|
||||
if (res.status === 204) return null
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// ------- Fetch data -------------------------------------------------------
|
||||
|
||||
const fetchClusters = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiFetch("/v1/infra/clusters")
|
||||
const list: Cluster[] = Array.isArray(data) ? data : []
|
||||
setClusters(list)
|
||||
if (list.length > 0 && !selectedCluster) {
|
||||
setSelectedCluster(list[0].id)
|
||||
}
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}, [selectedCluster])
|
||||
|
||||
const fetchFleets = useCallback(
|
||||
async (silent = false) => {
|
||||
if (!selectedCluster) return
|
||||
if (!silent) setLoading(true)
|
||||
try {
|
||||
const data = await apiFetch(`/v1/lux/fleets?cluster_id=${selectedCluster}`)
|
||||
setFleets(Array.isArray(data) ? data : [])
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
},
|
||||
[selectedCluster],
|
||||
)
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiFetch("/v1/lux/stats")
|
||||
setStats(data)
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchNetworks = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiFetch("/v1/lux/networks")
|
||||
if (Array.isArray(data)) setNetworks(data)
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchFleetDetail = useCallback(
|
||||
async (network: NetworkType) => {
|
||||
if (!selectedCluster) return
|
||||
try {
|
||||
const data: LuxFleetResponse = await apiFetch(
|
||||
`/v1/lux/fleets/${selectedCluster}/${network}`,
|
||||
)
|
||||
setFleetDetails((prev) => ({ ...prev, [`${selectedCluster}/${network}`]: data }))
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
},
|
||||
[selectedCluster],
|
||||
)
|
||||
|
||||
// ------- Initial load & polling -------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
fetchClusters()
|
||||
fetchStats()
|
||||
fetchNetworks()
|
||||
}, [fetchClusters, fetchStats, fetchNetworks])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCluster) {
|
||||
fetchFleets()
|
||||
}
|
||||
}, [selectedCluster, fetchFleets])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCluster) return
|
||||
const interval = setInterval(() => {
|
||||
fetchFleets(true)
|
||||
fetchStats()
|
||||
}, 15000)
|
||||
return () => clearInterval(interval)
|
||||
}, [selectedCluster, fetchFleets, fetchStats])
|
||||
|
||||
// Fetch node details when a fleet is expanded
|
||||
useEffect(() => {
|
||||
if (expandedFleet && selectedCluster) {
|
||||
const network = expandedFleet as NetworkType
|
||||
fetchFleetDetail(network)
|
||||
}
|
||||
}, [expandedFleet, selectedCluster, fetchFleetDetail])
|
||||
|
||||
// ------- Fleet operations -------------------------------------------------
|
||||
|
||||
async function handleCreate() {
|
||||
if (!formName.trim() || !selectedCluster) return
|
||||
setCreating(true)
|
||||
setCreateError(null)
|
||||
try {
|
||||
await apiFetch("/v1/lux/fleets", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: formName.trim(),
|
||||
cluster_id: selectedCluster,
|
||||
network: formNetwork,
|
||||
replicas: formReplicas,
|
||||
image_tag: formImageTag.trim(),
|
||||
chain_tracking: formChainTracking,
|
||||
}),
|
||||
})
|
||||
setCreateOpen(false)
|
||||
resetForm()
|
||||
fetchFleets()
|
||||
fetchStats()
|
||||
} catch (e) {
|
||||
setCreateError(String(e))
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setFormName("")
|
||||
setFormNetwork("testnet")
|
||||
setFormReplicas(5)
|
||||
setFormImageTag("luxd-v1.23.11")
|
||||
setFormChainTracking(true)
|
||||
setCreateError(null)
|
||||
}
|
||||
|
||||
async function handleScale(network: NetworkType, replicas: number) {
|
||||
setActionLoading(network)
|
||||
try {
|
||||
await apiFetch(
|
||||
`/v1/lux/fleets/${selectedCluster}/${network}/scale?replicas=${replicas}`,
|
||||
{ method: "POST" },
|
||||
)
|
||||
setScaleTarget(null)
|
||||
fetchFleets()
|
||||
} catch (e) {
|
||||
console.error("Scale failed:", e)
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRestart(network: NetworkType) {
|
||||
setActionLoading(network)
|
||||
try {
|
||||
await apiFetch(`/v1/lux/fleets/${selectedCluster}/${network}/restart`, {
|
||||
method: "POST",
|
||||
})
|
||||
setConfirmRestart(null)
|
||||
fetchFleets()
|
||||
} catch (e) {
|
||||
console.error("Restart failed:", e)
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDestroy(network: NetworkType) {
|
||||
setActionLoading(network)
|
||||
try {
|
||||
await apiFetch(`/v1/lux/fleets/${selectedCluster}/${network}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
setConfirmDestroy(null)
|
||||
setFleets((prev) => prev.filter((f) => f.network !== network))
|
||||
fetchStats()
|
||||
} catch (e) {
|
||||
console.error("Destroy failed:", e)
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
// ------- Render -----------------------------------------------------------
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Lux Fleet Dashboard</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Deploy and manage luxd validator fleets across Kubernetes clusters
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Cluster selector */}
|
||||
<Select
|
||||
value={selectedCluster}
|
||||
onValueChange={setSelectedCluster}
|
||||
className="w-[220px]"
|
||||
>
|
||||
{clusters.length === 0 ? (
|
||||
<option value="" disabled>
|
||||
No clusters
|
||||
</option>
|
||||
) : (
|
||||
clusters.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name} ({c.region})
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setRefreshing(true)
|
||||
fetchFleets()
|
||||
fetchStats()
|
||||
}}
|
||||
disabled={refreshing}
|
||||
>
|
||||
<RefreshCcw className={`mr-2 h-4 w-4 ${refreshing ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
|
||||
{/* Create fleet */}
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Fleet
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Lux Validator Fleet</DialogTitle>
|
||||
<DialogDescription>
|
||||
Deploy a fleet of luxd validators to the selected cluster
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Fleet Name</label>
|
||||
<Input
|
||||
placeholder="e.g., lux-mainnet-validators"
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 grid-cols-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Network</label>
|
||||
<Select
|
||||
value={formNetwork}
|
||||
onValueChange={(v) => setFormNetwork(v as NetworkType)}
|
||||
className="mt-1"
|
||||
>
|
||||
<option value="mainnet">Mainnet</option>
|
||||
<option value="testnet">Testnet</option>
|
||||
<option value="devnet">Devnet</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Replicas</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={formReplicas}
|
||||
onChange={(e) => setFormReplicas(parseInt(e.target.value) || 5)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Image Tag</label>
|
||||
<Input
|
||||
placeholder="luxd-v1.23.11"
|
||||
value={formImageTag}
|
||||
onChange={(e) => setFormImageTag(e.target.value)}
|
||||
className="mt-1 font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Chain Tracking</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Auto-configure C/P/X chain tracking for {formNetwork}
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={formChainTracking} onCheckedChange={setFormChainTracking} />
|
||||
</div>
|
||||
{createError && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-destructive text-sm">
|
||||
{createError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setCreateOpen(false)
|
||||
resetForm()
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={creating || !formName.trim() || !selectedCluster}
|
||||
>
|
||||
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Create Fleet
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats banner */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Fleets</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-2xl font-bold">
|
||||
{stats ? stats.total_fleets : <Loader2 className="h-5 w-5 animate-spin" />}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">across all clusters</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Nodes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-2xl font-bold">
|
||||
{stats ? stats.total_nodes : <Loader2 className="h-5 w-5 animate-spin" />}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">validator pods running</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Healthy Nodes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<Heart className="h-5 w-5 text-green-500" />
|
||||
<span className="text-2xl font-bold text-green-500">
|
||||
{stats ? stats.healthy_nodes : <Loader2 className="h-5 w-5 animate-spin" />}
|
||||
</span>
|
||||
{stats && stats.total_nodes > 0 && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
/ {stats.total_nodes} ({Math.round((stats.healthy_nodes / stats.total_nodes) * 100)}%)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">passing health checks</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Fleet cards per network */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center p-16">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="rounded-md bg-destructive/10 p-4 text-destructive">{error}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : fleets.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center text-muted-foreground">
|
||||
No fleets deployed on this cluster. Click "Create Fleet" to get started.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{fleets.map((fleet) => {
|
||||
const key = `${fleet.cluster_id}/${fleet.network}`
|
||||
const detail = fleetDetails[key]
|
||||
const isExpanded = expandedFleet === fleet.network
|
||||
const isActionLoading = actionLoading === fleet.network
|
||||
|
||||
return (
|
||||
<Card key={key} className="border-zinc-800">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() =>
|
||||
setExpandedFleet(isExpanded ? null : fleet.network)
|
||||
}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{fleet.name}
|
||||
<Badge variant="outline" className={NETWORK_COLORS[fleet.network]}>
|
||||
{fleet.network}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription className="font-mono text-xs mt-1">
|
||||
{fleet.image_tag}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{statusBadge(fleet.status)}
|
||||
<div className="text-sm text-right">
|
||||
<span className="font-bold">{fleet.ready_replicas}</span>
|
||||
<span className="text-muted-foreground">/{fleet.replicas} ready</span>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Helm rev {fleet.helm_revision}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{/* Fleet operations */}
|
||||
<CardContent className="pt-0 pb-4">
|
||||
<div className="flex items-center gap-2 border-t border-zinc-800 pt-4">
|
||||
{/* Scale */}
|
||||
{scaleTarget === fleet.network ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={String(scaleValue)}
|
||||
onValueChange={(v) => setScaleValue(parseInt(v))}
|
||||
className="w-[80px]"
|
||||
>
|
||||
{Array.from({ length: 20 }, (_, i) => i + 1).map((n) => (
|
||||
<option key={n} value={String(n)}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleScale(fleet.network, scaleValue)}
|
||||
disabled={isActionLoading}
|
||||
>
|
||||
{isActionLoading ? (
|
||||
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Scale className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
Apply
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setScaleTarget(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setScaleValue(fleet.replicas)
|
||||
setScaleTarget(fleet.network)
|
||||
}}
|
||||
>
|
||||
<Scale className="mr-2 h-3 w-3" />
|
||||
Scale
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Restart */}
|
||||
{confirmRestart === fleet.network ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-yellow-500">Restart all pods?</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleRestart(fleet.network)}
|
||||
disabled={isActionLoading}
|
||||
>
|
||||
{isActionLoading ? (
|
||||
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
Confirm
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setConfirmRestart(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setConfirmRestart(fleet.network)}
|
||||
>
|
||||
<RotateCcw className="mr-2 h-3 w-3" />
|
||||
Restart
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Destroy */}
|
||||
{confirmDestroy === fleet.network ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-red-500">
|
||||
Permanently destroy this fleet?
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleDestroy(fleet.network)}
|
||||
disabled={isActionLoading}
|
||||
>
|
||||
{isActionLoading ? (
|
||||
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
Destroy
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setConfirmDestroy(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-red-400 hover:text-red-300 border-red-500/30 hover:border-red-500/50"
|
||||
onClick={() => setConfirmDestroy(fleet.network)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-3 w-3" />
|
||||
Destroy
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
{/* Expanded: Node list table */}
|
||||
{isExpanded && (
|
||||
<CardContent className="pt-0">
|
||||
<div className="border-t border-zinc-800 pt-4">
|
||||
<p className="text-sm font-medium text-muted-foreground mb-3">
|
||||
Nodes ({detail?.nodes?.length ?? "..."})
|
||||
</p>
|
||||
{!detail ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : detail.nodes.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center p-6">
|
||||
No nodes reported. Fleet may still be deploying.
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Pod Name</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>External IP</TableHead>
|
||||
<TableHead className="text-right">C-Chain Height</TableHead>
|
||||
<TableHead className="text-right">Peers</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{detail.nodes.map((node) => (
|
||||
<TableRow key={node.pod_name}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Circle
|
||||
className={`h-2 w-2 fill-current ${
|
||||
node.healthy === true && node.ready
|
||||
? "text-green-500"
|
||||
: node.healthy === false
|
||||
? "text-red-500"
|
||||
: node.status === "Pending" ||
|
||||
node.status === "ContainerCreating"
|
||||
? "text-yellow-500"
|
||||
: "text-zinc-500"
|
||||
}`}
|
||||
/>
|
||||
<span className="font-mono text-xs">{node.pod_name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={`text-sm ${
|
||||
node.ready ? "text-green-400" : "text-yellow-400"
|
||||
}`}
|
||||
>
|
||||
{node.status}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{node.external_ip || "--"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">
|
||||
{node.c_chain_height != null
|
||||
? node.c_chain_height.toLocaleString()
|
||||
: "--"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">
|
||||
{node.peers != null ? node.peers : "--"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Restart confirmation dialog */}
|
||||
<Dialog
|
||||
open={confirmRestart !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setConfirmRestart(null)
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirm Restart</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will perform a rolling restart of all pods in the{" "}
|
||||
<span className="font-mono font-bold">{confirmRestart}</span> fleet. Nodes will be
|
||||
temporarily unavailable during restart.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirmRestart(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => confirmRestart && handleRestart(confirmRestart as NetworkType)}
|
||||
disabled={actionLoading !== null}
|
||||
>
|
||||
{actionLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Restart Fleet
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Destroy confirmation dialog */}
|
||||
<Dialog
|
||||
open={confirmDestroy !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setConfirmDestroy(null)
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirm Destroy</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will permanently delete the{" "}
|
||||
<span className="font-mono font-bold">{confirmDestroy}</span> fleet and all its
|
||||
nodes. This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirmDestroy(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => confirmDestroy && handleDestroy(confirmDestroy as NetworkType)}
|
||||
disabled={actionLoading !== null}
|
||||
>
|
||||
{actionLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Destroy Fleet
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* API reference */}
|
||||
<Card className="border-zinc-800">
|
||||
<CardHeader>
|
||||
<CardTitle>Fleet APIs</CardTitle>
|
||||
<CardDescription>Lux fleet management endpoints</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ method: "GET", path: "/v1/lux/fleets?cluster_id=xxx", desc: "List fleets" },
|
||||
{ method: "GET", path: "/v1/lux/fleets/{cluster_id}/{network}", desc: "Fleet detail with nodes" },
|
||||
{ method: "POST", path: "/v1/lux/fleets", desc: "Create fleet" },
|
||||
{ method: "PATCH", path: "/v1/lux/fleets/{cluster_id}/{network}", desc: "Update fleet config" },
|
||||
{ method: "DELETE", path: "/v1/lux/fleets/{cluster_id}/{network}", desc: "Destroy fleet" },
|
||||
{ method: "POST", path: "/v1/lux/fleets/{cluster_id}/{network}/scale?replicas=N", desc: "Scale replicas" },
|
||||
{ method: "POST", path: "/v1/lux/fleets/{cluster_id}/{network}/restart", desc: "Rolling restart" },
|
||||
{ method: "GET", path: "/v1/lux/stats", desc: "Fleet statistics" },
|
||||
{ method: "GET", path: "/v1/lux/networks", desc: "Network configs" },
|
||||
].map((ep) => (
|
||||
<div
|
||||
key={ep.method + ep.path}
|
||||
className="flex items-center gap-3 p-2.5 border border-zinc-800 rounded-lg"
|
||||
>
|
||||
<Badge
|
||||
variant="default"
|
||||
className={`font-mono text-xs w-16 justify-center ${
|
||||
ep.method === "POST"
|
||||
? "bg-blue-500/20 text-blue-400 border-blue-500/30"
|
||||
: ep.method === "DELETE"
|
||||
? "bg-red-500/20 text-red-400 border-red-500/30"
|
||||
: ep.method === "PATCH"
|
||||
? "bg-yellow-500/20 text-yellow-400 border-yellow-500/30"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{ep.method}
|
||||
</Badge>
|
||||
<code className="text-xs font-mono flex-1 text-muted-foreground">{ep.path}</code>
|
||||
<span className="text-xs text-muted-foreground">{ep.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Server, Box, HardDrive, Copy, Loader2 } from "lucide-react"
|
||||
import { Server, Box, HardDrive, Copy, Loader2, Shield } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
interface InfraStats {
|
||||
@@ -109,6 +109,29 @@ export default function InfrastructurePage() {
|
||||
|
||||
{/* Infrastructure Categories */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Lux Validators
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Deploy and manage Lux validator fleets
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Run fleets of luxd validators across Kubernetes clusters.
|
||||
Mainnet, testnet, and devnet support with chain tracking.
|
||||
</p>
|
||||
<Button variant="outline" className="w-full" asChild>
|
||||
<Link href="/dashboard/infrastructure/lux">
|
||||
Manage Fleets
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,553 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { WORLD_PATHS, generateGridLines, latLngToSVG } from "./world-paths"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FleetRegion {
|
||||
slug: string
|
||||
name: string
|
||||
lat: number
|
||||
lon: number
|
||||
active: boolean
|
||||
nodeCount: number
|
||||
networks: string[]
|
||||
healthyNodes: number
|
||||
}
|
||||
|
||||
export interface FleetMapProps {
|
||||
regions: FleetRegion[]
|
||||
onRegionClick?: (slug: string) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SVG_W = 960
|
||||
const SVG_H = 500
|
||||
|
||||
const COLOR_ACTIVE = "#0066FF"
|
||||
const COLOR_ACTIVE_GLOW = "#0066FF"
|
||||
const COLOR_INACTIVE = "#333333"
|
||||
const COLOR_BG = "#0a0a0a"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Mercator projection: lat/lon -> SVG x/y using the shared world-paths helper */
|
||||
function toSvg(lat: number, lon: number) {
|
||||
return latLngToSVG(lat, lon)
|
||||
}
|
||||
|
||||
/** Build P2P mesh connection lines between all active regions */
|
||||
function buildConnections(regions: FleetRegion[]) {
|
||||
const active = regions.filter((r) => r.active)
|
||||
const lines: { x1: number; y1: number; x2: number; y2: number; key: string }[] = []
|
||||
|
||||
for (let i = 0; i < active.length; i++) {
|
||||
for (let j = i + 1; j < active.length; j++) {
|
||||
const a = toSvg(active[i].lat, active[i].lon)
|
||||
const b = toSvg(active[j].lat, active[j].lon)
|
||||
lines.push({
|
||||
x1: a.x, y1: a.y,
|
||||
x2: b.x, y2: b.y,
|
||||
key: `${active[i].slug}-${active[j].slug}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
/** Health status derived from nodeCount vs healthyNodes */
|
||||
function healthStatus(r: FleetRegion): "healthy" | "degraded" | "partial" | "inactive" {
|
||||
if (!r.active) return "inactive"
|
||||
if (r.nodeCount === 0) return "inactive"
|
||||
if (r.healthyNodes === r.nodeCount) return "healthy"
|
||||
if (r.healthyNodes === 0) return "degraded"
|
||||
return "partial"
|
||||
}
|
||||
|
||||
function healthLabel(r: FleetRegion): string {
|
||||
const s = healthStatus(r)
|
||||
switch (s) {
|
||||
case "healthy": return "Healthy"
|
||||
case "degraded": return "Degraded"
|
||||
case "partial": return "Partial"
|
||||
case "inactive": return "Inactive"
|
||||
}
|
||||
}
|
||||
|
||||
function healthColor(r: FleetRegion): string {
|
||||
const s = healthStatus(r)
|
||||
switch (s) {
|
||||
case "healthy": return "#22c55e"
|
||||
case "degraded": return "#ef4444"
|
||||
case "partial": return "#eab308"
|
||||
case "inactive": return COLOR_INACTIVE
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSS Animations (injected once)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FLEET_STYLES = `
|
||||
@keyframes fleet-pulse {
|
||||
0% { r: 4; opacity: 0.8; }
|
||||
70% { r: 16; opacity: 0; }
|
||||
100% { r: 16; opacity: 0; }
|
||||
}
|
||||
@keyframes fleet-pulse-2 {
|
||||
0% { r: 4; opacity: 0.5; }
|
||||
70% { r: 22; opacity: 0; }
|
||||
100% { r: 22; opacity: 0; }
|
||||
}
|
||||
@keyframes fleet-dash {
|
||||
to { stroke-dashoffset: -20; }
|
||||
}
|
||||
@keyframes fleet-fadein {
|
||||
from { opacity: 0; transform: scale(0.6); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
@keyframes fleet-glow-breathe {
|
||||
0% { opacity: 0.6; }
|
||||
50% { opacity: 1; }
|
||||
100% { opacity: 0.6; }
|
||||
}
|
||||
.fleet-pulse-ring {
|
||||
animation: fleet-pulse 2.4s ease-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
.fleet-pulse-ring-2 {
|
||||
animation: fleet-pulse-2 2.4s ease-out infinite;
|
||||
animation-delay: 0.8s;
|
||||
pointer-events: none;
|
||||
}
|
||||
.fleet-mesh-line {
|
||||
stroke-dasharray: 6 4;
|
||||
animation: fleet-dash 1.5s linear infinite;
|
||||
}
|
||||
.fleet-node-enter {
|
||||
animation: fleet-fadein 0.35s ease-out;
|
||||
cursor: pointer;
|
||||
}
|
||||
.fleet-tooltip-enter {
|
||||
pointer-events: none;
|
||||
animation: fleet-fadein 0.12s ease-out;
|
||||
}
|
||||
.fleet-breathe {
|
||||
animation: fleet-glow-breathe 3s ease-in-out infinite;
|
||||
}
|
||||
`
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function FleetMap({ regions, onRegionClick, className }: FleetMapProps) {
|
||||
const [hovered, setHovered] = useState<string | null>(null)
|
||||
const [zoomTarget, setZoomTarget] = useState<{ x: number; y: number; slug: string } | null>(null)
|
||||
const stylesRef = useRef(false)
|
||||
|
||||
// Inject keyframe CSS once
|
||||
useEffect(() => {
|
||||
if (stylesRef.current) return
|
||||
stylesRef.current = true
|
||||
const el = document.createElement("style")
|
||||
el.setAttribute("data-fleet-map", "")
|
||||
el.textContent = FLEET_STYLES
|
||||
document.head.appendChild(el)
|
||||
return () => {
|
||||
el.remove()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Click: zoom to region. Click same region or double-click: zoom out.
|
||||
const handleRegionClick = useCallback(
|
||||
(slug: string, x: number, y: number) => {
|
||||
if (zoomTarget?.slug === slug) {
|
||||
// Already zoomed here -- zoom out
|
||||
setZoomTarget(null)
|
||||
} else {
|
||||
setZoomTarget({ x, y, slug })
|
||||
}
|
||||
onRegionClick?.(slug)
|
||||
},
|
||||
[zoomTarget, onRegionClick],
|
||||
)
|
||||
|
||||
const handleDoubleClick = useCallback(() => {
|
||||
setZoomTarget(null)
|
||||
}, [])
|
||||
|
||||
// Derived data
|
||||
const connections = buildConnections(regions)
|
||||
const gridLines = generateGridLines(SVG_W, SVG_H)
|
||||
|
||||
// Stats
|
||||
const totalNodes = regions.reduce((s, r) => s + r.nodeCount, 0)
|
||||
const activeCount = regions.filter((r) => r.active).length
|
||||
const totalHealthy = regions.reduce((s, r) => s + r.healthyNodes, 0)
|
||||
const healthPct = totalNodes > 0 ? Math.round((totalHealthy / totalNodes) * 100) : 0
|
||||
|
||||
// Zoom transform
|
||||
const zoomScale = 2.2
|
||||
const zoomTransform = zoomTarget
|
||||
? `translate(${SVG_W / 2 - zoomTarget.x * zoomScale}px, ${SVG_H / 2 - zoomTarget.y * zoomScale}px) scale(${zoomScale})`
|
||||
: "translate(0px, 0px) scale(1)"
|
||||
|
||||
// Hovered region info
|
||||
const hoveredRegion = hovered ? regions.find((r) => r.slug === hovered) : null
|
||||
const hoveredPt = hoveredRegion ? toSvg(hoveredRegion.lat, hoveredRegion.lon) : null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full overflow-hidden rounded-xl border border-white/[0.06]",
|
||||
className,
|
||||
)}
|
||||
style={{ background: COLOR_BG }}
|
||||
>
|
||||
{/* ---- Stats overlay ---- */}
|
||||
<div
|
||||
className="absolute top-4 right-4 z-10 flex flex-col gap-1.5 rounded-lg px-4 py-3"
|
||||
style={{
|
||||
background: "rgba(10,10,10,0.88)",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
backdropFilter: "blur(8px)",
|
||||
}}
|
||||
>
|
||||
<div className="text-[10px] uppercase tracking-[0.15em] text-gray-500 font-medium">
|
||||
Lux Fleet Network
|
||||
</div>
|
||||
<div className="flex gap-6">
|
||||
<StatBadge label="Nodes" value={totalNodes} />
|
||||
<StatBadge label="Regions" value={`${activeCount}/${regions.length}`} />
|
||||
<StatBadge
|
||||
label="Health"
|
||||
value={`${healthPct}%`}
|
||||
color={healthPct >= 80 ? "#22c55e" : healthPct >= 50 ? "#eab308" : "#ef4444"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ---- Legend ---- */}
|
||||
<div
|
||||
className="absolute bottom-4 left-4 z-10 flex gap-4 rounded-lg px-3 py-2"
|
||||
style={{
|
||||
background: "rgba(10,10,10,0.88)",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
backdropFilter: "blur(8px)",
|
||||
}}
|
||||
>
|
||||
<LegendDot color={COLOR_ACTIVE} label="Active" />
|
||||
<LegendDot color={COLOR_INACTIVE} label="Inactive" />
|
||||
</div>
|
||||
|
||||
{/* ---- SVG Map ---- */}
|
||||
<svg
|
||||
viewBox={`0 0 ${SVG_W} ${SVG_H}`}
|
||||
className="w-full h-auto select-none"
|
||||
style={{ display: "block" }}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
<defs>
|
||||
{/* Glow filter for active dots */}
|
||||
<filter id="fm-glow" x="-100%" y="-100%" width="300%" height="300%">
|
||||
<feGaussianBlur stdDeviation="4" result="blur" />
|
||||
<feColorMatrix
|
||||
in="blur"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0
|
||||
0 0.4 0 0 0
|
||||
0 0 1 0 0
|
||||
0 0 0 0.7 0"
|
||||
result="blueglow"
|
||||
/>
|
||||
<feMerge>
|
||||
<feMergeNode in="blueglow" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
{/* Subtle glow for mesh lines */}
|
||||
<filter id="fm-line-glow" x="-10%" y="-10%" width="120%" height="120%">
|
||||
<feGaussianBlur stdDeviation="1.2" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
{/* Radial gradient for active dot centers */}
|
||||
<radialGradient id="fm-dot-gradient" cx="40%" cy="40%">
|
||||
<stop offset="0%" stopColor="#4d9fff" />
|
||||
<stop offset="100%" stopColor={COLOR_ACTIVE} />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
{/* Zoomable group */}
|
||||
<g
|
||||
style={{
|
||||
transform: zoomTransform,
|
||||
transformOrigin: "center center",
|
||||
transition: "transform 0.7s cubic-bezier(0.25, 0.1, 0.25, 1)",
|
||||
}}
|
||||
>
|
||||
{/* Grid lines */}
|
||||
{gridLines.map((l, i) => (
|
||||
<line
|
||||
key={`g-${i}`}
|
||||
x1={l.x1} y1={l.y1} x2={l.x2} y2={l.y2}
|
||||
stroke="rgba(255,255,255,0.025)"
|
||||
strokeWidth={0.5}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Continent outlines */}
|
||||
{WORLD_PATHS.map((c) => (
|
||||
<path
|
||||
key={c.id}
|
||||
d={c.d}
|
||||
fill="#111111"
|
||||
stroke="rgba(255,255,255,0.07)"
|
||||
strokeWidth={0.6}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* P2P mesh connection lines */}
|
||||
{connections.map((c) => (
|
||||
<line
|
||||
key={c.key}
|
||||
x1={c.x1} y1={c.y1} x2={c.x2} y2={c.y2}
|
||||
stroke={`${COLOR_ACTIVE}26`}
|
||||
strokeWidth={0.7}
|
||||
className="fleet-mesh-line"
|
||||
filter="url(#fm-line-glow)"
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Region markers */}
|
||||
{regions.map((r) => {
|
||||
const pt = toSvg(r.lat, r.lon)
|
||||
const isActive = r.active
|
||||
const isHovered = hovered === r.slug
|
||||
const dotColor = isActive ? COLOR_ACTIVE : COLOR_INACTIVE
|
||||
const dotR = isActive ? (isHovered ? 6.5 : 5) : (isHovered ? 4 : 3)
|
||||
|
||||
return (
|
||||
<g
|
||||
key={r.slug}
|
||||
className="fleet-node-enter"
|
||||
onClick={() => handleRegionClick(r.slug, pt.x, pt.y)}
|
||||
onMouseEnter={() => setHovered(r.slug)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
>
|
||||
{/* Outer pulse ring 1 */}
|
||||
{isActive && (
|
||||
<circle
|
||||
cx={pt.x} cy={pt.y} r={4}
|
||||
fill="none"
|
||||
stroke={COLOR_ACTIVE_GLOW}
|
||||
strokeWidth={1.5}
|
||||
className="fleet-pulse-ring"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Outer pulse ring 2 (offset) */}
|
||||
{isActive && (
|
||||
<circle
|
||||
cx={pt.x} cy={pt.y} r={4}
|
||||
fill="none"
|
||||
stroke={COLOR_ACTIVE_GLOW}
|
||||
strokeWidth={0.8}
|
||||
className="fleet-pulse-ring-2"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Ambient glow behind active dots */}
|
||||
{isActive && (
|
||||
<circle
|
||||
cx={pt.x} cy={pt.y} r={10}
|
||||
fill={`${COLOR_ACTIVE}12`}
|
||||
className="fleet-breathe"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Core dot */}
|
||||
<circle
|
||||
cx={pt.x} cy={pt.y}
|
||||
r={dotR}
|
||||
fill={isActive ? "url(#fm-dot-gradient)" : dotColor}
|
||||
filter={isActive ? "url(#fm-glow)" : undefined}
|
||||
style={{ transition: "r 0.2s ease" }}
|
||||
/>
|
||||
|
||||
{/* Inner highlight */}
|
||||
{isActive && (
|
||||
<circle
|
||||
cx={pt.x - 1} cy={pt.y - 1}
|
||||
r={1.5}
|
||||
fill="rgba(255,255,255,0.3)"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Node count badge */}
|
||||
{isActive && r.nodeCount > 0 && (
|
||||
<>
|
||||
<rect
|
||||
x={pt.x + 7}
|
||||
y={pt.y - 15}
|
||||
width={r.nodeCount >= 100 ? 28 : r.nodeCount >= 10 ? 22 : 16}
|
||||
height={14}
|
||||
rx={3}
|
||||
fill="rgba(0,0,0,0.85)"
|
||||
stroke={`${COLOR_ACTIVE}66`}
|
||||
strokeWidth={0.6}
|
||||
/>
|
||||
<text
|
||||
x={pt.x + 7 + (r.nodeCount >= 100 ? 14 : r.nodeCount >= 10 ? 11 : 8)}
|
||||
y={pt.y - 5.5}
|
||||
fill="#e5e7eb"
|
||||
fontSize={8.5}
|
||||
fontFamily="var(--font-mono, ui-monospace, monospace)"
|
||||
fontWeight={600}
|
||||
textAnchor="middle"
|
||||
>
|
||||
{r.nodeCount}
|
||||
</text>
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Tooltip */}
|
||||
{hoveredRegion && hoveredPt && (() => {
|
||||
const tooltipW = 190
|
||||
const tooltipH = hoveredRegion.active ? 90 : 36
|
||||
// Flip tooltip left if too close to right edge
|
||||
const flipX = hoveredPt.x + tooltipW + 20 > SVG_W
|
||||
const tx = flipX ? hoveredPt.x - tooltipW - 12 : hoveredPt.x + 14
|
||||
// Flip tooltip up if too close to bottom
|
||||
const flipY = hoveredPt.y + tooltipH > SVG_H - 20
|
||||
const ty = flipY ? hoveredPt.y - tooltipH - 5 : hoveredPt.y - 50
|
||||
|
||||
return (
|
||||
<g className="fleet-tooltip-enter">
|
||||
{/* Tooltip background */}
|
||||
<rect
|
||||
x={tx}
|
||||
y={ty}
|
||||
width={tooltipW}
|
||||
height={tooltipH}
|
||||
rx={6}
|
||||
fill="rgba(8,8,8,0.95)"
|
||||
stroke="rgba(255,255,255,0.1)"
|
||||
strokeWidth={0.8}
|
||||
/>
|
||||
|
||||
{/* Region name */}
|
||||
<text
|
||||
x={tx + 12} y={ty + 16}
|
||||
fill="#f3f4f6" fontSize={10} fontWeight={600}
|
||||
>
|
||||
{hoveredRegion.name}
|
||||
</text>
|
||||
|
||||
{/* Slug */}
|
||||
<text
|
||||
x={tx + 12} y={ty + 28}
|
||||
fill="#6b7280" fontSize={8}
|
||||
fontFamily="var(--font-mono, ui-monospace, monospace)"
|
||||
>
|
||||
{hoveredRegion.slug}
|
||||
</text>
|
||||
|
||||
{hoveredRegion.active && (
|
||||
<>
|
||||
{/* Node count */}
|
||||
<text x={tx + 12} y={ty + 44} fill="#d1d5db" fontSize={8.5}>
|
||||
Nodes: {hoveredRegion.nodeCount}
|
||||
{" | "}
|
||||
Healthy: {hoveredRegion.healthyNodes}
|
||||
</text>
|
||||
|
||||
{/* Networks */}
|
||||
<text x={tx + 12} y={ty + 58} fill="#9ca3af" fontSize={8}>
|
||||
Networks: {hoveredRegion.networks.join(", ") || "none"}
|
||||
</text>
|
||||
|
||||
{/* Health status */}
|
||||
<text
|
||||
x={tx + 12} y={ty + 74}
|
||||
fill={healthColor(hoveredRegion)}
|
||||
fontSize={9} fontWeight={600}
|
||||
>
|
||||
{healthLabel(hoveredRegion).toUpperCase()}
|
||||
{" "}
|
||||
{hoveredRegion.nodeCount > 0
|
||||
? `${Math.round((hoveredRegion.healthyNodes / hoveredRegion.nodeCount) * 100)}%`
|
||||
: ""}
|
||||
</text>
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})()}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StatBadge({
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
}: {
|
||||
label: string
|
||||
value: string | number
|
||||
color?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<span
|
||||
className="text-lg font-bold font-mono tabular-nums"
|
||||
style={{ color: color ?? "#e5e7eb" }}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
<span className="text-[9px] uppercase tracking-wider text-gray-500">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LegendDot({ color, label }: { color: string; label: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{
|
||||
background: color,
|
||||
boxShadow: color !== COLOR_INACTIVE ? `0 0 6px ${color}` : "none",
|
||||
}}
|
||||
/>
|
||||
<span className="text-[9px] text-gray-400">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FleetMap
|
||||
@@ -0,0 +1,72 @@
|
||||
// DOKS (DigitalOcean Kubernetes Service) region coordinates and types
|
||||
// Used by fleet-map.tsx to position validator nodes on the world map
|
||||
|
||||
export interface DOKSRegion {
|
||||
name: string
|
||||
lat: number
|
||||
lng: number
|
||||
country: string
|
||||
}
|
||||
|
||||
export const DOKS_REGIONS: Record<string, DOKSRegion> = {
|
||||
nyc1: { name: "New York 1", lat: 40.7128, lng: -74.006, country: "US" },
|
||||
nyc2: { name: "New York 2", lat: 40.7128, lng: -74.006, country: "US" },
|
||||
nyc3: { name: "New York 3", lat: 40.7128, lng: -74.006, country: "US" },
|
||||
sfo2: { name: "San Francisco 2", lat: 37.7749, lng: -122.4194, country: "US" },
|
||||
sfo3: { name: "San Francisco 3", lat: 37.7749, lng: -122.4194, country: "US" },
|
||||
lon1: { name: "London 1", lat: 51.5074, lng: -0.1278, country: "GB" },
|
||||
ams3: { name: "Amsterdam 3", lat: 52.3676, lng: 4.9041, country: "NL" },
|
||||
fra1: { name: "Frankfurt 1", lat: 50.1109, lng: 8.6821, country: "DE" },
|
||||
tor1: { name: "Toronto 1", lat: 43.6532, lng: -79.3832, country: "CA" },
|
||||
sgp1: { name: "Singapore 1", lat: 1.3521, lng: 103.8198, country: "SG" },
|
||||
blr1: { name: "Bangalore 1", lat: 12.9716, lng: 77.5946, country: "IN" },
|
||||
syd1: { name: "Sydney 1", lat: -33.8688, lng: 151.2093, country: "AU" },
|
||||
atl1: { name: "Atlanta 1", lat: 33.749, lng: -84.388, country: "US" },
|
||||
}
|
||||
|
||||
export type NodeStatus = "healthy" | "syncing" | "degraded" | "available"
|
||||
|
||||
export interface FleetRegionInfo {
|
||||
regionId: string
|
||||
region: DOKSRegion
|
||||
status: NodeStatus
|
||||
fleetName?: string
|
||||
validatorCount: number
|
||||
blockHeight?: number
|
||||
healthPct?: number
|
||||
}
|
||||
|
||||
export interface LuxFleet {
|
||||
id: string
|
||||
name: string
|
||||
region: string
|
||||
status: "running" | "deploying" | "degraded" | "stopped"
|
||||
validators: number
|
||||
blockHeight?: number
|
||||
healthPct?: number
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
/** Map fleet status to node visual status */
|
||||
export function fleetStatusToNodeStatus(s: LuxFleet["status"]): NodeStatus {
|
||||
switch (s) {
|
||||
case "running":
|
||||
return "healthy"
|
||||
case "deploying":
|
||||
return "syncing"
|
||||
case "degraded":
|
||||
return "degraded"
|
||||
default:
|
||||
return "available"
|
||||
}
|
||||
}
|
||||
|
||||
/** Auth headers for API calls -- matches existing pattern in infrastructure/page.tsx */
|
||||
export function getAuthHeaders(): Record<string, string> {
|
||||
if (typeof window === "undefined") return {}
|
||||
const token = localStorage.getItem("bootnode_token")
|
||||
const apiKey = localStorage.getItem("bootnode_api_key")
|
||||
if (token) return { Authorization: `Bearer ${token}` }
|
||||
if (apiKey) return { "X-API-Key": apiKey }
|
||||
return {}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Simplified world continent outlines as SVG path data
|
||||
// Coordinates are in Mercator-projected SVG space (960x500 viewBox)
|
||||
// Derived from Natural Earth 110m simplified to ~20-40 vertices per continent
|
||||
// Each path is an array of [x, y] points that will be joined as an SVG polygon
|
||||
|
||||
export interface ContinentPath {
|
||||
id: string
|
||||
name: string
|
||||
d: string
|
||||
}
|
||||
|
||||
// Helper: convert lat/lng to Mercator SVG coords in a 960x500 viewBox
|
||||
function p(lat: number, lng: number): [number, number] {
|
||||
const x = (lng + 180) * (960 / 360)
|
||||
const latRad = (lat * Math.PI) / 180
|
||||
const mercN = Math.log(Math.tan(Math.PI / 4 + latRad / 2))
|
||||
const y = 250 - (960 * mercN) / (2 * Math.PI)
|
||||
return [Math.round(x * 10) / 10, Math.round(y * 10) / 10]
|
||||
}
|
||||
|
||||
function toPath(coords: [number, number][]): string {
|
||||
return (
|
||||
"M" +
|
||||
coords.map(([x, y]) => `${x},${y}`).join("L") +
|
||||
"Z"
|
||||
)
|
||||
}
|
||||
|
||||
// North America (simplified outline)
|
||||
const northAmerica = toPath([
|
||||
p(49, -125), p(60, -140), p(70, -140), p(72, -120),
|
||||
p(75, -95), p(72, -75), p(60, -65), p(50, -55),
|
||||
p(47, -60), p(45, -67), p(43, -70), p(40, -74),
|
||||
p(30, -82), p(25, -80), p(25, -90), p(20, -87),
|
||||
p(15, -88), p(15, -92), p(20, -105), p(23, -110),
|
||||
p(30, -115), p(32, -117), p(34, -120), p(38, -123),
|
||||
p(42, -124), p(46, -124),
|
||||
])
|
||||
|
||||
// South America
|
||||
const southAmerica = toPath([
|
||||
p(12, -72), p(10, -62), p(8, -60), p(5, -52),
|
||||
p(0, -50), p(-5, -35), p(-10, -37), p(-15, -39),
|
||||
p(-22, -41), p(-28, -49), p(-33, -52), p(-40, -62),
|
||||
p(-48, -66), p(-55, -68), p(-55, -70), p(-50, -75),
|
||||
p(-42, -73), p(-35, -72), p(-20, -70), p(-15, -76),
|
||||
p(-5, -80), p(0, -78), p(5, -77), p(10, -75),
|
||||
])
|
||||
|
||||
// Europe (including British Isles roughly)
|
||||
const europe = toPath([
|
||||
p(36, -10), p(38, -5), p(43, -9), p(44, -1),
|
||||
p(47, -2), p(49, 0), p(51, 2), p(54, -3),
|
||||
p(58, -5), p(60, 5), p(64, 10), p(70, 20),
|
||||
p(71, 28), p(70, 32), p(65, 30), p(60, 30),
|
||||
p(56, 24), p(55, 20), p(54, 14), p(51, 7),
|
||||
p(48, 8), p(46, 15), p(44, 12), p(42, 3),
|
||||
p(37, -2), p(36, -5),
|
||||
])
|
||||
|
||||
// Africa
|
||||
const africa = toPath([
|
||||
p(37, -10), p(37, 10), p(35, 12), p(32, 13),
|
||||
p(30, 33), p(22, 37), p(12, 44), p(10, 42),
|
||||
p(5, 42), p(0, 42), p(-5, 40), p(-12, 44),
|
||||
p(-20, 36), p(-26, 33), p(-34, 26), p(-35, 20),
|
||||
p(-30, 17), p(-22, 14), p(-15, 12), p(-5, 10),
|
||||
p(0, 10), p(5, 2), p(5, -5), p(7, -5),
|
||||
p(10, -15), p(15, -17), p(20, -17), p(25, -15),
|
||||
p(30, -10), p(35, -5),
|
||||
])
|
||||
|
||||
// Asia (simplified -- includes Middle East through East Asia)
|
||||
const asia = toPath([
|
||||
p(70, 32), p(72, 60), p(75, 80), p(73, 100),
|
||||
p(72, 120), p(70, 135), p(65, 140), p(60, 143),
|
||||
p(55, 135), p(50, 140), p(45, 142), p(40, 140),
|
||||
p(35, 140), p(35, 130), p(30, 120), p(22, 120),
|
||||
p(20, 110), p(10, 107), p(5, 105), p(1, 104),
|
||||
p(8, 80), p(12, 78), p(20, 73), p(25, 68),
|
||||
p(27, 56), p(30, 48), p(32, 36), p(35, 36),
|
||||
p(40, 44), p(42, 40), p(44, 38), p(50, 40),
|
||||
p(55, 38), p(55, 32), p(60, 30), p(65, 30),
|
||||
])
|
||||
|
||||
// Australia
|
||||
const australia = toPath([
|
||||
p(-12, 131), p(-12, 136), p(-15, 140), p(-20, 148),
|
||||
p(-28, 153), p(-33, 152), p(-37, 150), p(-39, 146),
|
||||
p(-38, 141), p(-35, 137), p(-32, 133), p(-35, 117),
|
||||
p(-33, 115), p(-28, 114), p(-22, 114), p(-17, 122),
|
||||
p(-14, 126), p(-12, 129),
|
||||
])
|
||||
|
||||
// Greenland
|
||||
const greenland = toPath([
|
||||
p(60, -45), p(65, -55), p(70, -55), p(76, -60),
|
||||
p(80, -50), p(82, -35), p(80, -20), p(76, -18),
|
||||
p(70, -22), p(65, -40), p(62, -43),
|
||||
])
|
||||
|
||||
export const WORLD_PATHS: ContinentPath[] = [
|
||||
{ id: "na", name: "North America", d: northAmerica },
|
||||
{ id: "sa", name: "South America", d: southAmerica },
|
||||
{ id: "eu", name: "Europe", d: europe },
|
||||
{ id: "af", name: "Africa", d: africa },
|
||||
{ id: "as", name: "Asia", d: asia },
|
||||
{ id: "oc", name: "Oceania", d: australia },
|
||||
{ id: "gl", name: "Greenland", d: greenland },
|
||||
]
|
||||
|
||||
// Grid lines for the map background (every 30 degrees)
|
||||
export function generateGridLines(
|
||||
width: number,
|
||||
height: number
|
||||
): { x1: number; y1: number; x2: number; y2: number }[] {
|
||||
const lines: { x1: number; y1: number; x2: number; y2: number }[] = []
|
||||
|
||||
// Longitude lines (vertical)
|
||||
for (let lng = -180; lng <= 180; lng += 30) {
|
||||
const x = (lng + 180) * (width / 360)
|
||||
lines.push({ x1: x, y1: 0, x2: x, y2: height })
|
||||
}
|
||||
|
||||
// Latitude lines (horizontal) -- Mercator spaced
|
||||
for (let lat = -60; lat <= 75; lat += 15) {
|
||||
const [, y] = p(lat, 0)
|
||||
lines.push({ x1: 0, y1: y, x2: width, y2: y })
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
/** Convert lat/lng to SVG coordinates in 960x500 viewBox */
|
||||
export function latLngToSVG(lat: number, lng: number): { x: number; y: number } {
|
||||
const [x, y] = p(lat, lng)
|
||||
return { x, y }
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// DigitalOcean Kubernetes regions and Lux network configuration
|
||||
// Used by the fleet management UI for cluster provisioning and monitoring.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DOKS Regions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DOKSRegion {
|
||||
slug: string;
|
||||
name: string;
|
||||
city: string;
|
||||
country: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export const DOKS_REGIONS: DOKSRegion[] = [
|
||||
{ slug: "nyc1", name: "New York 1", city: "New York", country: "US", lat: 40.7128, lon: -74.0060, available: true },
|
||||
{ slug: "nyc3", name: "New York 3", city: "New York", country: "US", lat: 40.7128, lon: -74.0060, available: true },
|
||||
{ slug: "sfo2", name: "San Francisco 2", city: "San Francisco", country: "US", lat: 37.7749, lon: -122.4194, available: true },
|
||||
{ slug: "sfo3", name: "San Francisco 3", city: "San Francisco", country: "US", lat: 37.7749, lon: -122.4194, available: true },
|
||||
{ slug: "lon1", name: "London 1", city: "London", country: "UK", lat: 51.5074, lon: -0.1278, available: true },
|
||||
{ slug: "ams3", name: "Amsterdam 3", city: "Amsterdam", country: "NL", lat: 52.3676, lon: 4.9041, available: true },
|
||||
{ slug: "fra1", name: "Frankfurt 1", city: "Frankfurt", country: "DE", lat: 50.1109, lon: 8.6821, available: true },
|
||||
{ slug: "tor1", name: "Toronto 1", city: "Toronto", country: "CA", lat: 43.6532, lon: -79.3832, available: true },
|
||||
{ slug: "sgp1", name: "Singapore 1", city: "Singapore", country: "SG", lat: 1.3521, lon: 103.8198, available: true },
|
||||
{ slug: "blr1", name: "Bangalore 1", city: "Bangalore", country: "IN", lat: 12.9716, lon: 77.5946, available: true },
|
||||
{ slug: "syd1", name: "Sydney 1", city: "Sydney", country: "AU", lat: -33.8688, lon: 151.2093, available: true },
|
||||
];
|
||||
|
||||
/** Look up a region by slug, returns undefined if not found. */
|
||||
export function getRegion(slug: string): DOKSRegion | undefined {
|
||||
return DOKS_REGIONS.find((r) => r.slug === slug);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lux Network Configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type LuxNetwork = "mainnet" | "testnet" | "devnet";
|
||||
|
||||
export interface LuxNetworkConfig {
|
||||
networkId: number;
|
||||
chainId: number;
|
||||
httpPort: number;
|
||||
stakingPort: number;
|
||||
namespace: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const NETWORK_CONFIGS: Record<LuxNetwork, LuxNetworkConfig> = {
|
||||
mainnet: { networkId: 1, chainId: 96369, httpPort: 9630, stakingPort: 9631, namespace: "lux-mainnet", color: "#0066FF" },
|
||||
testnet: { networkId: 2, chainId: 96368, httpPort: 9640, stakingPort: 9641, namespace: "lux-testnet", color: "#00CC66" },
|
||||
devnet: { networkId: 3, chainId: 96370, httpPort: 9650, stakingPort: 9651, namespace: "lux-devnet", color: "#FF6600" },
|
||||
};
|
||||
|
||||
export const NETWORK_NAMES: LuxNetwork[] = ["mainnet", "testnet", "devnet"] as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API Client Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "";
|
||||
|
||||
function authHeaders(): HeadersInit {
|
||||
const headers: HeadersInit = { "Content-Type": "application/json" };
|
||||
if (typeof window !== "undefined") {
|
||||
const token = localStorage.getItem("token");
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function request<T = unknown>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...init,
|
||||
headers: { ...authHeaders(), ...init?.headers },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`${res.status} ${res.statusText}: ${body}`);
|
||||
}
|
||||
// 204 No Content
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/** List all fleets, optionally filtered by cluster. */
|
||||
export async function fetchFleets(clusterId?: string) {
|
||||
const qs = clusterId ? `?clusterId=${encodeURIComponent(clusterId)}` : "";
|
||||
return request(`/api/fleets${qs}`);
|
||||
}
|
||||
|
||||
/** Get a single fleet by cluster + network. */
|
||||
export async function fetchFleet(clusterId: string, network: LuxNetwork) {
|
||||
return request(`/api/fleets/${encodeURIComponent(clusterId)}/${network}`);
|
||||
}
|
||||
|
||||
/** Create a new fleet. */
|
||||
export async function createFleet(params: Record<string, unknown>) {
|
||||
return request("/api/fleets", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
}
|
||||
|
||||
/** Update an existing fleet. */
|
||||
export async function updateFleet(
|
||||
clusterId: string,
|
||||
network: LuxNetwork,
|
||||
params: Record<string, unknown>,
|
||||
) {
|
||||
return request(`/api/fleets/${encodeURIComponent(clusterId)}/${network}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete a fleet. */
|
||||
export async function deleteFleet(clusterId: string, network: LuxNetwork) {
|
||||
return request(`/api/fleets/${encodeURIComponent(clusterId)}/${network}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
/** Scale a fleet to the given replica count. */
|
||||
export async function scaleFleet(
|
||||
clusterId: string,
|
||||
network: LuxNetwork,
|
||||
replicas: number,
|
||||
) {
|
||||
return request(`/api/fleets/${encodeURIComponent(clusterId)}/${network}/scale`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ replicas }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Restart all nodes in a fleet. */
|
||||
export async function restartFleet(clusterId: string, network: LuxNetwork) {
|
||||
return request(`/api/fleets/${encodeURIComponent(clusterId)}/${network}/restart`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch aggregate stats across all fleets. */
|
||||
export async function fetchStats() {
|
||||
return request("/api/stats");
|
||||
}
|
||||
|
||||
/** Fetch available Lux networks. */
|
||||
export async function fetchNetworks() {
|
||||
return request("/api/networks");
|
||||
}
|
||||
Reference in New Issue
Block a user