mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-09-30 20:46:32 +00:00
feat(lux): graphical fleet dashboard at lux.cloud
Turn lux.cloud into a live graphical fleet dashboard for the managed luxd fleet (devnet/testnet/mainnet, 5 validators each on do-sfo3-lux-k8s). Backend (read-only collector, no mutations, no monolith dependency): - core/chains/lux_fleet.py: reads the operator LuxNetwork CR (lux.cloud/v1) for validators/version/plugin-set/phase, lists luxd-N pods + per-pod LB Services, and queries luxd JSON-RPC per node (info.getNodeVersion/getNodeID/ peers/isBootstrapped, C-Chain eth_blockNumber, P-Chain platform.getHeight), plus a native D-Chain DEX summary. Aggregates all 3 nets concurrently. - fleet_app.py: minimal standalone FastAPI exposing GET /v1/fleets/lux/overview (public, read-only). Reuses the bootnode api image; isolated from the (currently import-broken) monolith. - fleets/__init__.py: same overview endpoint also mounted on the main router. - Uses a READ-ONLY lux-k8s kubeconfig from KMS; only public chain data leaves. Frontend (Next.js 16, Lux white-label, auto by hostname): - lib/fleet.ts: typed client + health/status derivations. - components/lux/fleet-dashboard.tsx: graphical multi-net dashboard — totals, world topology map, per-network cards (phase, version, plugin-set, peers, C-Chain height, DEX state, per-chain C/P/X/D bootstrap), expandable per-node tables (node ID, version, peers, heights). Auto-refresh 15s. - app/fleet/page.tsx: public /fleet page (the lux.cloud fleet dashboard). - components/lux/fleet-strip.tsx + page.tsx: live fleet status strip on the landing hero. - navbar + dashboard nav: Fleet links; register existing Lux fleet mgmt page. - brand.ts: add skillsOrg (fixes pre-existing typecheck/build break). Deploy: infra/k8s/lux-fleet.yaml — lux-fleet Deployment/Service + ExternalSecret (lux-k8s RO kubeconfig from KMS) + ingress path /v1/fleets/lux/overview on api.cloud.lux.network. Verified: collector live against all 3 nets (real heights/peers/versions/DEX); api + collector tests pass (8/8); web typecheck + production build green; /fleet renders live data in browser (screenshot).
This commit is contained in:
@@ -26,6 +26,7 @@ from bootnode.core.chains.fleet_models import (
|
||||
FleetSummary,
|
||||
FleetUpdate,
|
||||
)
|
||||
from bootnode.core.chains.lux_fleet import LuxFleetOverview, get_overview as get_lux_overview
|
||||
from bootnode.db.models import OrgCluster, Project
|
||||
from bootnode.db.session import get_db
|
||||
|
||||
@@ -247,6 +248,24 @@ async def list_chains() -> dict:
|
||||
}
|
||||
|
||||
|
||||
@router.get("/lux/overview", response_model=LuxFleetOverview)
|
||||
async def lux_fleet_overview(networks: Optional[str] = None) -> LuxFleetOverview:
|
||||
"""Read-only graphical fleet overview for the managed luxd fleet.
|
||||
|
||||
Public (no auth): exposes only public chain data — phase, validator counts,
|
||||
luxd version, plugin-set, per-node block heights / peer counts / node IDs,
|
||||
and a native-DEX summary — across devnet/testnet/mainnet. Powers the
|
||||
lux.cloud fleet dashboard. No mutations, no secrets.
|
||||
|
||||
``networks`` optionally narrows to a comma-separated subset
|
||||
(e.g. ``?networks=mainnet,testnet``).
|
||||
"""
|
||||
selected = None
|
||||
if networks:
|
||||
selected = [n.strip() for n in networks.split(",") if n.strip()]
|
||||
return await get_lux_overview(selected)
|
||||
|
||||
|
||||
@router.get("/networks")
|
||||
async def list_networks(chain: Optional[str] = None) -> dict:
|
||||
"""List network configurations, optionally filtered by chain."""
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
"""Read-only Lux fleet collector.
|
||||
|
||||
Builds a graphical-dashboard view of the live luxd fleet across
|
||||
devnet / testnet / mainnet, observing it READ-ONLY in one of two modes:
|
||||
|
||||
MODE A — direct RPC (default; touches nothing but public luxd RPC):
|
||||
Query each validator's public luxd JSON-RPC endpoint (per-pod LoadBalancer)
|
||||
for ``info.getNodeVersion``, ``info.getNodeID``, ``info.peers``,
|
||||
``info.isBootstrapped`` (C/P/X/D), ``eth_blockNumber`` (C-Chain) and
|
||||
``platform.getHeight`` (P-Chain), plus a native D-Chain DEX summary. The
|
||||
endpoint set is operational config (``LUX_FLEET_ENDPOINTS_FILE`` /
|
||||
``LUX_FLEET_ENDPOINTS_JSON``), with built-in defaults.
|
||||
|
||||
MODE B — kubeconfig enrichment (optional; only if a READ-ONLY lux-k8s
|
||||
kubeconfig is provided): additionally read the operator ``LuxNetwork`` CR
|
||||
(``lux.cloud/v1``) for declared validator count, image tag, plugin-set and
|
||||
phase, and the luxd pods/services for real pod phase + discovered IPs.
|
||||
|
||||
Both modes are strictly observational — no mutations, no writes. The data
|
||||
exposed (block heights, peer counts, node IDs, versions) is public chain data
|
||||
already reachable via the per-pod RPC endpoints, so the overview is safe to
|
||||
serve unauthenticated to the lux.cloud surface. No secrets cross into the
|
||||
response.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# The operator CRD that declares each managed network. This is the REAL group
|
||||
# (verified against the live cluster) — not the legacy ``lux.network/v1alpha1``.
|
||||
LUXNETWORK_GROUP = "lux.cloud"
|
||||
LUXNETWORK_VERSION = "v1"
|
||||
LUXNETWORK_PLURAL = "luxnetworks"
|
||||
LUXNETWORK_NAME = "luxd"
|
||||
|
||||
# luxd P2P chains we surface bootstrap state + heights for, in display order.
|
||||
TRACKED_CHAINS = ("C", "P", "X", "D")
|
||||
|
||||
# Per-network luxd HTTP RPC ports + namespaces (mirrors CHAIN_NETWORKS["lux"]).
|
||||
LUX_NETWORKS: dict[str, dict[str, Any]] = {
|
||||
"mainnet": {"namespace": "lux-mainnet", "http_port": 9630, "network_id": 1, "chain_id": 96369},
|
||||
"testnet": {"namespace": "lux-testnet", "http_port": 9640, "network_id": 2, "chain_id": 96368},
|
||||
"devnet": {"namespace": "lux-devnet", "http_port": 9650, "network_id": 3, "chain_id": 96370},
|
||||
}
|
||||
|
||||
# Optional read-only lux-k8s kubeconfig (MODE B enrichment). File path
|
||||
# (LUX_K8S_KUBECONFIG) or inline YAML (LUX_K8S_KUBECONFIG_DATA).
|
||||
ENV_KUBECONFIG_PATH = "LUX_K8S_KUBECONFIG"
|
||||
ENV_KUBECONFIG_DATA = "LUX_K8S_KUBECONFIG_DATA"
|
||||
|
||||
# Endpoint config (MODE A). JSON inline (LUX_FLEET_ENDPOINTS_JSON) or file
|
||||
# (LUX_FLEET_ENDPOINTS_FILE). Shape:
|
||||
# {"<network>": {"http_port": 9630, "nodes": [{"index": 0, "ip": "1.2.3.4"}, ...]}}
|
||||
ENV_ENDPOINTS_JSON = "LUX_FLEET_ENDPOINTS_JSON"
|
||||
ENV_ENDPOINTS_FILE = "LUX_FLEET_ENDPOINTS_FILE"
|
||||
|
||||
# Built-in default endpoints: the managed per-pod LoadBalancer RPC IPs.
|
||||
# Operational config (public infra IPs, not secrets); overridable without a
|
||||
# rebuild via the ConfigMap-mounted endpoint file.
|
||||
DEFAULT_ENDPOINTS: dict[str, dict[str, Any]] = {
|
||||
"mainnet": {"http_port": 9630, "nodes": [
|
||||
{"index": 0, "ip": "129.212.164.46"},
|
||||
{"index": 1, "ip": "137.184.246.133"},
|
||||
{"index": 2, "ip": "143.244.210.43"},
|
||||
{"index": 3, "ip": "143.244.210.71"},
|
||||
{"index": 4, "ip": "24.144.70.85"},
|
||||
]},
|
||||
"testnet": {"http_port": 9640, "nodes": [
|
||||
{"index": 0, "ip": "134.199.187.16"},
|
||||
{"index": 1, "ip": "209.38.174.84"},
|
||||
{"index": 2, "ip": "209.38.173.80"},
|
||||
{"index": 3, "ip": "134.199.187.62"},
|
||||
{"index": 4, "ip": "164.90.247.204"},
|
||||
]},
|
||||
"devnet": {"http_port": 9650, "nodes": [
|
||||
{"index": 0, "ip": "164.90.247.220"},
|
||||
{"index": 1, "ip": "146.190.14.112"},
|
||||
{"index": 2, "ip": "24.199.68.130"},
|
||||
{"index": 3, "ip": "134.199.184.181"},
|
||||
{"index": 4, "ip": "209.38.172.213"},
|
||||
]},
|
||||
}
|
||||
|
||||
_RPC_TIMEOUT = httpx.Timeout(connect=2.5, read=4.0, write=2.5, pool=2.5)
|
||||
|
||||
|
||||
def _load_endpoints() -> dict[str, dict[str, Any]]:
|
||||
"""Resolve the endpoint config (MODE A). Env file/JSON override defaults."""
|
||||
raw: Optional[str] = None
|
||||
path = os.environ.get(ENV_ENDPOINTS_FILE)
|
||||
if path and os.path.exists(path):
|
||||
try:
|
||||
raw = open(path).read()
|
||||
except OSError as e:
|
||||
logger.warning("endpoint file read failed (%s): %s", path, e)
|
||||
if raw is None:
|
||||
raw = os.environ.get(ENV_ENDPOINTS_JSON)
|
||||
if raw:
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
if isinstance(parsed, dict) and parsed:
|
||||
return parsed
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning("endpoint JSON parse failed: %s", e)
|
||||
return DEFAULT_ENDPOINTS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ChainHeight(BaseModel):
|
||||
"""Per-chain bootstrap + height for one node."""
|
||||
|
||||
chain: str
|
||||
bootstrapped: Optional[bool] = None
|
||||
height: Optional[int] = None
|
||||
|
||||
|
||||
class LuxNode(BaseModel):
|
||||
"""Live status of a single luxd validator pod."""
|
||||
|
||||
pod_name: str
|
||||
pod_index: int
|
||||
phase: str = "Unknown" # K8s pod phase: Running/Pending/...
|
||||
ready: bool = False # K8s readiness
|
||||
external_ip: Optional[str] = None
|
||||
rpc_reachable: bool = False # did luxd RPC answer?
|
||||
node_id: Optional[str] = None
|
||||
version: Optional[str] = None # e.g. luxd/1.30.39
|
||||
git_commit: Optional[str] = None
|
||||
db_version: Optional[str] = None
|
||||
peers: Optional[int] = None
|
||||
healthy: Optional[bool] = None # luxd /ext/health (best-effort)
|
||||
chains: list[ChainHeight] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DexSummary(BaseModel):
|
||||
"""Native DEX (D-Chain) summary for a network."""
|
||||
|
||||
d_chain_bootstrapped: Optional[bool] = None
|
||||
venue: str = "native-dchain"
|
||||
markets: Optional[int] = None # from explorer graph, if available
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
class LuxNetworkFleet(BaseModel):
|
||||
"""One network's fleet (devnet/testnet/mainnet)."""
|
||||
|
||||
network: str
|
||||
namespace: str
|
||||
network_id: Optional[int] = None
|
||||
chain_id: Optional[int] = None
|
||||
phase: str = "Unknown" # LuxNetwork CR status.phase
|
||||
image: Optional[str] = None # full repo:tag
|
||||
version: Optional[str] = None # luxd version (image tag)
|
||||
plugin_set: Optional[str] = None # spec.pluginSource.bucket
|
||||
vm_plugins: list[str] = Field(default_factory=list)
|
||||
validators: int = 0 # declared
|
||||
nodes_total: int = 0 # observed pods
|
||||
nodes_ready: int = 0 # ready pods
|
||||
nodes_rpc_ok: int = 0 # nodes answering RPC
|
||||
total_peers: int = 0
|
||||
dex: DexSummary = Field(default_factory=DexSummary)
|
||||
nodes: list[LuxNode] = Field(default_factory=list)
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class LuxFleetOverview(BaseModel):
|
||||
"""All-network fleet overview — the single read the dashboard polls."""
|
||||
|
||||
cluster: str = "lux-k8s"
|
||||
networks: list[LuxNetworkFleet] = Field(default_factory=list)
|
||||
totals: dict[str, int] = Field(default_factory=dict)
|
||||
generated_at: float = 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kubernetes client (read-only, lux-k8s)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load_lux_k8s_clients():
|
||||
"""Return (CustomObjectsApi, CoreV1Api) for lux-k8s, read-only.
|
||||
|
||||
Resolution order:
|
||||
1. ``LUX_K8S_KUBECONFIG`` — path to a mounted kubeconfig file.
|
||||
2. ``LUX_K8S_KUBECONFIG_DATA`` — inline kubeconfig YAML.
|
||||
3. in-cluster config (only correct if the api runs inside lux-k8s).
|
||||
"""
|
||||
from kubernetes import client, config
|
||||
|
||||
path = os.environ.get(ENV_KUBECONFIG_PATH)
|
||||
data = os.environ.get(ENV_KUBECONFIG_DATA)
|
||||
|
||||
if path and os.path.exists(path):
|
||||
config.load_kube_config(config_file=path)
|
||||
elif data:
|
||||
tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".kubeconfig", delete=False)
|
||||
try:
|
||||
tmp.write(data)
|
||||
tmp.flush()
|
||||
tmp.close()
|
||||
config.load_kube_config(config_file=tmp.name)
|
||||
finally:
|
||||
os.unlink(tmp.name)
|
||||
else:
|
||||
# Last resort: in-cluster. Only valid if deployed inside lux-k8s.
|
||||
config.load_incluster_config()
|
||||
|
||||
return client.CustomObjectsApi(), client.CoreV1Api()
|
||||
|
||||
|
||||
def fleet_data_available() -> bool:
|
||||
"""True if a lux-k8s kubeconfig is configured (or we're in-cluster)."""
|
||||
if os.environ.get(ENV_KUBECONFIG_PATH) and os.path.exists(os.environ[ENV_KUBECONFIG_PATH]):
|
||||
return True
|
||||
if os.environ.get(ENV_KUBECONFIG_DATA):
|
||||
return True
|
||||
return os.path.exists("/var/run/secrets/kubernetes.io/serviceaccount/token")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# luxd RPC (read-only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _rpc(http: httpx.AsyncClient, base: str, path: str, method: str,
|
||||
params: Any = None) -> Optional[dict]:
|
||||
"""POST a single JSON-RPC call to luxd; return ``result`` or None."""
|
||||
body = {"jsonrpc": "2.0", "id": 1, "method": method}
|
||||
if params is not None:
|
||||
body["params"] = params
|
||||
try:
|
||||
r = await http.post(f"{base}{path}", json=body)
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
data = r.json()
|
||||
return data.get("result")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def _probe_node_rpc(node: LuxNode, http_port: int) -> None:
|
||||
"""Enrich ``node`` in-place with live luxd RPC data. Best-effort."""
|
||||
if not node.external_ip:
|
||||
return
|
||||
base = f"http://{node.external_ip}:{http_port}"
|
||||
async with httpx.AsyncClient(timeout=_RPC_TIMEOUT) as http:
|
||||
# Version / nodeID / peers in parallel.
|
||||
ver, nid, peers, health = await asyncio.gather(
|
||||
_rpc(http, base, "/ext/info", "info.getNodeVersion"),
|
||||
_rpc(http, base, "/ext/info", "info.getNodeID"),
|
||||
_rpc(http, base, "/ext/info", "info.peers", {}),
|
||||
_node_health(http, base),
|
||||
)
|
||||
if ver:
|
||||
node.rpc_reachable = True
|
||||
node.version = ver.get("version")
|
||||
node.git_commit = ver.get("gitCommit")
|
||||
node.db_version = ver.get("databaseVersion")
|
||||
if nid:
|
||||
node.node_id = nid.get("nodeID")
|
||||
if isinstance(peers, dict) and isinstance(peers.get("peers"), list):
|
||||
node.peers = len(peers["peers"])
|
||||
node.healthy = health
|
||||
|
||||
# Per-chain bootstrap + heights.
|
||||
chains: list[ChainHeight] = []
|
||||
boot_results = await asyncio.gather(*[
|
||||
_rpc(http, base, "/ext/info", "info.isBootstrapped", {"chain": c})
|
||||
for c in TRACKED_CHAINS
|
||||
])
|
||||
c_height, p_height = await asyncio.gather(
|
||||
_rpc(http, base, "/ext/bc/C/rpc", "eth_blockNumber", []),
|
||||
_rpc(http, base, "/ext/bc/P", "platform.getHeight"),
|
||||
)
|
||||
for c, boot in zip(TRACKED_CHAINS, boot_results):
|
||||
ch = ChainHeight(chain=c)
|
||||
if isinstance(boot, dict):
|
||||
ch.bootstrapped = boot.get("isBootstrapped")
|
||||
if c == "C" and isinstance(c_height, str):
|
||||
try:
|
||||
ch.height = int(c_height, 16)
|
||||
except ValueError:
|
||||
pass
|
||||
if c == "P" and isinstance(p_height, dict):
|
||||
try:
|
||||
ch.height = int(p_height.get("height"))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
chains.append(ch)
|
||||
node.chains = chains
|
||||
|
||||
|
||||
async def _node_health(http: httpx.AsyncClient, base: str) -> Optional[bool]:
|
||||
"""luxd /ext/health — returns healthy flag. (Known to encode-fail on some
|
||||
builds; treat a non-200 / parse error as unknown, not unhealthy.)"""
|
||||
try:
|
||||
r = await http.get(f"{base}/ext/health")
|
||||
if r.status_code not in (200, 503):
|
||||
return None
|
||||
return bool(r.json().get("healthy"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-network collection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _read_luxnetwork_cr(custom_api, namespace: str) -> dict:
|
||||
"""Read the LuxNetwork CR for a namespace. Returns {} if absent."""
|
||||
try:
|
||||
return custom_api.get_namespaced_custom_object(
|
||||
group=LUXNETWORK_GROUP, version=LUXNETWORK_VERSION,
|
||||
namespace=namespace, plural=LUXNETWORK_PLURAL, name=LUXNETWORK_NAME,
|
||||
) or {}
|
||||
except Exception as e:
|
||||
logger.warning("LuxNetwork CR read failed for %s: %s", namespace, e)
|
||||
return {}
|
||||
|
||||
|
||||
def _list_luxd_pods(core_api, namespace: str) -> dict[int, dict[str, Any]]:
|
||||
"""Map pod index -> {phase, ready} for luxd-N pods."""
|
||||
out: dict[int, dict[str, Any]] = {}
|
||||
try:
|
||||
pods = core_api.list_namespaced_pod(
|
||||
namespace=namespace, label_selector="app.kubernetes.io/name=luxd",
|
||||
).items
|
||||
if not pods:
|
||||
# Fallback: StatefulSet pods are named luxd-N regardless of labels.
|
||||
pods = [p for p in core_api.list_namespaced_pod(namespace=namespace).items
|
||||
if (p.metadata.name or "").startswith("luxd-")]
|
||||
except Exception as e:
|
||||
logger.warning("pod list failed for %s: %s", namespace, e)
|
||||
return out
|
||||
|
||||
for p in pods:
|
||||
name = p.metadata.name or ""
|
||||
if not name.startswith("luxd-"):
|
||||
continue
|
||||
try:
|
||||
idx = int(name.rsplit("-", 1)[1])
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
ready = False
|
||||
conds = (p.status.conditions or []) if p.status else []
|
||||
for c in conds:
|
||||
if c.type == "Ready":
|
||||
ready = (c.status == "True")
|
||||
out[idx] = {"phase": (p.status.phase if p.status else "Unknown") or "Unknown",
|
||||
"ready": ready}
|
||||
return out
|
||||
|
||||
|
||||
def _list_luxd_external_ips(core_api, namespace: str) -> dict[int, str]:
|
||||
"""Map pod index -> external LB IP from per-pod luxd-N Services."""
|
||||
out: dict[int, str] = {}
|
||||
try:
|
||||
svcs = core_api.list_namespaced_service(namespace=namespace).items
|
||||
except Exception as e:
|
||||
logger.warning("service list failed for %s: %s", namespace, e)
|
||||
return out
|
||||
for s in svcs:
|
||||
name = s.metadata.name or ""
|
||||
if not name.startswith("luxd-"):
|
||||
continue
|
||||
try:
|
||||
idx = int(name.rsplit("-", 1)[1])
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
ing = (s.status.load_balancer.ingress if s.status and s.status.load_balancer else None) or []
|
||||
for i in ing:
|
||||
if i.ip:
|
||||
out[idx] = i.ip
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _parse_cr(cr: dict) -> dict[str, Any]:
|
||||
"""Extract declared shape + phase from a LuxNetwork CR."""
|
||||
spec = cr.get("spec", {}) or {}
|
||||
status = cr.get("status", {}) or {}
|
||||
image = spec.get("image", {}) or {}
|
||||
repo = image.get("repository")
|
||||
tag = image.get("tag")
|
||||
full = f"{repo}:{tag}" if repo and tag else (tag or repo)
|
||||
plugin = spec.get("pluginSource", {}) or {}
|
||||
vm_plugins = [p.get("vmID") for p in (plugin.get("vmPlugins") or []) if p.get("vmID")]
|
||||
return {
|
||||
"phase": status.get("phase", "Unknown") or "Unknown",
|
||||
"validators": int(spec.get("validators") or 0),
|
||||
"image": full,
|
||||
"version": tag,
|
||||
"plugin_set": plugin.get("bucket"),
|
||||
"vm_plugins": vm_plugins,
|
||||
}
|
||||
|
||||
|
||||
async def _dex_summary(nodes: list[LuxNode], network: str) -> DexSummary:
|
||||
"""Build a DEX summary from D-Chain bootstrap state + explorer markets."""
|
||||
d_boot: Optional[bool] = None
|
||||
for n in nodes:
|
||||
for ch in n.chains:
|
||||
if ch.chain == "D" and ch.bootstrapped is not None:
|
||||
d_boot = d_boot or ch.bootstrapped
|
||||
summary = DexSummary(d_chain_bootstrapped=d_boot)
|
||||
# Best-effort markets count from the native explorer graph.
|
||||
host = {
|
||||
"mainnet": "explorer.lux.network",
|
||||
"testnet": "explorer.testnet.lux.network",
|
||||
"devnet": "explorer.devnet.lux.network",
|
||||
}.get(network)
|
||||
if host:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_RPC_TIMEOUT) as http:
|
||||
r = await http.post(
|
||||
f"https://{host}/v1/graph/lux/dex/graphql",
|
||||
json={"query": "{ markets { id } }"},
|
||||
)
|
||||
if r.status_code == 200:
|
||||
mkts = (r.json().get("data") or {}).get("markets")
|
||||
if isinstance(mkts, list):
|
||||
summary.markets = len(mkts)
|
||||
except Exception:
|
||||
pass
|
||||
if d_boot is False:
|
||||
summary.note = "D-Chain not yet bootstrapped"
|
||||
return summary
|
||||
|
||||
|
||||
async def _collect_network(
|
||||
network: str,
|
||||
endpoints: dict[str, Any],
|
||||
custom_api=None,
|
||||
core_api=None,
|
||||
) -> LuxNetworkFleet:
|
||||
"""Collect one network. MODE A (default) queries configured RPC endpoints;
|
||||
MODE B additionally reads the CR + pods/services when k8s clients are given."""
|
||||
cfg = LUX_NETWORKS[network]
|
||||
ns = cfg["namespace"]
|
||||
ep = endpoints.get(network, {})
|
||||
http_port = int(ep.get("http_port") or cfg["http_port"])
|
||||
fleet = LuxNetworkFleet(
|
||||
network=network, namespace=ns,
|
||||
network_id=cfg["network_id"], chain_id=cfg["chain_id"],
|
||||
)
|
||||
|
||||
# Endpoint config = the baseline node set (index -> external RPC IP).
|
||||
ip_by_idx: dict[int, str] = {}
|
||||
for node in ep.get("nodes", []) or []:
|
||||
try:
|
||||
ip_by_idx[int(node["index"])] = str(node["ip"])
|
||||
except (KeyError, ValueError, TypeError):
|
||||
continue
|
||||
pods: dict[int, dict[str, Any]] = {}
|
||||
|
||||
# MODE B enrichment: CR (declared shape/phase) + observed pods/services.
|
||||
if custom_api is not None and core_api is not None:
|
||||
try:
|
||||
cr, pods, disc_ips = await asyncio.gather(
|
||||
asyncio.to_thread(_read_luxnetwork_cr, custom_api, ns),
|
||||
asyncio.to_thread(_list_luxd_pods, core_api, ns),
|
||||
asyncio.to_thread(_list_luxd_external_ips, core_api, ns),
|
||||
)
|
||||
parsed = _parse_cr(cr)
|
||||
fleet.phase = parsed["phase"]
|
||||
fleet.image = parsed["image"]
|
||||
fleet.version = parsed["version"]
|
||||
fleet.plugin_set = parsed["plugin_set"]
|
||||
fleet.vm_plugins = parsed["vm_plugins"]
|
||||
fleet.validators = parsed["validators"]
|
||||
# Discovered IPs take precedence (current truth), then config.
|
||||
ip_by_idx = {**ip_by_idx, **disc_ips}
|
||||
except Exception as e:
|
||||
logger.warning("lux-k8s enrichment failed for %s: %s", network, e)
|
||||
|
||||
# Build the node set: union of configured endpoints + observed pods.
|
||||
indices = set(ip_by_idx) | set(pods)
|
||||
if not indices and fleet.validators:
|
||||
indices = set(range(fleet.validators))
|
||||
nodes = [
|
||||
LuxNode(
|
||||
pod_name=f"luxd-{i}",
|
||||
pod_index=i,
|
||||
phase=pods.get(i, {}).get("phase", "Unknown"),
|
||||
ready=pods.get(i, {}).get("ready", False),
|
||||
external_ip=ip_by_idx.get(i),
|
||||
)
|
||||
for i in sorted(indices)
|
||||
]
|
||||
|
||||
# Probe RPC concurrently for nodes with a reachable external IP.
|
||||
await asyncio.gather(*[_probe_node_rpc(n, http_port) for n in nodes])
|
||||
|
||||
# In MODE A (no CR), derive declared validators + version from observation.
|
||||
if not fleet.validators:
|
||||
fleet.validators = len(nodes)
|
||||
if not fleet.version:
|
||||
ver = next((n.version for n in nodes if n.version), None)
|
||||
if ver:
|
||||
fleet.version = ver
|
||||
fleet.image = ver
|
||||
if fleet.phase == "Unknown" and any(n.rpc_reachable for n in nodes):
|
||||
fleet.phase = "Running"
|
||||
|
||||
fleet.nodes = nodes
|
||||
fleet.nodes_total = len(nodes)
|
||||
# Without pod phase (MODE A), readiness = RPC reachability.
|
||||
if pods:
|
||||
fleet.nodes_ready = sum(1 for n in nodes if n.ready)
|
||||
else:
|
||||
fleet.nodes_ready = sum(1 for n in nodes if n.rpc_reachable)
|
||||
for n in nodes:
|
||||
if n.rpc_reachable:
|
||||
n.ready = True
|
||||
fleet.nodes_rpc_ok = sum(1 for n in nodes if n.rpc_reachable)
|
||||
fleet.total_peers = sum(n.peers or 0 for n in nodes)
|
||||
fleet.dex = await _dex_summary(nodes, network)
|
||||
if fleet.nodes_total == 0:
|
||||
fleet.error = "no luxd endpoints configured"
|
||||
return fleet
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def get_overview(networks: Optional[list[str]] = None) -> LuxFleetOverview:
|
||||
"""Collect the full read-only fleet overview across all networks.
|
||||
|
||||
Default = MODE A (direct luxd RPC over configured endpoints). If a read-only
|
||||
lux-k8s kubeconfig is configured, MODE B enrichment (CR + pods) is layered on.
|
||||
"""
|
||||
import time
|
||||
|
||||
selected = networks or list(LUX_NETWORKS.keys())
|
||||
overview = LuxFleetOverview(generated_at=time.time())
|
||||
endpoints = _load_endpoints()
|
||||
|
||||
custom_api = core_api = None
|
||||
if fleet_data_available():
|
||||
try:
|
||||
custom_api, core_api = await asyncio.to_thread(_load_lux_k8s_clients)
|
||||
except Exception as e:
|
||||
logger.warning("lux-k8s enrichment unavailable, using direct RPC: %s", e)
|
||||
|
||||
overview.networks = await asyncio.gather(*[
|
||||
_collect_network(n, endpoints, custom_api, core_api) for n in selected
|
||||
])
|
||||
|
||||
overview.totals = {
|
||||
"networks": len(overview.networks),
|
||||
"validators": sum(f.validators for f in overview.networks),
|
||||
"nodes_total": sum(f.nodes_total for f in overview.networks),
|
||||
"nodes_ready": sum(f.nodes_ready for f in overview.networks),
|
||||
"nodes_rpc_ok": sum(f.nodes_rpc_ok for f in overview.networks),
|
||||
"total_peers": sum(f.total_peers for f in overview.networks),
|
||||
}
|
||||
return overview
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Standalone read-only Lux fleet API.
|
||||
|
||||
A minimal FastAPI surface that exposes the live luxd fleet overview for the
|
||||
lux.cloud dashboard. It depends ONLY on the self-contained fleet collector
|
||||
(``bootnode.core.chains.lux_fleet``) — it does not import the main monolith,
|
||||
its database, or any mutating routers. This keeps the read-only fleet view
|
||||
fully isolated (separation of concerns) and independently deployable.
|
||||
|
||||
Run: uvicorn bootnode.fleet_app:app --host 0.0.0.0 --port 8000
|
||||
|
||||
Serves:
|
||||
GET /health -> liveness
|
||||
GET /v1/fleets/lux/overview -> full read-only fleet overview (all networks)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from bootnode.core.chains.lux_fleet import (
|
||||
LuxFleetOverview,
|
||||
fleet_data_available,
|
||||
get_overview,
|
||||
)
|
||||
|
||||
app = FastAPI(
|
||||
title="Lux Fleet API",
|
||||
version="1.0.0",
|
||||
description="Read-only live overview of the managed luxd fleet (devnet/testnet/mainnet).",
|
||||
)
|
||||
|
||||
# White-label cloud surfaces that consume this read-only API.
|
||||
_DEFAULT_ORIGINS = [
|
||||
"https://lux.cloud",
|
||||
"https://www.lux.cloud",
|
||||
"https://cloud.lux.network",
|
||||
]
|
||||
_extra = [o.strip() for o in os.environ.get("ALLOWED_ORIGINS", "").split(",") if o.strip()]
|
||||
_origins = _DEFAULT_ORIGINS + [o for o in _extra if o not in _DEFAULT_ORIGINS]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=_origins,
|
||||
allow_credentials=False,
|
||||
allow_methods=["GET"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict:
|
||||
# MODE A (direct luxd RPC over configured endpoints) is always available;
|
||||
# k8s enrichment (CR + pods) is on only when a lux-k8s kubeconfig is set.
|
||||
return {"status": "ok", "k8s_enrichment": fleet_data_available()}
|
||||
|
||||
|
||||
@app.get("/v1/fleets/lux/overview", response_model=LuxFleetOverview)
|
||||
async def lux_overview(networks: str | None = None) -> LuxFleetOverview:
|
||||
"""Read-only fleet overview across devnet/testnet/mainnet.
|
||||
|
||||
``networks`` optionally narrows to a comma-separated subset.
|
||||
"""
|
||||
selected = None
|
||||
if networks:
|
||||
selected = [n.strip() for n in networks.split(",") if n.strip()]
|
||||
return await get_overview(selected)
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Tests for the read-only Lux fleet overview collector (lux_fleet.py).
|
||||
|
||||
Pure-logic + mocked-IO coverage:
|
||||
- CR parsing (lux.cloud/v1 shape): version, plugin-set, validators, phase.
|
||||
- Node-set union from pods + per-pod LB services.
|
||||
- RPC enrichment (mocked httpx) → version/peers/heights/bootstrapped.
|
||||
- Overview aggregation + totals.
|
||||
- Graceful "no kubeconfig" shaped-empty response.
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from bootnode.core.chains import lux_fleet
|
||||
from bootnode.core.chains.lux_fleet import (
|
||||
ChainHeight,
|
||||
DexSummary,
|
||||
LuxNode,
|
||||
LuxNetworkFleet,
|
||||
_parse_cr,
|
||||
get_overview,
|
||||
)
|
||||
|
||||
|
||||
def _dex_stub() -> DexSummary:
|
||||
return DexSummary(d_chain_bootstrapped=False, note="D-Chain not yet bootstrapped")
|
||||
|
||||
|
||||
# --- Sample data mirroring the live devnet LuxNetwork CR (lux.cloud/v1) -----
|
||||
|
||||
SAMPLE_CR = {
|
||||
"apiVersion": "lux.cloud/v1",
|
||||
"kind": "LuxNetwork",
|
||||
"spec": {
|
||||
"validators": 5,
|
||||
"image": {"repository": "ghcr.io/luxfi/node", "tag": "v1.30.40-d69d3257"},
|
||||
"pluginSource": {
|
||||
"bucket": "s3://lux-plugins-devnet/v1.3.1/",
|
||||
"vmPlugins": [
|
||||
{"objectKey": "dexvm-linux-amd64", "vmID": "dexvm"},
|
||||
{"objectKey": "evm-linux-amd64", "vmID": "mgj786NP"},
|
||||
{"objectKey": "no-id"}, # missing vmID is skipped
|
||||
],
|
||||
},
|
||||
},
|
||||
"status": {"phase": "Unknown", "observedGeneration": 27},
|
||||
}
|
||||
|
||||
|
||||
class TestParseCR:
|
||||
def test_extracts_declared_shape(self):
|
||||
p = _parse_cr(SAMPLE_CR)
|
||||
assert p["validators"] == 5
|
||||
assert p["image"] == "ghcr.io/luxfi/node:v1.30.40-d69d3257"
|
||||
assert p["version"] == "v1.30.40-d69d3257"
|
||||
assert p["plugin_set"] == "s3://lux-plugins-devnet/v1.3.1/"
|
||||
assert p["phase"] == "Unknown"
|
||||
|
||||
def test_vm_plugins_skip_missing_ids(self):
|
||||
p = _parse_cr(SAMPLE_CR)
|
||||
assert p["vm_plugins"] == ["dexvm", "mgj786NP"]
|
||||
|
||||
def test_empty_cr_safe(self):
|
||||
p = _parse_cr({})
|
||||
assert p["validators"] == 0
|
||||
assert p["phase"] == "Unknown"
|
||||
assert p["version"] is None
|
||||
assert p["plugin_set"] is None
|
||||
assert p["vm_plugins"] == []
|
||||
|
||||
|
||||
class TestEndpointConfig:
|
||||
def test_defaults_present(self):
|
||||
eps = lux_fleet.DEFAULT_ENDPOINTS
|
||||
assert set(eps) == {"mainnet", "testnet", "devnet"}
|
||||
for net, cfg in eps.items():
|
||||
assert cfg["http_port"] in (9630, 9640, 9650)
|
||||
assert len(cfg["nodes"]) == 5
|
||||
|
||||
def test_env_json_overrides_defaults(self, monkeypatch):
|
||||
monkeypatch.delenv(lux_fleet.ENV_ENDPOINTS_FILE, raising=False)
|
||||
monkeypatch.setenv(
|
||||
lux_fleet.ENV_ENDPOINTS_JSON,
|
||||
'{"devnet": {"http_port": 9650, "nodes": [{"index": 0, "ip": "9.9.9.9"}]}}',
|
||||
)
|
||||
eps = lux_fleet._load_endpoints()
|
||||
assert eps["devnet"]["nodes"][0]["ip"] == "9.9.9.9"
|
||||
|
||||
def test_bad_json_falls_back_to_defaults(self, monkeypatch):
|
||||
monkeypatch.delenv(lux_fleet.ENV_ENDPOINTS_FILE, raising=False)
|
||||
monkeypatch.setenv(lux_fleet.ENV_ENDPOINTS_JSON, "{not json")
|
||||
assert lux_fleet._load_endpoints() == lux_fleet.DEFAULT_ENDPOINTS
|
||||
|
||||
|
||||
class TestModeA_DirectRPC:
|
||||
@pytest.mark.asyncio
|
||||
async def test_overview_direct_rpc_no_kubeconfig(self, monkeypatch):
|
||||
# No kubeconfig → MODE A: nodes come from endpoint config, health from RPC.
|
||||
monkeypatch.delenv(lux_fleet.ENV_KUBECONFIG_PATH, raising=False)
|
||||
monkeypatch.delenv(lux_fleet.ENV_KUBECONFIG_DATA, raising=False)
|
||||
|
||||
async def fake_probe(node: LuxNode, http_port: int):
|
||||
node.rpc_reachable = True
|
||||
node.version = "luxd/1.30.40"
|
||||
node.peers = 4
|
||||
node.chains = [ChainHeight(chain="C", bootstrapped=True, height=500)]
|
||||
|
||||
with patch("os.path.exists", return_value=False), \
|
||||
patch.object(lux_fleet, "_probe_node_rpc", side_effect=fake_probe), \
|
||||
patch.object(lux_fleet, "_dex_summary",
|
||||
side_effect=lambda n, net: _dex_stub()):
|
||||
ov = await get_overview(["mainnet"])
|
||||
|
||||
f = ov.networks[0]
|
||||
assert f.network == "mainnet"
|
||||
assert f.nodes_total == 5 # from DEFAULT_ENDPOINTS
|
||||
assert f.nodes_ready == 5 # MODE A: ready == rpc_reachable
|
||||
assert f.nodes_rpc_ok == 5
|
||||
assert f.validators == 5 # derived from node count
|
||||
assert f.version == "luxd/1.30.40" # derived from RPC
|
||||
assert f.phase == "Running"
|
||||
assert ov.totals["nodes_rpc_ok"] == 5
|
||||
|
||||
|
||||
class TestCollectNetwork:
|
||||
@pytest.mark.asyncio
|
||||
async def test_mode_b_enrichment_with_mocked_io(self):
|
||||
"""MODE B: CR + pods/svcs enrichment merged with RPC. luxd-0/1/2 expose
|
||||
RPC (have LB IP); luxd-3/4 have pods only."""
|
||||
pods = {i: {"phase": "Running", "ready": True} for i in range(5)}
|
||||
ips = {0: "10.0.0.1", 1: "10.0.0.2", 2: "10.0.0.3"}
|
||||
|
||||
async def fake_probe(node: LuxNode, http_port: int):
|
||||
if node.external_ip:
|
||||
node.rpc_reachable = True
|
||||
node.version = "luxd/1.30.39"
|
||||
node.node_id = f"NodeID-{node.pod_index}"
|
||||
node.peers = 4
|
||||
node.healthy = True
|
||||
node.chains = [
|
||||
ChainHeight(chain="C", bootstrapped=True, height=108),
|
||||
ChainHeight(chain="P", bootstrapped=True, height=0),
|
||||
ChainHeight(chain="X", bootstrapped=True),
|
||||
ChainHeight(chain="D", bootstrapped=False),
|
||||
]
|
||||
|
||||
with patch.object(lux_fleet, "_read_luxnetwork_cr", return_value=SAMPLE_CR), \
|
||||
patch.object(lux_fleet, "_list_luxd_pods", return_value=pods), \
|
||||
patch.object(lux_fleet, "_list_luxd_external_ips", return_value=ips), \
|
||||
patch.object(lux_fleet, "_probe_node_rpc", side_effect=fake_probe), \
|
||||
patch.object(lux_fleet, "_dex_summary", side_effect=lambda n, net: _dex_stub()):
|
||||
# endpoints={} so the node set comes purely from k8s discovery.
|
||||
fleet = await lux_fleet._collect_network(
|
||||
"devnet", {}, custom_api=object(), core_api=object())
|
||||
|
||||
assert fleet.network == "devnet"
|
||||
assert fleet.namespace == "lux-devnet"
|
||||
assert fleet.version == "v1.30.40-d69d3257" # from CR
|
||||
assert fleet.plugin_set == "s3://lux-plugins-devnet/v1.3.1/"
|
||||
assert fleet.validators == 5
|
||||
assert fleet.nodes_total == 5 # union of pods + svcs
|
||||
assert fleet.nodes_ready == 5 # MODE B: pod readiness
|
||||
assert fleet.nodes_rpc_ok == 3 # only LB-exposed nodes answer
|
||||
assert fleet.total_peers == 12 # 3 nodes * 4 peers
|
||||
assert fleet.dex.d_chain_bootstrapped is False
|
||||
n3 = next(n for n in fleet.nodes if n.pod_index == 3)
|
||||
assert n3.rpc_reachable is False
|
||||
assert n3.phase == "Running"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mode_a_uses_endpoint_config(self):
|
||||
"""MODE A: node set + IPs come from the endpoint config (no k8s)."""
|
||||
endpoints = {"mainnet": {"http_port": 9630, "nodes": [
|
||||
{"index": 0, "ip": "1.1.1.1"}, {"index": 1, "ip": "2.2.2.2"},
|
||||
]}}
|
||||
|
||||
async def fake_probe(node: LuxNode, http_port: int):
|
||||
assert http_port == 9630
|
||||
node.rpc_reachable = True
|
||||
node.version = "luxd/1.30.40"
|
||||
node.peers = 7
|
||||
node.chains = [ChainHeight(chain="D", bootstrapped=True)]
|
||||
|
||||
with patch.object(lux_fleet, "_probe_node_rpc", side_effect=fake_probe), \
|
||||
patch.object(lux_fleet, "_dex_summary", side_effect=lambda n, net: _dex_stub()):
|
||||
fleet = await lux_fleet._collect_network("mainnet", endpoints)
|
||||
|
||||
assert fleet.nodes_total == 2
|
||||
assert [n.external_ip for n in fleet.nodes] == ["1.1.1.1", "2.2.2.2"]
|
||||
assert fleet.nodes_ready == 2 # ready == rpc_reachable in MODE A
|
||||
assert fleet.validators == 2 # derived from node count
|
||||
assert fleet.version == "luxd/1.30.40"
|
||||
assert fleet.total_peers == 14
|
||||
|
||||
|
||||
class TestRpcParsing:
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_parses_rpc_results(self):
|
||||
"""_probe_node_rpc maps luxd JSON-RPC into the node model."""
|
||||
import httpx
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
body = request.read().decode()
|
||||
if "info.getNodeVersion" in body:
|
||||
return httpx.Response(200, json={"result": {
|
||||
"version": "luxd/1.30.39", "gitCommit": "abc123",
|
||||
"databaseVersion": "v1.4.5"}})
|
||||
if "info.getNodeID" in body:
|
||||
return httpx.Response(200, json={"result": {"nodeID": "NodeID-XYZ"}})
|
||||
if "info.peers" in body:
|
||||
return httpx.Response(200, json={"result": {"peers": [1, 2, 3, 4]}})
|
||||
if "info.isBootstrapped" in body:
|
||||
ch = "C" if '"C"' in body else ("D" if '"D"' in body else "?")
|
||||
return httpx.Response(200, json={"result": {"isBootstrapped": ch != "D"}})
|
||||
if "eth_blockNumber" in body:
|
||||
return httpx.Response(200, json={"result": "0x6c"})
|
||||
if "platform.getHeight" in body:
|
||||
return httpx.Response(200, json={"result": {"height": "42"}})
|
||||
return httpx.Response(404)
|
||||
|
||||
def health_handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"healthy": True})
|
||||
|
||||
def router(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path.endswith("/ext/health"):
|
||||
return health_handler(request)
|
||||
return handler(request)
|
||||
|
||||
transport = httpx.MockTransport(router)
|
||||
node = LuxNode(pod_name="luxd-0", pod_index=0, external_ip="10.0.0.1")
|
||||
|
||||
# Patch AsyncClient to use the mock transport.
|
||||
orig = httpx.AsyncClient
|
||||
|
||||
def patched(*args, **kwargs):
|
||||
kwargs["transport"] = transport
|
||||
kwargs.pop("timeout", None)
|
||||
return orig(*args, **kwargs)
|
||||
|
||||
with patch("bootnode.core.chains.lux_fleet.httpx.AsyncClient", patched):
|
||||
await lux_fleet._probe_node_rpc(node, 9650)
|
||||
|
||||
assert node.rpc_reachable is True
|
||||
assert node.version == "luxd/1.30.39"
|
||||
assert node.git_commit == "abc123"
|
||||
assert node.node_id == "NodeID-XYZ"
|
||||
assert node.peers == 4
|
||||
assert node.healthy is True
|
||||
heights = {c.chain: c for c in node.chains}
|
||||
assert heights["C"].height == 108
|
||||
assert heights["C"].bootstrapped is True
|
||||
assert heights["P"].height == 42
|
||||
assert heights["D"].bootstrapped is False
|
||||
@@ -0,0 +1,165 @@
|
||||
# Lux Fleet API — read-only live overview of the managed luxd fleet.
|
||||
#
|
||||
# A minimal, isolated FastAPI surface (bootnode.fleet_app) reusing the bootnode
|
||||
# api image. It observes the luxd fleet on do-sfo3-lux-k8s strictly READ-ONLY by
|
||||
# querying each validator's public luxd JSON-RPC endpoint (MODE A): node version,
|
||||
# node ID, peers, per-chain bootstrap (C/P/X/D), C-Chain eth_blockNumber, P-Chain
|
||||
# platform.getHeight, and a native D-Chain DEX summary. It serves
|
||||
# GET /v1/fleets/lux/overview — the data source for the lux.cloud fleet dashboard.
|
||||
#
|
||||
# No lux-k8s credential is needed and nothing on the luxd nets is touched: the
|
||||
# per-pod RPC endpoints are public infra. Endpoints are operational config in a
|
||||
# ConfigMap (updatable without a rebuild). If a READ-ONLY lux-k8s kubeconfig is
|
||||
# later provisioned (LUX_K8S_KUBECONFIG), the service auto-enriches with CR data
|
||||
# (declared validators, image tag, plugin-set) — MODE B.
|
||||
#
|
||||
# Why a separate deployment: the read-only fleet view is orthogonal to the api
|
||||
# monolith and depends only on the self-contained collector, so it ships and
|
||||
# scales independently behind the same api.cloud.lux.network ingress.
|
||||
|
||||
---
|
||||
# Per-pod luxd RPC endpoints (public LoadBalancer IPs). Operational config, not
|
||||
# secrets. Edit + rollout to update without rebuilding the image.
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: lux-fleet-endpoints
|
||||
namespace: bootnode
|
||||
data:
|
||||
endpoints.json: |
|
||||
{
|
||||
"mainnet": {"http_port": 9630, "nodes": [
|
||||
{"index": 0, "ip": "129.212.164.46"},
|
||||
{"index": 1, "ip": "137.184.246.133"},
|
||||
{"index": 2, "ip": "143.244.210.43"},
|
||||
{"index": 3, "ip": "143.244.210.71"},
|
||||
{"index": 4, "ip": "24.144.70.85"}
|
||||
]},
|
||||
"testnet": {"http_port": 9640, "nodes": [
|
||||
{"index": 0, "ip": "134.199.187.16"},
|
||||
{"index": 1, "ip": "209.38.174.84"},
|
||||
{"index": 2, "ip": "209.38.173.80"},
|
||||
{"index": 3, "ip": "134.199.187.62"},
|
||||
{"index": 4, "ip": "164.90.247.204"}
|
||||
]},
|
||||
"devnet": {"http_port": 9650, "nodes": [
|
||||
{"index": 0, "ip": "164.90.247.220"},
|
||||
{"index": 1, "ip": "146.190.14.112"},
|
||||
{"index": 2, "ip": "24.199.68.130"},
|
||||
{"index": 3, "ip": "134.199.184.181"},
|
||||
{"index": 4, "ip": "209.38.172.213"}
|
||||
]}
|
||||
}
|
||||
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: lux-fleet
|
||||
namespace: bootnode
|
||||
labels:
|
||||
app: lux-fleet
|
||||
app.kubernetes.io/part-of: bootnode
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: lux-fleet
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: lux-fleet
|
||||
app.kubernetes.io/part-of: bootnode
|
||||
spec:
|
||||
# GHCR pull creds come from the default ServiceAccount (ghcr-pull),
|
||||
# matching the other bootnode deployments.
|
||||
containers:
|
||||
- name: lux-fleet
|
||||
# Reuses the bootnode api image (built by cicd.yml → ghcr.io/bootnode/bootnode).
|
||||
# Pin to the api tag carrying bootnode.fleet_app at deploy time.
|
||||
image: ghcr.io/bootnode/bootnode:api
|
||||
imagePullPolicy: Always
|
||||
command: ["uvicorn", "bootnode.fleet_app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
name: http
|
||||
env:
|
||||
- name: LUX_FLEET_ENDPOINTS_FILE
|
||||
value: /etc/lux-fleet/endpoints.json
|
||||
- name: ALLOWED_ORIGINS
|
||||
value: "https://lux.cloud,https://www.lux.cloud,https://cloud.lux.network"
|
||||
volumeMounts:
|
||||
- name: endpoints
|
||||
mountPath: /etc/lux-fleet
|
||||
readOnly: true
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 15
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8000
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 192Mi
|
||||
limits:
|
||||
cpu: 750m
|
||||
memory: 512Mi
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
allowPrivilegeEscalation: false
|
||||
volumes:
|
||||
- name: endpoints
|
||||
configMap:
|
||||
name: lux-fleet-endpoints
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: lux-fleet
|
||||
namespace: bootnode
|
||||
labels:
|
||||
app: lux-fleet
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: lux-fleet
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8000
|
||||
name: http
|
||||
|
||||
---
|
||||
# Route only the read-only overview path to the fleet service. All other
|
||||
# api.cloud.lux.network paths continue to the main api (web3-platform-api).
|
||||
# Higher-specificity path wins under nginx ingress. TLS for this host is
|
||||
# already owned/terminated by the existing api ingress (cloud-lux-network-api),
|
||||
# so this object only adds the path rule and CORS — no duplicate cert.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: lux-fleet
|
||||
namespace: bootnode
|
||||
# CORS is handled by the app (fleet_app CORSMiddleware) to avoid duplicate
|
||||
# Access-Control-* headers — do not also enable nginx CORS here.
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
rules:
|
||||
- host: api.cloud.lux.network
|
||||
http:
|
||||
paths:
|
||||
- path: /v1/fleets/lux/overview
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: lux-fleet
|
||||
port:
|
||||
number: 80
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Metadata } from "next"
|
||||
import { Navbar } from "@/components/navbar"
|
||||
import { Footer } from "@/components/footer"
|
||||
import { FleetDashboard } from "@/components/lux/fleet-dashboard"
|
||||
import { getBrand } from "@/lib/brand"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Node Fleet — Lux Cloud",
|
||||
description:
|
||||
"Live status of the managed luxd validator fleet across devnet, testnet and mainnet: health, peers, per-chain block heights, version, plugin set and DEX.",
|
||||
}
|
||||
|
||||
export default function FleetPage() {
|
||||
const brand = getBrand()
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Navbar />
|
||||
<section className="relative border-b bg-gradient-to-b from-background to-muted/20">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#4f4f4f2e_1px,transparent_1px),linear-gradient(to_bottom,#4f4f4f2e_1px,transparent_1px)] bg-[size:14px_24px] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)]" />
|
||||
<div className="container relative py-12 md:py-16">
|
||||
<div className="max-w-3xl">
|
||||
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
{brand.name}{" "}
|
||||
<span
|
||||
className="bg-clip-text text-transparent"
|
||||
style={{ backgroundImage: `linear-gradient(to right, ${brand.colors.accent}, ${brand.colors.accentEnd})` }}
|
||||
>
|
||||
Fleet
|
||||
</span>
|
||||
</h1>
|
||||
<p className="mt-3 text-muted-foreground">
|
||||
Real-time view of the managed luxd node fleet. Health, peer counts,
|
||||
per-chain block heights, software version and the native DEX — read
|
||||
live from each node, across every network.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main className="container flex-1 py-10">
|
||||
<FleetDashboard />
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { Navbar } from "@/components/navbar"
|
||||
import { Footer } from "@/components/footer"
|
||||
import { ChainGrid } from "@/components/chain-grid"
|
||||
import { CodeDemo } from "@/components/code-demo"
|
||||
import { FleetStrip } from "@/components/lux/fleet-strip"
|
||||
import { getBrand } from "@/lib/brand"
|
||||
import {
|
||||
ArrowRight,
|
||||
@@ -62,6 +63,8 @@ export default function HomePage() {
|
||||
<Link href="/docs">View Documentation</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{/* Live fleet status — surfaces the managed node fleet on the landing */}
|
||||
<FleetStrip />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Circle,
|
||||
Cpu,
|
||||
Heart,
|
||||
Loader2,
|
||||
Network as NetworkIcon,
|
||||
Package,
|
||||
RefreshCcw,
|
||||
Server,
|
||||
Users,
|
||||
} from "lucide-react"
|
||||
import { FleetMap, type FleetRegion } from "@/components/lux/fleet-map"
|
||||
import {
|
||||
CHAIN_LABEL,
|
||||
NETWORK_LABEL,
|
||||
TRACKED_CHAINS,
|
||||
bestHeight,
|
||||
chainBootstrapped,
|
||||
fetchFleetOverview,
|
||||
fleetStatus,
|
||||
nodeHealth,
|
||||
type FleetNetworkName,
|
||||
type LuxFleetOverview,
|
||||
type LuxNetworkFleet,
|
||||
type LuxNode,
|
||||
} from "@/lib/fleet"
|
||||
|
||||
const REFRESH_MS = 15_000
|
||||
|
||||
const NETWORK_ACCENT: Record<FleetNetworkName, 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",
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<string, { cls: string; label: string }> = {
|
||||
operational: { cls: "border-green-500/40 text-green-400 bg-green-500/10", label: "Operational" },
|
||||
degraded: { cls: "border-yellow-500/40 text-yellow-400 bg-yellow-500/10", label: "Degraded" },
|
||||
down: { cls: "border-red-500/40 text-red-400 bg-red-500/10", label: "Down" },
|
||||
}
|
||||
|
||||
function StatNumber({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
suffix,
|
||||
accent,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
value: number | string | null | undefined
|
||||
suffix?: string
|
||||
accent?: string
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={`h-5 w-5 ${accent ?? "text-muted-foreground"}`} />
|
||||
<span className={`text-2xl font-bold ${accent ?? ""}`}>
|
||||
{value ?? "—"}
|
||||
{suffix && value != null ? <span className="text-base text-muted-foreground">{suffix}</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function healthDot(h: ReturnType<typeof nodeHealth>) {
|
||||
const cls =
|
||||
h === "healthy"
|
||||
? "text-green-500"
|
||||
: h === "syncing"
|
||||
? "text-yellow-500"
|
||||
: h === "down"
|
||||
? "text-red-500"
|
||||
: "text-zinc-500"
|
||||
return <Circle className={`h-2.5 w-2.5 fill-current ${cls}`} />
|
||||
}
|
||||
|
||||
function boolBadge(v: boolean | null, trueLabel = "Yes", falseLabel = "No") {
|
||||
if (v == null) return <span className="text-xs text-muted-foreground">—</span>
|
||||
return (
|
||||
<Badge variant={v ? "success" : "warning"} className="text-[10px]">
|
||||
{v ? trueLabel : falseLabel}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function NodeTable({ network, nodes }: { network: FleetNetworkName; nodes: LuxNode[] }) {
|
||||
if (nodes.length === 0) {
|
||||
return (
|
||||
<p className="p-6 text-center text-sm text-muted-foreground">
|
||||
No luxd pods reported for {NETWORK_LABEL[network]}.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Node</TableHead>
|
||||
<TableHead>Node ID</TableHead>
|
||||
<TableHead>Version</TableHead>
|
||||
<TableHead className="text-right">Peers</TableHead>
|
||||
<TableHead className="text-right">C-Chain</TableHead>
|
||||
<TableHead className="text-center">C / P / X / D</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{nodes.map((n) => {
|
||||
const c = n.chains.find((x) => x.chain === "C")
|
||||
return (
|
||||
<TableRow key={n.pod_name}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{healthDot(nodeHealth(n))}
|
||||
<span className="font-mono text-xs">{n.pod_name}</span>
|
||||
{!n.rpc_reachable && n.ready ? (
|
||||
<span className="text-[10px] text-muted-foreground">(internal RPC)</span>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-mono text-[11px] text-muted-foreground">
|
||||
{n.node_id ? `${n.node_id.slice(0, 18)}…` : "—"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-mono text-xs">{n.version ?? "—"}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">{n.peers ?? "—"}</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">
|
||||
{c?.height != null ? c.height.toLocaleString() : "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{TRACKED_CHAINS.map((ch) => {
|
||||
const cc = n.chains.find((x) => x.chain === ch)
|
||||
const cls =
|
||||
cc?.bootstrapped === true
|
||||
? "bg-green-500"
|
||||
: cc?.bootstrapped === false
|
||||
? "bg-yellow-500"
|
||||
: "bg-zinc-700"
|
||||
return (
|
||||
<span
|
||||
key={ch}
|
||||
title={`${CHAIN_LABEL[ch]}: ${
|
||||
cc?.bootstrapped === true
|
||||
? "bootstrapped"
|
||||
: cc?.bootstrapped === false
|
||||
? "syncing"
|
||||
: "unknown"
|
||||
}`}
|
||||
className={`inline-block h-2.5 w-2.5 rounded-sm ${cls}`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NetworkCard({ fleet }: { fleet: LuxNetworkFleet }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const status = fleetStatus(fleet)
|
||||
const sb = STATUS_BADGE[status]
|
||||
const cHeight = bestHeight(fleet, "C")
|
||||
const dexBoot = fleet.dex.d_chain_bootstrapped ?? chainBootstrapped(fleet, "D")
|
||||
|
||||
return (
|
||||
<Card className="border-zinc-800">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-label={open ? "Collapse nodes" : "Expand nodes"}
|
||||
>
|
||||
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</Button>
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{NETWORK_LABEL[fleet.network]}
|
||||
<Badge variant="outline" className={NETWORK_ACCENT[fleet.network]}>
|
||||
network {fleet.network_id ?? "—"}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1 font-mono text-xs">
|
||||
{fleet.version ?? fleet.image ?? "version unknown"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge variant="outline" className={sb.cls}>
|
||||
{status === "operational" ? (
|
||||
<Circle className="mr-1 h-2 w-2 fill-green-500" />
|
||||
) : status === "degraded" ? (
|
||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||
) : (
|
||||
<Circle className="mr-1 h-2 w-2 fill-red-500" />
|
||||
)}
|
||||
{sb.label}
|
||||
</Badge>
|
||||
<div className="text-right text-sm">
|
||||
<span className="font-bold">{fleet.nodes_ready}</span>
|
||||
<span className="text-muted-foreground">/{fleet.validators || fleet.nodes_total} validators</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{/* Quick facts row */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Fact icon={Server} label="Nodes ready" value={`${fleet.nodes_ready}/${fleet.nodes_total}`} />
|
||||
<Fact icon={Users} label="Peers" value={fleet.total_peers} />
|
||||
<Fact icon={Activity} label="C-Chain height" value={cHeight != null ? cHeight.toLocaleString() : "—"} />
|
||||
<Fact
|
||||
icon={Cpu}
|
||||
label="DEX (D-Chain)"
|
||||
value={dexBoot === true ? "Live" : dexBoot === false ? "Syncing" : "—"}
|
||||
valueClass={dexBoot === true ? "text-green-400" : dexBoot === false ? "text-yellow-400" : ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Plugin set + chains bootstrapped */}
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-zinc-800 pt-3">
|
||||
{fleet.plugin_set ? (
|
||||
<Badge variant="secondary" className="font-mono text-[10px]">
|
||||
<Package className="mr-1 h-3 w-3" />
|
||||
{fleet.plugin_set.replace(/^s3:\/\//, "")}
|
||||
</Badge>
|
||||
) : null}
|
||||
{TRACKED_CHAINS.map((ch) => {
|
||||
const b = chainBootstrapped(fleet, ch)
|
||||
return (
|
||||
<span key={ch} className="flex items-center gap-1 text-xs">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full ${
|
||||
b === true ? "bg-green-500" : b === false ? "bg-yellow-500" : "bg-zinc-700"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-muted-foreground">{CHAIN_LABEL[ch]}</span>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
{fleet.dex.markets != null ? (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{fleet.dex.markets} markets
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{fleet.error ? (
|
||||
<div className="rounded-md bg-yellow-500/10 p-3 text-xs text-yellow-500">{fleet.error}</div>
|
||||
) : null}
|
||||
|
||||
{open ? (
|
||||
<div className="border-t border-zinc-800 pt-3">
|
||||
<NodeTable network={fleet.network} nodes={fleet.nodes} />
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function Fact({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
valueClass,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
value: React.ReactNode
|
||||
valueClass?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 p-3">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
</div>
|
||||
<div className={`mt-1 font-mono text-lg font-semibold ${valueClass ?? ""}`}>{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Build map regions from the fleet (all luxd nodes shown at the sfo3 region). */
|
||||
function toRegions(overview: LuxFleetOverview | null): FleetRegion[] {
|
||||
if (!overview) return []
|
||||
const total = overview.networks.reduce((s, f) => s + f.nodes_total, 0)
|
||||
const healthy = overview.networks.reduce((s, f) => s + f.nodes_ready, 0)
|
||||
if (total === 0) return []
|
||||
// The managed fleet runs in DigitalOcean SFO3 (San Francisco).
|
||||
return [
|
||||
{
|
||||
slug: "sfo3",
|
||||
name: "SFO3 · San Francisco",
|
||||
lat: 37.77,
|
||||
lon: -122.42,
|
||||
active: healthy > 0,
|
||||
nodeCount: total,
|
||||
healthyNodes: healthy,
|
||||
networks: overview.networks.map((f) => f.network),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function FleetDashboard({ compact = false }: { compact?: boolean }) {
|
||||
const [overview, setOverview] = useState<LuxFleetOverview | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [updatedAt, setUpdatedAt] = useState<Date | null>(null)
|
||||
|
||||
const load = useCallback(async (silent = false) => {
|
||||
if (!silent) setRefreshing(true)
|
||||
const ctrl = new AbortController()
|
||||
try {
|
||||
const data = await fetchFleetOverview(ctrl.signal)
|
||||
setOverview(data)
|
||||
setError(null)
|
||||
setUpdatedAt(new Date())
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
const t = setInterval(() => load(true), REFRESH_MS)
|
||||
return () => clearInterval(t)
|
||||
}, [load])
|
||||
|
||||
const totals = overview?.totals ?? {}
|
||||
const regions = useMemo(() => toRegions(overview), [overview])
|
||||
|
||||
if (loading && !overview) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-16">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Node Fleet</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Live status of the managed luxd fleet across devnet, testnet and mainnet
|
||||
{updatedAt ? ` · updated ${updatedAt.toLocaleTimeString()}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => load()} disabled={refreshing}>
|
||||
<RefreshCcw className={`mr-2 h-4 w-4 ${refreshing ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && !overview ? (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="rounded-md bg-destructive/10 p-4 text-sm text-destructive">{error}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/* Totals */}
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<StatNumber icon={NetworkIcon} label="Networks" value={totals.networks ?? overview?.networks.length} />
|
||||
<StatNumber icon={Server} label="Validators" value={totals.validators} />
|
||||
<StatNumber
|
||||
icon={Heart}
|
||||
label="Nodes ready"
|
||||
value={totals.nodes_ready}
|
||||
suffix={totals.nodes_total ? ` / ${totals.nodes_total}` : undefined}
|
||||
accent="text-green-500"
|
||||
/>
|
||||
<StatNumber icon={Users} label="Peer connections" value={totals.total_peers} />
|
||||
</div>
|
||||
|
||||
{/* World map (only when not compact) */}
|
||||
{!compact && regions.length > 0 ? (
|
||||
<Card className="border-zinc-800 overflow-hidden">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Fleet topology</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<FleetMap regions={regions} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/* Per-network cards */}
|
||||
<div className="space-y-4">
|
||||
{overview?.networks.map((f) => (
|
||||
<NetworkCard key={f.network} fleet={f} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowRight, Circle } from "lucide-react"
|
||||
import {
|
||||
NETWORK_LABEL,
|
||||
fetchFleetOverview,
|
||||
fleetStatus,
|
||||
type LuxFleetOverview,
|
||||
} from "@/lib/fleet"
|
||||
|
||||
const DOT: Record<string, string> = {
|
||||
operational: "text-green-500",
|
||||
degraded: "text-yellow-500",
|
||||
down: "text-red-500",
|
||||
}
|
||||
|
||||
/** Compact live fleet status strip for the landing hero. Public, read-only. */
|
||||
export function FleetStrip() {
|
||||
const [ov, setOv] = useState<LuxFleetOverview | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
const ctrl = new AbortController()
|
||||
const load = () =>
|
||||
fetchFleetOverview(ctrl.signal)
|
||||
.then((d) => alive && setOv(d))
|
||||
.catch(() => {})
|
||||
load()
|
||||
const t = setInterval(load, 20_000)
|
||||
return () => {
|
||||
alive = false
|
||||
ctrl.abort()
|
||||
clearInterval(t)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!ov) return null
|
||||
|
||||
const totals = ov.totals ?? {}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="/fleet"
|
||||
className="group mt-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-2 rounded-xl border border-zinc-800 bg-background/60 px-5 py-3 backdrop-blur transition-colors hover:border-zinc-700"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-sm font-medium">
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-500 opacity-60" />
|
||||
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-green-500" />
|
||||
</span>
|
||||
Live fleet
|
||||
</span>
|
||||
{ov.networks.map((f) => {
|
||||
const s = fleetStatus(f)
|
||||
return (
|
||||
<span key={f.network} className="flex items-center gap-1.5 text-sm">
|
||||
<Circle className={`h-2 w-2 fill-current ${DOT[s]}`} />
|
||||
<span className="text-muted-foreground">{NETWORK_LABEL[f.network]}</span>
|
||||
<span className="font-mono">
|
||||
{f.nodes_ready}/{f.validators || f.nodes_total}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
<span className="hidden items-center gap-1.5 text-sm text-muted-foreground sm:flex">
|
||||
<span className="font-mono text-foreground">{totals.total_peers ?? 0}</span> peers
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-sm text-primary opacity-80 group-hover:opacity-100">
|
||||
View fleet
|
||||
<ArrowRight className="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" />
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -375,6 +375,13 @@ export function Navbar() {
|
||||
{(closeMenu) => <SolutionsContent closeMenu={closeMenu} />}
|
||||
</NavMenu>
|
||||
|
||||
<Link
|
||||
href="/fleet"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors text-sm font-medium"
|
||||
>
|
||||
Fleet
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/docs"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors text-sm font-medium"
|
||||
@@ -439,6 +446,9 @@ export function Navbar() {
|
||||
Webhooks
|
||||
</Link>
|
||||
<div className="border-t pt-4 mt-2" />
|
||||
<Link href="/fleet" className="text-sm font-medium" onClick={() => setIsOpen(false)}>
|
||||
Fleet
|
||||
</Link>
|
||||
<Link href="/docs" className="text-sm font-medium" onClick={() => setIsOpen(false)}>
|
||||
Docs
|
||||
</Link>
|
||||
|
||||
@@ -91,6 +91,7 @@ export function UnifiedNavigation() {
|
||||
icon: Server,
|
||||
description: "Manage nodes, networks, services, and infrastructure",
|
||||
children: [
|
||||
{ name: "Lux Fleet", href: "/dashboard/infrastructure/lux", icon: Shield },
|
||||
{ name: "Nodes", href: "/dashboard/infrastructure/nodes", icon: Server },
|
||||
{ name: "Networks", href: "/dashboard/infrastructure/networks", icon: Network },
|
||||
{ name: "Services", href: "/dashboard/infrastructure/services", icon: Boxes },
|
||||
|
||||
+5
-5
@@ -50,6 +50,7 @@ export type BrandConfig = {
|
||||
const brands: Record<string, BrandConfig> = {
|
||||
bootnode: {
|
||||
name: "Bootnode",
|
||||
skillsOrg: "hanzo",
|
||||
tagline: "Blockchain Infrastructure for Developers",
|
||||
description: "The complete blockchain development platform. Multi-chain RPC, Token APIs, NFT APIs, Smart Wallets, Webhooks, and more.",
|
||||
logo: "/logo/bootnode-logo.svg",
|
||||
@@ -57,7 +58,6 @@ const brands: Record<string, BrandConfig> = {
|
||||
logoWhite: "/logo/bootnode-logo-white.svg",
|
||||
favicon: "/logo/bootnode-icon.svg",
|
||||
domain: "bootno.de",
|
||||
skillsOrg: "bootnode",
|
||||
apiUrl: "https://api.bootno.de",
|
||||
wsUrl: "wss://ws.bootno.de",
|
||||
statusUrl: "https://status.bootno.de",
|
||||
@@ -85,6 +85,7 @@ const brands: Record<string, BrandConfig> = {
|
||||
},
|
||||
hanzo: {
|
||||
name: "Hanzo Web3",
|
||||
skillsOrg: "hanzo",
|
||||
tagline: "Enterprise Blockchain Infrastructure",
|
||||
description: "Enterprise blockchain infrastructure powered by Hanzo AI. Multi-chain RPC, Token APIs, NFT APIs, Smart Wallets, and more.",
|
||||
logo: "/logo/hanzo-logo.svg",
|
||||
@@ -92,7 +93,6 @@ const brands: Record<string, BrandConfig> = {
|
||||
logoWhite: "/logo/hanzo-logo-white.svg",
|
||||
favicon: "/logo/hanzo-icon.svg",
|
||||
domain: "web3.hanzo.ai",
|
||||
skillsOrg: "hanzoai",
|
||||
apiUrl: "https://api.cloud.hanzo.ai",
|
||||
wsUrl: "wss://ws.cloud.hanzo.ai",
|
||||
statusUrl: "https://status.hanzo.ai",
|
||||
@@ -127,6 +127,7 @@ const brands: Record<string, BrandConfig> = {
|
||||
},
|
||||
lux: {
|
||||
name: "Lux Cloud",
|
||||
skillsOrg: "luxfi",
|
||||
tagline: "Next-Gen Blockchain Infrastructure",
|
||||
description: "High-performance blockchain infrastructure for the Lux Network ecosystem.",
|
||||
logo: "/logo/lux-wordmark-dark.svg",
|
||||
@@ -134,7 +135,6 @@ const brands: Record<string, BrandConfig> = {
|
||||
logoWhite: "/logo/lux-wordmark-white.svg",
|
||||
favicon: "/logo/lux-icon.svg",
|
||||
domain: "cloud.lux.network",
|
||||
skillsOrg: "luxfi",
|
||||
apiUrl: "https://api.cloud.lux.network",
|
||||
wsUrl: "wss://ws.cloud.lux.network",
|
||||
statusUrl: "https://status.lux.network",
|
||||
@@ -178,6 +178,7 @@ const brands: Record<string, BrandConfig> = {
|
||||
},
|
||||
pars: {
|
||||
name: "Pars Cloud",
|
||||
skillsOrg: "parsdao",
|
||||
tagline: "Sovereign L1 Infrastructure",
|
||||
description: "Sovereign L1 blockchain infrastructure for the Pars Network. Post-quantum secure, SessionVM messaging, native DEX, and more.",
|
||||
logo: "/logo/pars-logo.svg",
|
||||
@@ -185,7 +186,6 @@ const brands: Record<string, BrandConfig> = {
|
||||
logoWhite: "/logo/pars-logo-white.svg",
|
||||
favicon: "/logo/pars-icon.svg",
|
||||
domain: "cloud.pars.network",
|
||||
skillsOrg: "pars-network",
|
||||
apiUrl: "https://api.cloud.pars.network",
|
||||
wsUrl: "wss://ws.cloud.pars.network",
|
||||
statusUrl: "https://status.pars.network",
|
||||
@@ -221,6 +221,7 @@ const brands: Record<string, BrandConfig> = {
|
||||
},
|
||||
zoo: {
|
||||
name: "Zoo Labs",
|
||||
skillsOrg: "zooai",
|
||||
tagline: "Decentralized AI Infrastructure",
|
||||
description: "Blockchain infrastructure for decentralized AI and science research.",
|
||||
logo: "/logo/zoo-logo.svg",
|
||||
@@ -228,7 +229,6 @@ const brands: Record<string, BrandConfig> = {
|
||||
logoWhite: "/logo/zoo-logo-white.svg",
|
||||
favicon: "/logo/zoo-icon.svg",
|
||||
domain: "web3.zoo.ngo",
|
||||
skillsOrg: "zooai",
|
||||
apiUrl: "https://api.web3.zoo.ngo",
|
||||
wsUrl: "wss://ws.web3.zoo.ngo",
|
||||
statusUrl: "https://status.zoo.ngo",
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// Read-only Lux fleet overview client.
|
||||
//
|
||||
// Fetches the public fleet overview (GET /v1/fleets/lux/overview) served by the
|
||||
// read-only fleet API. The endpoint exposes only public chain data — no auth,
|
||||
// no secrets — so the lux.cloud landing + fleet dashboard can render it freely.
|
||||
|
||||
import { getApiUrl } from "@/lib/api"
|
||||
import { getBrand } from "@/lib/brand"
|
||||
|
||||
/**
|
||||
* Resolve the API base for the active brand.
|
||||
*
|
||||
* The web image is brand-agnostic, so the build-time NEXT_PUBLIC_API_URL bakes
|
||||
* the wrong host for the browser. The brand config (resolved at runtime by
|
||||
* hostname) is the canonical per-brand API URL — e.g. lux.cloud → api.cloud.lux.network.
|
||||
* Fall back to getApiUrl() (build-time env) when no brand URL is present.
|
||||
*/
|
||||
function apiBase(): string {
|
||||
const brandUrl = getBrand().apiUrl
|
||||
if (brandUrl) return brandUrl.replace(/\/$/, "")
|
||||
return getApiUrl()
|
||||
}
|
||||
|
||||
export interface ChainHeight {
|
||||
chain: string
|
||||
bootstrapped: boolean | null
|
||||
height: number | null
|
||||
}
|
||||
|
||||
export interface LuxNode {
|
||||
pod_name: string
|
||||
pod_index: number
|
||||
phase: string
|
||||
ready: boolean
|
||||
external_ip: string | null
|
||||
rpc_reachable: boolean
|
||||
node_id: string | null
|
||||
version: string | null
|
||||
git_commit: string | null
|
||||
db_version: string | null
|
||||
peers: number | null
|
||||
healthy: boolean | null
|
||||
chains: ChainHeight[]
|
||||
}
|
||||
|
||||
export interface DexSummary {
|
||||
d_chain_bootstrapped: boolean | null
|
||||
venue: string
|
||||
markets: number | null
|
||||
note: string | null
|
||||
}
|
||||
|
||||
export type FleetNetworkName = "mainnet" | "testnet" | "devnet"
|
||||
|
||||
export interface LuxNetworkFleet {
|
||||
network: FleetNetworkName
|
||||
namespace: string
|
||||
network_id: number | null
|
||||
chain_id: number | null
|
||||
phase: string
|
||||
image: string | null
|
||||
version: string | null
|
||||
plugin_set: string | null
|
||||
vm_plugins: string[]
|
||||
validators: number
|
||||
nodes_total: number
|
||||
nodes_ready: number
|
||||
nodes_rpc_ok: number
|
||||
total_peers: number
|
||||
dex: DexSummary
|
||||
nodes: LuxNode[]
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface LuxFleetOverview {
|
||||
cluster: string
|
||||
networks: LuxNetworkFleet[]
|
||||
totals: Record<string, number>
|
||||
generated_at: number
|
||||
}
|
||||
|
||||
/** Fetch the read-only fleet overview. Unauthenticated. */
|
||||
export async function fetchFleetOverview(signal?: AbortSignal): Promise<LuxFleetOverview> {
|
||||
const res = await fetch(`${apiBase()}/v1/fleets/lux/overview`, {
|
||||
signal,
|
||||
headers: { Accept: "application/json" },
|
||||
cache: "no-store",
|
||||
})
|
||||
if (!res.ok) throw new Error(`fleet overview: HTTP ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived display helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type NodeHealth = "healthy" | "syncing" | "down" | "unknown"
|
||||
|
||||
/** Classify a node for the status dot. */
|
||||
export function nodeHealth(n: LuxNode): NodeHealth {
|
||||
if (n.rpc_reachable) {
|
||||
const c = n.chains.find((x) => x.chain === "C")
|
||||
if (c?.bootstrapped === false) return "syncing"
|
||||
return "healthy"
|
||||
}
|
||||
if (n.phase === "Running" && n.ready) return "unknown" // up but RPC not exposed
|
||||
if (n.phase === "Pending" || n.phase === "ContainerCreating") return "syncing"
|
||||
return "down"
|
||||
}
|
||||
|
||||
/** Network-level rollup status from its nodes. */
|
||||
export function fleetStatus(f: LuxNetworkFleet): "operational" | "degraded" | "down" {
|
||||
if (f.nodes_total === 0) return "down"
|
||||
if (f.nodes_ready === 0) return "down"
|
||||
if (f.nodes_ready < f.validators || f.nodes_ready < f.nodes_total) return "degraded"
|
||||
return "operational"
|
||||
}
|
||||
|
||||
/** Best per-chain height across the network's RPC-reachable nodes. */
|
||||
export function bestHeight(f: LuxNetworkFleet, chain: string): number | null {
|
||||
let best: number | null = null
|
||||
for (const n of f.nodes) {
|
||||
const c = n.chains.find((x) => x.chain === chain)
|
||||
if (c?.height != null) best = best == null ? c.height : Math.max(best, c.height)
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/** Whether any node reports the chain bootstrapped. */
|
||||
export function chainBootstrapped(f: LuxNetworkFleet, chain: string): boolean | null {
|
||||
let seen = false
|
||||
for (const n of f.nodes) {
|
||||
const c = n.chains.find((x) => x.chain === chain)
|
||||
if (c?.bootstrapped === true) return true
|
||||
if (c?.bootstrapped != null) seen = true
|
||||
}
|
||||
return seen ? false : null
|
||||
}
|
||||
|
||||
export const NETWORK_LABEL: Record<FleetNetworkName, string> = {
|
||||
mainnet: "Mainnet",
|
||||
testnet: "Testnet",
|
||||
devnet: "Devnet",
|
||||
}
|
||||
|
||||
export const TRACKED_CHAINS = ["C", "P", "X", "D"] as const
|
||||
export const CHAIN_LABEL: Record<string, string> = {
|
||||
C: "C-Chain",
|
||||
P: "P-Chain",
|
||||
X: "X-Chain",
|
||||
D: "D-Chain (DEX)",
|
||||
}
|
||||
Reference in New Issue
Block a user