Compare commits

...
1 Commits
Author SHA1 Message Date
zeekayandClaude Opus 4.8 e7558dd415 feat(nodes): surface primary-network chains per network (getBlockchains)
The Nodes surface (Network category, enabled on lux/zoo/pars + hanzo) showed
validators + peers per luxd primary network, but not the network's chains. This
adds the live primary-network chain set — the letter chains X C D Q A B T Z G K
plus the P-Chain — read from `platform.getBlockchains` through the same
same-origin, session-gated, method-allowlisted `/nodes` proxy.

- `/nodes` proxy: `platform.getBlockchains` added as the 5th (and only new)
  allowlisted luxd read method. A network counts as reporting if validators,
  peers, OR chains answered; chains are best-effort (a network can report
  validators yet not answer getBlockchains → honest empty chain list, never
  fabricated chains).
- `nodes.ts`: `RawBlockchain`/`ChainInfo` types + PURE `normalizeChains`
  (prepends the P-Chain, which getBlockchains omits; preserves reported order;
  drops id-less chains). `NetworkInventory.chains` added.
- `NodesModule`: renamed to "Networks & Nodes"; per-network card gains a Chains
  count + live chain chips; a Chains table (Network · Chain · Blockchain ID · VM)
  renders above the validators/peers table, honoring the network filter.
- Tests: +3 normalizeChains cases over the real devnet wire shape (33/33 pass).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 01:52:35 -07:00
5 changed files with 205 additions and 28 deletions
+18 -3
View File
@@ -23,8 +23,10 @@ import { nodeNetworksForBrand, type NodeNetworkId } from '~/lib/products/brand-s
import {
NODE_NETWORK_META,
combineInventory,
normalizeChains,
parseHeight,
type NetworkInventory,
type RawBlockchain,
type RawPeer,
type RawValidator,
} from '~/lib/api/nodes'
@@ -85,21 +87,31 @@ async function probe(net: NodeNetworkId): Promise<NetworkInventory> {
validators: 0,
peers: 0,
nodes: [],
chains: [],
}
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS)
try {
const [valR, peerR, verR, hgtR] = await Promise.allSettled([
const [valR, peerR, verR, hgtR, chainR] = await Promise.allSettled([
rpc<{ validators?: RawValidator[] }>(host, '/ext/bc/P', 'platform.getCurrentValidators', ctrl.signal),
rpc<{ numPeers?: string; peers?: RawPeer[] }>(host, '/ext/info', 'info.peers', ctrl.signal),
rpc<{ version?: string }>(host, '/ext/info', 'info.getNodeVersion', ctrl.signal),
rpc<{ height?: string }>(host, '/ext/bc/P', 'platform.getHeight', ctrl.signal),
rpc<{ blockchains?: RawBlockchain[] }>(host, '/ext/bc/P', 'platform.getBlockchains', ctrl.signal),
])
const reachable = valR.status === 'fulfilled' || peerR.status === 'fulfilled'
const reachable =
valR.status === 'fulfilled' || peerR.status === 'fulfilled' || chainR.status === 'fulfilled'
if (!reachable) {
const reason = valR.status === 'rejected' ? valR.reason : peerR.status === 'rejected' ? peerR.reason : null
const reason =
valR.status === 'rejected'
? valR.reason
: peerR.status === 'rejected'
? peerR.reason
: chainR.status === 'rejected'
? chainR.reason
: null
base.error = reason instanceof Error ? reason.message : 'unreachable'
return base
}
@@ -114,6 +126,9 @@ async function probe(net: NodeNetworkId): Promise<NetworkInventory> {
base.peers = nodes.filter((n) => n.role === 'peer').length
if (verR.status === 'fulfilled') base.version = verR.value?.version
if (hgtR.status === 'fulfilled') base.height = parseHeight(hgtR.value?.height)
// Chains are best-effort: a network can report validators/peers yet not answer
// getBlockchains — then the chains list is honestly empty (no fabricated chains).
if (chainR.status === 'fulfilled') base.chains = normalizeChains(chainR.value?.blockchains)
return base
} catch (e) {
base.error = e instanceof Error ? e.message : String(e)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/console",
"version": "8.4.52",
"version": "8.4.53",
"private": true,
"license": "BSD-3-Clause",
"author": "Hanzo AI <dev@hanzo.ai>",
+105 -24
View File
@@ -40,6 +40,14 @@ const FIELDS: FieldDefinition[] = [
{ name: 'height', label: 'Height', type: 'text', width: 120 },
]
/** Columns for the chains grid — the network's primary-network chains. */
const CHAIN_FIELDS: FieldDefinition[] = [
{ name: 'network', label: 'Network', type: 'text', width: 130 },
{ name: 'name', label: 'Chain', type: 'text', width: 120 },
{ name: 'id', label: 'Blockchain ID', type: 'text', width: 380 },
{ name: 'vmID', label: 'VM', type: 'text', width: 300 },
]
/** A reporting network's block height, shared by every node on it (real, honest). */
type DisplayRow = Record<string, unknown>
@@ -60,11 +68,57 @@ function toRows(inv: NetworkInventory[], filter: 'all' | NodeNetworkId): Display
)
}
/** Summary card for one network — label, health, counts, height (all real). */
/** Flatten reporting networks → one row per chain (real `platform.getBlockchains`). */
function toChainRows(inv: NetworkInventory[], filter: 'all' | NodeNetworkId): DisplayRow[] {
return inv
.filter((n) => n.status === 'reporting' && (filter === 'all' || n.id === filter))
.flatMap((n) =>
n.chains.map((c) => ({
network: n.label,
name: c.name,
id: c.id,
vmID: c.vmID ?? '—',
})),
)
}
/** A short chain label (e.g. "T-Chain" → "T"), for the compact chain chips. */
function chainShort(name: string): string {
const m = /^([A-Za-z])-Chain$/.exec(name)
return m ? m[1] : name
}
/** Compact, real chain chips for a network — the primary-network chains luxd reports. */
function ChainChips({ chains }: { chains: NetworkInventory['chains'] }) {
if (!chains.length) return null
return (
<YStack gap="$1.5">
<Text fontSize="$1" color="$color10">Chains ({chains.length})</Text>
<XStack gap="$1.5" flexWrap="wrap">
{chains.map((c) => (
<XStack
key={c.id}
bg="$color4"
borderWidth={1}
borderColor="$borderColor"
rounded="$3"
px="$2"
py="$0.5"
items="center"
>
<Text fontSize="$2" fontWeight="700">{chainShort(c.name)}</Text>
</XStack>
))}
</XStack>
</YStack>
)
}
/** Summary card for one network — label, health, counts, height, chains (all real). */
function NetworkCard({ n }: { n: NetworkInventory }) {
const reporting = n.status === 'reporting'
return (
<Card borderWidth={1} borderColor="$borderColor" p="$3" gap="$2" minW={220} flex={1}>
<Card borderWidth={1} borderColor="$borderColor" p="$3" gap="$2" minW={240} flex={1}>
<XStack justify="space-between" items="center" gap="$2">
<Text fontSize="$4" fontWeight="700">
{n.label}
@@ -72,20 +126,27 @@ function NetworkCard({ n }: { n: NetworkInventory }) {
<StatusTag status={reporting ? 'active' : 'down'} />
</XStack>
{reporting ? (
<XStack gap="$4" flexWrap="wrap">
<YStack>
<Text fontSize="$1" color="$color10">Validators</Text>
<Text fontSize="$5" fontWeight="800">{n.validators.toLocaleString()}</Text>
</YStack>
<YStack>
<Text fontSize="$1" color="$color10">Peers</Text>
<Text fontSize="$5" fontWeight="800">{n.peers.toLocaleString()}</Text>
</YStack>
<YStack>
<Text fontSize="$1" color="$color10">Height</Text>
<Text fontSize="$5" fontWeight="800">{fmtHeight(n.height)}</Text>
</YStack>
</XStack>
<YStack gap="$3">
<XStack gap="$4" flexWrap="wrap">
<YStack>
<Text fontSize="$1" color="$color10">Chains</Text>
<Text fontSize="$5" fontWeight="800">{n.chains.length ? n.chains.length.toLocaleString() : '—'}</Text>
</YStack>
<YStack>
<Text fontSize="$1" color="$color10">Validators</Text>
<Text fontSize="$5" fontWeight="800">{n.validators.toLocaleString()}</Text>
</YStack>
<YStack>
<Text fontSize="$1" color="$color10">Peers</Text>
<Text fontSize="$5" fontWeight="800">{n.peers.toLocaleString()}</Text>
</YStack>
<YStack>
<Text fontSize="$1" color="$color10">Height</Text>
<Text fontSize="$5" fontWeight="800">{fmtHeight(n.height)}</Text>
</YStack>
</XStack>
<ChainChips chains={n.chains} />
</YStack>
) : (
<Text fontSize="$2" color="$color10">
Not reporting{n.error ? `${n.error}` : ''}
@@ -120,14 +181,15 @@ export function NodesModule(_props: { params: Record<string, string> }) {
const reporting = useMemo(() => inv.filter((n) => n.status === 'reporting'), [inv])
const rows = useMemo(() => toRows(inv, filter), [inv, filter])
const chainRows = useMemo(() => toChainRows(inv, filter), [inv, filter])
const noneReachable = !loading && !state && inv.length > 0 && reporting.length === 0
const nodesIcon = findEntry('nodes')?.icon
return (
<YStack gap="$4">
<PageHeader
title="Nodes"
subtitle="Blockchain node infrastructure — validators and peers across networks. Height is the network P-chain height; uptime is as reported by the queried node."
title="Networks & Nodes"
subtitle="Lux blockchain networks — their primary-network chains, validators, and peers. Chains are the live set the P-chain reports; height is the network P-chain height; uptime is as reported by the queried node."
actions={<Button size="$3" onPress={load} disabled={loading}>Refresh</Button>}
/>
@@ -174,12 +236,31 @@ export function NodesModule(_props: { params: Record<string, string> }) {
description="None of the networks configured for this brand answered their luxd RPC. This view lights up automatically once a network is reachable — no placeholder nodes are shown."
/>
) : (
<DataTable
fields={FIELDS}
records={rows}
loading={loading}
empty="No nodes reported for the selected network(s)."
/>
<>
{chainRows.length > 0 && (
<YStack gap="$2">
<Text fontSize="$5" fontWeight="700">Chains</Text>
<Text fontSize="$2" color="$color10">
The primary-network chains each reporting network runs (live from the P-chain).
</Text>
<DataTable
fields={CHAIN_FIELDS}
records={chainRows}
loading={loading}
empty="No chains reported for the selected network(s)."
/>
</YStack>
)}
<YStack gap="$2">
<Text fontSize="$5" fontWeight="700">Validators & peers</Text>
<DataTable
fields={FIELDS}
records={rows}
loading={loading}
empty="No nodes reported for the selected network(s)."
/>
</YStack>
</>
)}
</>
)}
+33
View File
@@ -10,6 +10,7 @@ import {
normalizeValidators,
normalizePeers,
combineInventory,
normalizeChains,
parseUptimePct,
parseHeight,
fmtUptime,
@@ -17,6 +18,7 @@ import {
fmtWeight,
type RawValidator,
type RawPeer,
type RawBlockchain,
} from './nodes'
import type { Cluster } from './platform'
@@ -262,6 +264,37 @@ describe('combineInventory — dedupe validators+peers by nodeID', () => {
})
})
describe('normalizeChains — platform.getBlockchains → chain list (P prepended)', () => {
// The real live devnet wire shape (the T-model letter chains).
const WIRE: RawBlockchain[] = [
{ id: 'LxQUnwVkZWcfsGigw3qC1EmFWiZK4d9HwxYcYazxadw5pTDyX', name: 'K-Chain', netID: '11111111111111111111111111111111LpoYY', vmID: 'pJJCSV7hHYVY6TUZwR8qUPAfuhX8JLb2C1AzNSezrYNbgau8M' },
{ id: '2H16HhzqZHrUqvoGh59u8ReMeLuyTBJrkf61JnRVp4ZxuAtQ1F', name: 'G-Chain', netID: '11111111111111111111111111111111LpoYY', vmID: 'nZQm4Dmg1rjX18rb8maL9gamYyXPf1xCvF7ymWzxp6a1nSQTt' },
{ id: '25kZyebvQGwtRVS7uiRJcECoEbKf53URfFA4P176QptMj9o8Ti', name: 'A-Chain', netID: '11111111111111111111111111111111LpoYY', vmID: 'juFxSrbCM4wszxddKepj1GWwmrn9YgN1g4n3VUWPpRo9JjERA' },
]
it('prepends the P-Chain and preserves the reported chains in order', () => {
const chains = normalizeChains(WIRE)
expect(chains).toHaveLength(4) // P + 3
expect(chains[0].name).toBe('P-Chain')
expect(chains[0].id).toBe('11111111111111111111111111111111LpoYY')
expect(chains.map((c) => c.name)).toEqual(['P-Chain', 'K-Chain', 'G-Chain', 'A-Chain'])
expect(chains[1].vmID).toBe('pJJCSV7hHYVY6TUZwR8qUPAfuhX8JLb2C1AzNSezrYNbgau8M')
})
it('undefined/empty input → just the P-Chain (never fabricated chains)', () => {
expect(normalizeChains(undefined).map((c) => c.name)).toEqual(['P-Chain'])
expect(normalizeChains([]).map((c) => c.name)).toEqual(['P-Chain'])
})
it('drops a chain with no id (can not be addressed); falls back name→id when name absent', () => {
const chains = normalizeChains([
{ name: 'X-Chain' }, // no id → dropped
{ id: 'abc123' }, // no name → name falls back to id
] as RawBlockchain[])
expect(chains.map((c) => c.name)).toEqual(['P-Chain', 'abc123'])
})
})
describe('node formatters', () => {
it('fmtUptime', () => {
expect(fmtUptime(undefined)).toBe('—')
+48
View File
@@ -186,6 +186,48 @@ export interface RawPeer {
[k: string]: unknown
}
/** Raw blockchain as `platform.getBlockchains` returns it. */
export interface RawBlockchain {
id?: string
name?: string
/** The network (subnet) the chain belongs to — the primary network for all our chains. */
netID?: string
subnetID?: string
vmID?: string
[k: string]: unknown
}
/** One primary-network chain — the identity a chains table renders. PURE view-model. */
export interface ChainInfo {
/** blockchainID (base58check). */
id: string
/** Human name as luxd reports it (e.g. "X-Chain", "T-Chain"). */
name: string
/** The VM the chain runs (vmID), when known. */
vmID?: string
}
/**
* Normalize `platform.getBlockchains` → the chain list for a network. PURE.
*
* The P-Chain (the platform chain the call itself runs against) is NOT returned by
* getBlockchains, so it is prepended as the first, always-present chain — the
* primary network's coordinating chain. Everything else is exactly what luxd
* reports (no fabrication): the letter chains (X C D Q A B T Z G K …) in the order
* the node returns them. A chain with no id is dropped (can't be addressed).
*/
export function normalizeChains(blockchains: RawBlockchain[] | undefined): ChainInfo[] {
const P: ChainInfo = { id: '11111111111111111111111111111111LpoYY', name: 'P-Chain' }
const rest = (blockchains ?? [])
.filter((b): b is RawBlockchain & { id: string } => typeof b.id === 'string' && b.id.length > 0)
.map((b) => ({
id: b.id,
name: typeof b.name === 'string' && b.name ? b.name : b.id,
vmID: typeof b.vmID === 'string' ? b.vmID : undefined,
}))
return [P, ...rest]
}
/**
* luxd uptime string → integer percent, or undefined when absent/unparseable.
* luxd reports uptime as a 0..1 fraction ("0.9950"); some builds report 0..100.
@@ -305,6 +347,12 @@ export interface NetworkInventory {
validators: number
peers: number
nodes: NodeRow[]
/**
* The network's primary-network chains (`platform.getBlockchains`, P-Chain
* prepended). Present only when `reporting`; empty when the network's RPC did
* not answer the getBlockchains call (honest — never a fabricated chain set).
*/
chains: ChainInfo[]
}
/**