Files
zeekayandHanzo Dev da98c0de75 feat(console): one icon set — every glyph comes from @luxfi/ui/icons
74 files imported `lucide-react` directly, pinned ^1.26.0, while @luxfi/ui —
which this console renders alongside — resolves 1.27. Two Lucide copies from two
resolutions in one bundle is how a set stops being one set. Two more glyphs were
not from any icon library at all: the node list drew its network in/out arrows as
the literal characters "↓" and "↑", and the chain onboarding selector drew its
tick as "✓". Those are font glyphs — they take the text metrics, not the icon
box, so they sit on a different baseline and scale with the font, not `size-*`.

One source now: `@luxfi/ui/icons`, Lucide as @hanzo/gui standardises on it and
@luxfi/ui themes it. `lucide-react` is out of package.json, so per-surface drift
is no longer expressible; @luxfi/ui comes in at 7.4.3, which is where `./icons`
lives. The three text glyphs are ArrowDown / ArrowUp / Check.

All 121 distinct icon names this console uses were checked against the 6014
symbols @luxfi/ui/icons exports before the rewrite — zero missing, with a
negative control. `next build` is clean: 78 routes, Next 16.2.11 + Turbopack.

The d3 chart roots (node-globe, node-map, globe-view, donut-chart, fleet-map)
keep their <svg>: those are plots, not glyphs. The command menu's "↑↓" stays —
it is a keyboard legend inside <kbd>, and it means the keys.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 14:57:55 -07:00

1065 lines
36 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { getApiUrl } from "@/lib/api"
import { useState, useEffect } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { Select } from "@/components/ui/select"
import { Server, Plus, ExternalLink, Loader2, X, Play, Square, Trash2, RefreshCcw, Activity, AlertTriangle, ChevronRight, ArrowDown, ArrowUp } from "@luxfi/ui/icons"
import Link from "next/link"
import { getBrand } from "@/lib/brand"
const brand = getBrand()
// Chain icons (using crypto symbols and emoji fallbacks)
const CHAIN_ICONS: Record<string, string> = {
// Hanzo Ecosystem
lux: "💎",
zoo: "🦁",
pars: "🌟",
hanzo: "⚡",
// Major L1s
ethereum: "⟠",
bitcoin: "₿",
solana: "◎",
avalanche: "🔺",
cardano: "₳",
polkadot: "●",
cosmos: "⚛",
near: "Ⓝ",
tron: "⚡",
ton: "💎",
hedera: "ℏ",
filecoin: "⨏",
// L2s & Rollups
polygon: "⬡",
arbitrum: "🔵",
optimism: "🔴",
base: "🔷",
bsc: "🟡",
zksync: "⬢",
linea: "▬",
scroll: "📜",
mantle: "Ⓜ",
blast: "💥",
starknet: "⬡",
// Alt L1s
xrpl: "✕",
fantom: "👻",
cronos: "🔶",
gnosis: "🦉",
celo: "🟢",
moonbeam: "🌙",
sei: "🌊",
sui: "💧",
aptos: "🔷",
// Staking & DeFi chains
bittensor: "🧠",
tezos: "ꜩ",
algorand: "Ⱥ",
injective: "💉",
iota: "ι",
stacks: "Ⓢ",
celestia: "☀",
aleo: "🔐",
fetch: "🤖",
livepeer: "🎬",
multiversx: "✕",
akash: "☁",
zetachain: "Z",
axelar: "🔗",
dydx: "📊",
casper: "👻",
avail: "▲",
band: "📡",
secret: "🔒",
skale: "⬡",
waves: "🌊",
osmosis: "🧪",
radix: "◉",
harmony: "♾",
kava: "🔶",
}
// Supported chains and networks for node deployment
const SUPPORTED_CHAINS: Record<string, { name: string; networks: string[]; category: string }> = {
// Layer 1 - Main
ethereum: {
name: "Ethereum",
networks: ["mainnet", "sepolia", "holesky"],
category: "L1",
},
bitcoin: {
name: "Bitcoin",
networks: ["mainnet", "testnet", "signet"],
category: "L1",
},
solana: {
name: "Solana",
networks: ["mainnet", "devnet", "testnet"],
category: "L1",
},
avalanche: {
name: "Avalanche",
networks: ["mainnet", "fuji"],
category: "L1",
},
lux: {
name: "Lux",
networks: ["mainnet", "testnet", "local"],
category: "L1",
},
zoo: {
name: "Zoo",
networks: ["mainnet", "testnet", "local"],
category: "L1",
},
pars: {
name: "Pars",
networks: ["mainnet", "testnet", "local"],
category: "L1",
},
hanzo: {
name: "Hanzo",
networks: ["mainnet", "testnet", "local"],
category: "L1",
},
// Layer 2 - ETH Rollups
arbitrum: {
name: "Arbitrum",
networks: ["mainnet", "sepolia", "nova"],
category: "L2",
},
optimism: {
name: "Optimism",
networks: ["mainnet", "sepolia"],
category: "L2",
},
base: {
name: "Base",
networks: ["mainnet", "sepolia"],
category: "L2",
},
zksync: {
name: "zkSync Era",
networks: ["mainnet", "sepolia"],
category: "L2",
},
linea: {
name: "Linea",
networks: ["mainnet", "sepolia"],
category: "L2",
},
scroll: {
name: "Scroll",
networks: ["mainnet", "sepolia"],
category: "L2",
},
mantle: {
name: "Mantle",
networks: ["mainnet", "sepolia"],
category: "L2",
},
blast: {
name: "Blast",
networks: ["mainnet", "sepolia"],
category: "L2",
},
polygon: {
name: "Polygon",
networks: ["mainnet", "amoy", "zkevm"],
category: "L2",
},
// Other L1s
bsc: {
name: "BNB Chain",
networks: ["mainnet", "testnet"],
category: "L1",
},
xrpl: {
name: "XRP Ledger",
networks: ["mainnet", "testnet", "devnet"],
category: "L1",
},
cardano: {
name: "Cardano",
networks: ["mainnet", "preprod", "preview"],
category: "L1",
},
polkadot: {
name: "Polkadot",
networks: ["mainnet", "westend"],
category: "L1",
},
cosmos: {
name: "Cosmos Hub",
networks: ["mainnet", "testnet"],
category: "L1",
},
near: {
name: "NEAR",
networks: ["mainnet", "testnet"],
category: "L1",
},
fantom: {
name: "Fantom",
networks: ["mainnet", "testnet"],
category: "L1",
},
cronos: {
name: "Cronos",
networks: ["mainnet", "testnet"],
category: "L1",
},
gnosis: {
name: "Gnosis Chain",
networks: ["mainnet", "chiado"],
category: "L1",
},
celo: {
name: "Celo",
networks: ["mainnet", "alfajores"],
category: "L1",
},
moonbeam: {
name: "Moonbeam",
networks: ["mainnet", "moonbase"],
category: "L1",
},
sei: {
name: "Sei",
networks: ["mainnet", "testnet"],
category: "L1",
},
sui: {
name: "Sui",
networks: ["mainnet", "testnet", "devnet"],
category: "L1",
},
aptos: {
name: "Aptos",
networks: ["mainnet", "testnet", "devnet"],
category: "L1",
},
ton: {
name: "TON",
networks: ["mainnet", "testnet"],
category: "L1",
},
tron: {
name: "TRON",
networks: ["mainnet", "shasta", "nile"],
category: "L1",
},
filecoin: {
name: "Filecoin",
networks: ["mainnet", "calibration"],
category: "L1",
},
hedera: {
name: "Hedera",
networks: ["mainnet", "testnet"],
category: "L1",
},
// Additional Staking Chains
bittensor: {
name: "Bittensor",
networks: ["mainnet", "testnet"],
category: "L1",
},
tezos: {
name: "Tezos",
networks: ["mainnet", "ghostnet"],
category: "L1",
},
algorand: {
name: "Algorand",
networks: ["mainnet", "testnet"],
category: "L1",
},
injective: {
name: "Injective",
networks: ["mainnet", "testnet"],
category: "L1",
},
iota: {
name: "IOTA",
networks: ["mainnet", "shimmer"],
category: "L1",
},
stacks: {
name: "Stacks",
networks: ["mainnet", "testnet"],
category: "L1",
},
celestia: {
name: "Celestia",
networks: ["mainnet", "mocha"],
category: "L1",
},
aleo: {
name: "Aleo",
networks: ["mainnet", "testnet"],
category: "L1",
},
fetch: {
name: "Fetch.ai",
networks: ["mainnet", "testnet"],
category: "L1",
},
livepeer: {
name: "Livepeer",
networks: ["mainnet"],
category: "L1",
},
multiversx: {
name: "MultiversX",
networks: ["mainnet", "devnet"],
category: "L1",
},
starknet: {
name: "Starknet",
networks: ["mainnet", "sepolia"],
category: "L2",
},
akash: {
name: "Akash",
networks: ["mainnet", "testnet"],
category: "L1",
},
zetachain: {
name: "ZetaChain",
networks: ["mainnet", "testnet"],
category: "L1",
},
axelar: {
name: "Axelar",
networks: ["mainnet", "testnet"],
category: "L1",
},
dydx: {
name: "dYdX",
networks: ["mainnet", "testnet"],
category: "L1",
},
casper: {
name: "Casper",
networks: ["mainnet", "testnet"],
category: "L1",
},
avail: {
name: "Avail",
networks: ["mainnet", "turing"],
category: "L1",
},
band: {
name: "Band Protocol",
networks: ["mainnet", "testnet"],
category: "L1",
},
secret: {
name: "Secret Network",
networks: ["mainnet", "pulsar"],
category: "L1",
},
skale: {
name: "SKALE",
networks: ["mainnet"],
category: "L2",
},
waves: {
name: "Waves",
networks: ["mainnet", "testnet"],
category: "L1",
},
osmosis: {
name: "Osmosis",
networks: ["mainnet", "testnet"],
category: "L1",
},
radix: {
name: "Radix",
networks: ["mainnet", "stokenet"],
category: "L1",
},
harmony: {
name: "Harmony",
networks: ["mainnet", "testnet"],
category: "L1",
},
kava: {
name: "Kava",
networks: ["mainnet", "testnet"],
category: "L1",
},
}
interface Node {
id: string
name: string
chain: string
network: string
provider: string
status: string
endpoint: string | null
ws_endpoint?: string | null
beacon_api?: string | null
execution_client?: string
consensus_client?: string
mev_enabled?: boolean
containers?: string[]
created_at: string
metrics?: {
current_block?: number
highest_block?: number
sync_progress?: number
syncing?: boolean
peer_count?: number
chain_id?: number
cpu_usage?: number
memory_usage?: number
memory_percent?: number
disk_usage?: number
network_rx?: number
network_tx?: number
}
}
export default function NodesPage() {
const [nodes, setNodes] = useState<Node[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showCreate, setShowCreate] = useState(false)
const [creating, setCreating] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
// Form state
const [name, setName] = useState("")
const [chain, setChain] = useState("ethereum")
const [network, setNetwork] = useState("mainnet")
const [provider, setProvider] = useState("docker")
const [advancedMode, setAdvancedMode] = useState(false)
const [preset, setPreset] = useState("full")
const [executionClient, setExecutionClient] = useState("geth")
const [consensusClient, setConsensusClient] = useState("lighthouse")
const [enableMev, setEnableMev] = useState(false)
const [enableValidator, setEnableValidator] = useState(false)
const [feeRecipient, setFeeRecipient] = useState("")
const [refreshingMetrics, setRefreshingMetrics] = useState(false)
const [deletingAll, setDeletingAll] = useState(false)
function getAuthHeaders(): Record<string, string> {
const token = localStorage.getItem("bootnode_token")
const apiKey = localStorage.getItem("bootnode_api_key")
if (token) return { "Authorization": `Bearer ${token}` }
if (apiKey) return { "X-API-Key": apiKey }
return {}
}
async function fetchNodes() {
try {
const res = await fetch(`${getApiUrl()}/v1/nodes/`, {
headers: getAuthHeaders()
})
if (res.ok) {
const data = await res.json()
setNodes(Array.isArray(data) ? data : [])
} else {
setError(`Failed to fetch nodes: ${res.status}`)
}
} catch (e) {
setError(String(e))
} finally {
setLoading(false)
}
}
async function refreshMetrics() {
setRefreshingMetrics(true)
try {
// Fetch detailed metrics for each node
const apiUrl = getApiUrl()
const metricsRes = await fetch(`${apiUrl}/v1/nodes/metrics/all`, {
headers: getAuthHeaders()
})
if (metricsRes.ok) {
const metrics = await metricsRes.json()
// Update nodes with metrics
setNodes(prev => prev.map(node => ({
...node,
metrics: { ...node.metrics, ...metrics[node.id] }
})))
}
} catch (e) {
console.error("Failed to refresh metrics:", e)
} finally {
setRefreshingMetrics(false)
}
}
useEffect(() => {
fetchNodes()
}, [])
// Auto-refresh metrics every 10 seconds
useEffect(() => {
if (nodes.length === 0) return
refreshMetrics()
const interval = setInterval(refreshMetrics, 10000)
return () => clearInterval(interval)
}, [nodes.length])
async function handleCreate() {
if (!name.trim() || !chain.trim()) return
setCreating(true)
setCreateError(null)
try {
const res = await fetch(`${getApiUrl()}/v1/nodes/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...getAuthHeaders()
},
body: JSON.stringify({
name: name.trim() || `${chain}-${network}-node`,
chain: chain.trim(),
network: network.trim(),
provider: advancedMode ? provider.trim() : "docker",
mode: advancedMode ? "advanced" : "simple",
preset: !advancedMode && chain === "ethereum" ? preset : undefined,
execution_client: advancedMode && chain === "ethereum" ? executionClient : undefined,
consensus_client: advancedMode && chain === "ethereum" ? (consensusClient || null) : undefined,
enable_mev: advancedMode && chain === "ethereum" ? enableMev : false,
enable_validator: advancedMode && chain === "ethereum" ? enableValidator : false,
fee_recipient: advancedMode && enableValidator ? feeRecipient : undefined,
})
})
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }))
throw new Error(err.detail || `Failed: ${res.status}`)
}
const newNode = await res.json()
setNodes(prev => [...prev, newNode])
setName("")
setChain("ethereum")
setNetwork("mainnet")
setProvider("docker")
setShowCreate(false)
} catch (e) {
setCreateError(String(e))
} finally {
setCreating(false)
}
}
async function handleNodeAction(nodeId: string, action: "start" | "stop" | "delete") {
const apiUrl = getApiUrl()
try {
if (action === "delete") {
const res = await fetch(`${apiUrl}/v1/nodes/${nodeId}`, {
method: "DELETE",
headers: getAuthHeaders()
})
if (res.ok) {
setNodes(prev => prev.filter(n => n.id !== nodeId))
}
} else {
const res = await fetch(`${apiUrl}/v1/nodes/${nodeId}/${action}`, {
method: "POST",
headers: getAuthHeaders()
})
if (res.ok) {
const updated = await res.json()
setNodes(prev => prev.map(n => n.id === nodeId ? updated : n))
}
}
} catch (e) {
console.error(`Failed to ${action} node:`, e)
}
}
async function handleDeleteAll() {
if (!confirm(`Are you sure you want to delete ALL ${nodes.length} nodes? This cannot be undone.`)) return
setDeletingAll(true)
const apiUrl = getApiUrl()
try {
// Delete all nodes in parallel
await Promise.all(nodes.map(node =>
fetch(`${apiUrl}/v1/nodes/${node.id}`, {
method: "DELETE",
headers: getAuthHeaders()
})
))
setNodes([])
} catch (e) {
console.error("Failed to delete all nodes:", e)
} finally {
setDeletingAll(false)
}
}
return (
<div className="space-y-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Nodes</h1>
<p className="text-muted-foreground">
Deploy and manage blockchain nodes
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={refreshMetrics} disabled={refreshingMetrics}>
<RefreshCcw className={`mr-2 h-4 w-4 ${refreshingMetrics ? "animate-spin" : ""}`} />
Refresh
</Button>
{nodes.length > 0 && (
<Button variant="outline" size="sm" className="text-destructive" onClick={handleDeleteAll} disabled={deletingAll}>
{deletingAll ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Trash2 className="mr-2 h-4 w-4" />}
Delete All
</Button>
)}
<Button onClick={() => setShowCreate(!showCreate)}>
<Plus className="mr-2 h-4 w-4" />
Deploy Node
</Button>
</div>
</div>
{/* Create Form */}
{showCreate && (
<Card>
<CardHeader>
<CardTitle>Deploy New Node</CardTitle>
<CardDescription>Configure and deploy a blockchain node</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Mode Toggle */}
<div className="flex items-center justify-between pb-4 border-b">
<div>
<span className="text-sm font-medium">Deployment Mode</span>
<p className="text-xs text-muted-foreground">
{advancedMode ? "Full control over all settings" : "Quick deploy with optimized presets"}
</p>
</div>
<div className="flex items-center gap-2">
<span className={`text-sm ${!advancedMode ? "font-medium" : "text-muted-foreground"}`}>Simple</span>
<button
type="button"
onClick={() => setAdvancedMode(!advancedMode)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${advancedMode ? "bg-primary" : "bg-muted"}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${advancedMode ? "translate-x-6" : "translate-x-1"}`} />
</button>
<span className={`text-sm ${advancedMode ? "font-medium" : "text-muted-foreground"}`}>Advanced</span>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="text-sm font-medium">Node Name</label>
<Input
placeholder="e.g., My ETH Node"
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1"
/>
</div>
<div>
<label className="text-sm font-medium">Chain</label>
<select
value={chain}
onChange={(e) => { setChain(e.target.value); setNetwork(SUPPORTED_CHAINS[e.target.value]?.networks[0] || "mainnet") }}
className="mt-1 w-full px-3 py-2 border rounded-md bg-background"
>
<optgroup label="Hanzo Ecosystem">
{["lux", "zoo", "pars", "hanzo"].map((key) => (
<option key={key} value={key}>{CHAIN_ICONS[key]} {SUPPORTED_CHAINS[key]?.name}</option>
))}
</optgroup>
<optgroup label="Layer 1">
{Object.entries(SUPPORTED_CHAINS)
.filter(([key, info]) => info.category === "L1" && !["lux", "zoo", "pars", "hanzo"].includes(key))
.map(([key, info]) => (
<option key={key} value={key}>{CHAIN_ICONS[key] || "⬡"} {info.name}</option>
))}
</optgroup>
<optgroup label="Layer 2 & Rollups">
{Object.entries(SUPPORTED_CHAINS)
.filter(([_, info]) => info.category === "L2")
.map(([key, info]) => (
<option key={key} value={key}>{CHAIN_ICONS[key] || "⬡"} {info.name}</option>
))}
</optgroup>
</select>
</div>
<div>
<label className="text-sm font-medium">Network</label>
<select
value={network}
onChange={(e) => setNetwork(e.target.value)}
className="mt-1 w-full px-3 py-2 border rounded-md bg-background"
>
{(SUPPORTED_CHAINS[chain]?.networks || ["mainnet"]).map((net) => (
<option key={net} value={net}>{net}</option>
))}
</select>
</div>
{!advancedMode && chain === "ethereum" && (
<div>
<label className="text-sm font-medium">Node Type</label>
<Select value={preset} onValueChange={setPreset} className="mt-1">
<option value="rpc">RPC Only - Fast, minimal (no sync)</option>
<option value="full">Full Node - Syncs all blocks</option>
<option value="staking">Staking Node - Validator ready + MEV</option>
<option value="archive">Archive Node - Full history (~18TB)</option>
</Select>
</div>
)}
{advancedMode && (
<div>
<label className="text-sm font-medium">Provider</label>
<Select value={provider} onValueChange={setProvider} className="mt-1">
<option value="docker">Docker (Local)</option>
<option value="cloud">Cloud (Managed)</option>
</Select>
</div>
)}
</div>
{/* Advanced Ethereum options */}
{advancedMode && chain === "ethereum" && (
<div className="space-y-4 pt-4 border-t">
<div className="grid gap-4 md:grid-cols-3">
<div>
<label className="text-sm font-medium">Execution Client</label>
<Select value={executionClient} onValueChange={setExecutionClient} className="mt-1">
<option value="geth">Geth (Go Ethereum)</option>
<option value="nethermind">Nethermind (.NET)</option>
<option value="besu">Besu (Java)</option>
<option value="erigon">Erigon (Go, archive)</option>
</Select>
</div>
<div>
<label className="text-sm font-medium">Consensus Client</label>
<Select value={consensusClient} onValueChange={setConsensusClient} className="mt-1">
<option value="lighthouse">Lighthouse (Rust)</option>
<option value="prysm">Prysm (Go)</option>
<option value="lodestar">Lodestar (TypeScript)</option>
<option value="teku">Teku (Java)</option>
<option value="">None (RPC proxy only)</option>
</Select>
</div>
<div className="space-y-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={enableMev}
onChange={(e) => setEnableMev(e.target.checked)}
className="h-4 w-4 rounded border-gray-300"
disabled={!consensusClient}
/>
<span className="text-sm font-medium">Enable MEV-Boost</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={enableValidator}
onChange={(e) => setEnableValidator(e.target.checked)}
className="h-4 w-4 rounded border-gray-300"
disabled={!consensusClient}
/>
<span className="text-sm font-medium">Enable Validator</span>
</label>
</div>
</div>
{enableValidator && (
<div>
<label className="text-sm font-medium">Fee Recipient Address</label>
<Input
placeholder="0x... (ETH address for staking rewards)"
value={feeRecipient}
onChange={(e) => setFeeRecipient(e.target.value)}
className="mt-1 font-mono"
/>
</div>
)}
</div>
)}
{createError && (
<div className="rounded-md bg-destructive/10 p-3 text-destructive text-sm">
{createError}
</div>
)}
<div className="flex gap-2">
<Button onClick={handleCreate} disabled={creating || !name.trim()}>
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Deploy Node
</Button>
<Button variant="outline" onClick={() => setShowCreate(false)}>
<X className="mr-2 h-4 w-4" />
Cancel
</Button>
</div>
</CardContent>
</Card>
)}
{/* Stats */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Total Nodes</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{loading ? "..." : nodes.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Running</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{loading ? "..." : nodes.filter(n => n.status === "running").length}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Status</CardTitle>
</CardHeader>
<CardContent>
<Badge variant={error ? "destructive" : "default"}>
{loading ? "Loading" : error ? "Error" : "Connected"}
</Badge>
</CardContent>
</Card>
</div>
{/* Nodes List */}
<Card>
<CardHeader>
<CardTitle>Your Nodes</CardTitle>
<CardDescription>
Blockchain nodes deployed through {brand.name}
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center p-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : error ? (
<div className="rounded-md bg-destructive/10 p-4 text-destructive">{error}</div>
) : nodes.length === 0 ? (
<div className="text-center text-muted-foreground p-12">
No nodes deployed yet. Click "Deploy Node" to get started.
</div>
) : (
<div className="space-y-4">
{nodes.map((node) => (
<div key={node.id} className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted/50 transition-colors group">
<Link href={`/dashboard/infrastructure/nodes/${node.id}`} className="flex items-center gap-4 flex-1 min-w-0">
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center shrink-0 text-xl">
{CHAIN_ICONS[node.chain] || <Server className="h-5 w-5 text-primary" />}
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h4 className="font-medium truncate">{node.name}</h4>
<Badge variant={node.status === "running" ? "default" : node.status === "starting" ? "secondary" : "outline"}>
{node.status}
</Badge>
</div>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<Badge variant="outline" className="text-xs">{node.chain}</Badge>
<Badge variant="outline" className="text-xs">{node.network}</Badge>
{node.execution_client && (
<Badge variant="secondary" className="text-xs">{node.execution_client}</Badge>
)}
{node.consensus_client && (
<Badge variant="secondary" className="text-xs">{node.consensus_client}</Badge>
)}
{node.mev_enabled && (
<Badge className="text-xs bg-purple-500">MEV</Badge>
)}
</div>
</div>
</Link>
<div className="flex items-center gap-4 shrink-0">
{/* Sync Progress */}
{node.metrics?.current_block !== undefined && (
<div className="text-right min-w-[100px] hidden lg:block">
<p className="text-sm font-medium font-mono">
{node.metrics.current_block.toLocaleString()}
</p>
<p className="text-xs text-muted-foreground">
{node.metrics.syncing ? (
<span className="text-yellow-500">
Syncing {node.metrics.sync_progress?.toFixed(1)}%
</span>
) : (
"Synced"
)}
</p>
</div>
)}
{/* Peers */}
{node.metrics?.peer_count !== undefined && (
<div className="text-right hidden md:block">
<p className="text-sm font-medium">{node.metrics.peer_count}</p>
<p className="text-xs text-muted-foreground">peers</p>
</div>
)}
{/* CPU */}
{node.metrics?.cpu_usage !== undefined && (
<div className="text-right hidden lg:block">
<p className="text-sm font-medium">{node.metrics.cpu_usage.toFixed(1)}%</p>
<p className="text-xs text-muted-foreground">CPU</p>
</div>
)}
{/* Memory */}
{node.metrics?.memory_usage !== undefined && (
<div className="text-right hidden lg:block">
<p className="text-sm font-medium">{node.metrics.memory_usage.toFixed(1)} GB</p>
<p className="text-xs text-muted-foreground">Memory</p>
</div>
)}
{/* Disk */}
{node.metrics?.disk_usage !== undefined && node.metrics.disk_usage > 0 && (
<div className="text-right hidden xl:block">
<p className="text-sm font-medium">{node.metrics.disk_usage.toFixed(1)} GB</p>
<p className="text-xs text-muted-foreground">Disk</p>
</div>
)}
{/* Network */}
{node.metrics?.network_rx !== undefined && (
<div className="text-right hidden xl:block">
<p className="text-sm font-medium">
<ArrowDown className="inline size-3.5 text-green-500" aria-label="in" />{node.metrics.network_rx.toFixed(0)}
<ArrowUp className="ml-1 inline size-3.5 text-blue-500" aria-label="out" />{node.metrics.network_tx?.toFixed(0) || 0} MB
</p>
<p className="text-xs text-muted-foreground">Network</p>
</div>
)}
<div className="flex items-center gap-1">
{node.status === "running" ? (
<Button variant="ghost" size="icon" onClick={(e) => { e.preventDefault(); handleNodeAction(node.id, "stop"); }} title="Stop node">
<Square className="h-4 w-4" />
</Button>
) : node.status === "stopped" || node.status === "exited" ? (
<Button variant="ghost" size="icon" onClick={(e) => { e.preventDefault(); handleNodeAction(node.id, "start"); }} title="Start node">
<Play className="h-4 w-4" />
</Button>
) : null}
{node.endpoint && (
<Button variant="ghost" size="icon" asChild title="Open RPC endpoint">
<a href={node.endpoint} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
<ExternalLink className="h-4 w-4" />
</a>
</Button>
)}
<Button variant="ghost" size="icon" className="text-destructive" onClick={(e) => { e.preventDefault(); handleNodeAction(node.id, "delete"); }} title="Delete node">
<Trash2 className="h-4 w-4" />
</Button>
<ChevronRight className="h-4 w-4 text-muted-foreground ml-2 opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Consensus Client Notice for ETH nodes */}
{nodes.some(n => n.chain === "ethereum" && n.metrics?.peer_count && n.metrics.peer_count > 0 && n.metrics.current_block === 0) && (
<Card className="border-yellow-500/50 bg-yellow-500/5">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-yellow-500 mt-0.5" />
<div>
<p className="font-medium">Consensus Client Required</p>
<p className="text-sm text-muted-foreground">
Post-Merge Ethereum requires a consensus client (beacon node) to sync.
Geth execution nodes need Prysm, Lighthouse, or similar running alongside.
RPC endpoints are functional but blocks won't sync without a beacon client.
</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Supported Chains */}
<Card>
<CardHeader>
<CardTitle>Supported Chains</CardTitle>
<CardDescription>30+ blockchain nodes available - Click to deploy</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Hanzo/Lux Ecosystem */}
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-3">Hanzo Ecosystem</h4>
<div className="grid gap-2 grid-cols-2 md:grid-cols-4">
{["lux", "zoo", "pars", "hanzo"].map((c) => (
<Button
key={c}
variant="outline"
className="justify-start"
onClick={() => { setChain(c); setShowCreate(true); }}
>
<span className="mr-2 text-lg">{CHAIN_ICONS[c]}</span>
{SUPPORTED_CHAINS[c]?.name || c}
</Button>
))}
</div>
</div>
{/* Layer 1s */}
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-3">Layer 1 Blockchains</h4>
<div className="grid gap-2 grid-cols-2 md:grid-cols-4 lg:grid-cols-6">
{Object.entries(SUPPORTED_CHAINS)
.filter(([key, info]) => info.category === "L1" && !["lux", "zoo", "pars", "hanzo"].includes(key))
.map(([key, info]) => (
<Button
key={key}
variant="outline"
className="justify-start"
onClick={() => { setChain(key); setShowCreate(true); }}
>
<span className="mr-2 text-lg">{CHAIN_ICONS[key] || "⬡"}</span>
{info.name}
</Button>
))}
</div>
</div>
{/* Layer 2s */}
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-3">Layer 2 & Rollups</h4>
<div className="grid gap-2 grid-cols-2 md:grid-cols-4 lg:grid-cols-6">
{Object.entries(SUPPORTED_CHAINS)
.filter(([_, info]) => info.category === "L2")
.map(([key, info]) => (
<Button
key={key}
variant="outline"
className="justify-start"
onClick={() => { setChain(key); setShowCreate(true); }}
>
<span className="mr-2 text-lg">{CHAIN_ICONS[key] || "⬡"}</span>
{info.name}
</Button>
))}
</div>
</div>
</CardContent>
</Card>
</div>
)
}