Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7558dd415 |
@@ -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
@@ -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>",
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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('—')
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user