mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-10-25 02:31:30 +00:00
luxd serves /v1 only — server/http/server.go pins baseURL="/v1" — so every /ext call in this repo was a silent no-op. The fleet collector's _rpc() returns None on non-200, so the lux.cloud dashboard rendered nodes_rpc_ok:0, version:null and every node rpc_reachable:false while the fleet was in fact running luxd/1.36.2 (mainnet), 1.36.24 (testnet), 1.36.25 (devnet). Fixing the path alone would not have helped: the collector addressed each validator by a hardcoded public LoadBalancer IP, and there are now zero LoadBalancer Services in lux-mainnet/testnet/devnet. Those IPs time out. So node addressing is no longer configured — it is derived from the cluster on every collection, and the one way to reach a validator is the lux-k8s API server's pod proxy, since luxd RPC is cluster-internal. That collapses the old MODE A / MODE B split into a single path: read the CR for the declared shape, the pods for phase/readiness/IP, then each pod's JSON-RPC through the proxy. One credential authenticates both. Deleting the endpoint ConfigMap and its 15 baked-in IPs removes the failure mode where infrastructure moves and a dashboard quietly reports zeros. Probes move to /v1/health/liveness, not /v1/health: a degraded-but-running validator answers /v1/health with 503, so probing it would restart nodes that are merely behind. Any fleet from charts/lux could never have gone Ready. LuxNode.external_ip becomes .ip — with no public per-pod endpoint, the honest value is the pod IP. Avalanche endpoints keep /ext; that is their API, not ours. Measured, with controls: pod-proxy POST /v1/info -> 200 on all 15 pods; /ext/health -> 404; /zzz/health -> 404; nonexistent pod -> 404. A test asserts every probe path is under /v1 and fails if /ext returns. Co-authored-by: Hanzo Dev <[email protected]>
148 lines
4.3 KiB
TypeScript
148 lines
4.3 KiB
TypeScript
// 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 { apiBase, 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.
|
|
*/
|
|
|
|
export interface ChainHeight {
|
|
chain: string
|
|
bootstrapped: boolean | null
|
|
height: number | null
|
|
}
|
|
|
|
export interface LuxNode {
|
|
pod_name: string
|
|
pod_index: number
|
|
phase: string
|
|
ready: boolean
|
|
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)",
|
|
}
|