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:
2026-07-25 12:35:14 -07:00
parent a698acc743
commit d5fbe67fad
13 changed files with 1820 additions and 5 deletions
+19
View File
@@ -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."""
+574
View File
@@ -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
+69
View File
@@ -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)
+255
View File
@@ -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
+165
View File
@@ -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
+47
View File
@@ -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>
)
}
+3
View File
@@ -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>
+444
View File
@@ -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>
)
}
+76
View File
@@ -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>
)
}
+10
View File
@@ -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>
+1
View File
@@ -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
View File
@@ -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",
+152
View File
@@ -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)",
}