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

367 lines
13 KiB
TypeScript

"use client"
import { getApiUrl } from "@/lib/api"
import { useState, useEffect } from "react"
import { useParams, useRouter } from "next/navigation"
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 {
Server, ArrowLeft, Play, Square, Trash2, RefreshCcw, Copy, Check,
Activity, HardDrive, Cpu, MemoryStick, Network, Loader2, Terminal
} from "@luxfi/ui/icons"
interface NodeDetails {
id: string
name: string
chain: string
network: string
provider: string
status: string
endpoint: string | null
ws_endpoint?: string | null
beacon_api?: string | null
validator_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
disk_usage?: number
network_rx?: number
network_tx?: number
}
error?: string
}
export default function NodeDetailClient() {
const params = useParams()
const router = useRouter()
const nodeId = params.id as string
const [node, setNode] = useState<NodeDetails | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [copied, setCopied] = useState<string | null>(null)
const [rpcInput, setRpcInput] = useState('{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}')
const [rpcResult, setRpcResult] = useState<string | null>(null)
const [rpcLoading, setRpcLoading] = useState(false)
function getAuthHeaders(): Record<string, string> {
if (typeof window === "undefined") return {}
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 fetchNode() {
try {
const res = await fetch(
`${getApiUrl()}/v1/nodes/${nodeId}`,
{ headers: getAuthHeaders() }
)
if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`)
const data = await res.json()
setNode(data)
} catch (e) {
setError(String(e))
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchNode()
const interval = setInterval(fetchNode, 5000)
return () => clearInterval(interval)
}, [nodeId])
async function handleAction(action: "start" | "stop" | "delete") {
const apiUrl = getApiUrl()
try {
if (action === "delete") {
if (!confirm("Are you sure you want to delete this node?")) return
await fetch(`${apiUrl}/v1/nodes/${nodeId}`, {
method: "DELETE",
headers: getAuthHeaders()
})
router.push("/dashboard/infrastructure/nodes")
} else {
const res = await fetch(`${apiUrl}/v1/nodes/${nodeId}/${action}`, {
method: "POST",
headers: getAuthHeaders()
})
if (res.ok) fetchNode()
}
} catch (e) {
console.error(`Failed to ${action}:`, e)
}
}
async function sendRpcRequest() {
if (!node?.endpoint) return
setRpcLoading(true)
try {
const payload = JSON.parse(rpcInput)
const res = await fetch(node.endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
})
const data = await res.json()
setRpcResult(JSON.stringify(data, null, 2))
} catch (e) {
setRpcResult(`Error: ${e}`)
} finally {
setRpcLoading(false)
}
}
function copyToClipboard(text: string, key: string) {
navigator.clipboard.writeText(text)
setCopied(key)
setTimeout(() => setCopied(null), 2000)
}
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
)
}
if (error || !node) {
return (
<div className="space-y-4">
<Button variant="ghost" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" /> Back
</Button>
<Card className="border-destructive">
<CardContent className="p-6">
<p className="text-destructive">{error || "Node not found"}</p>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Server className="h-6 w-6" />
{node.name}
</h1>
<div className="flex items-center gap-2 mt-1">
<Badge variant={node.status === "running" ? "default" : "secondary"}>
{node.status}
</Badge>
<Badge variant="outline">{node.chain}</Badge>
<Badge variant="outline">{node.network}</Badge>
{node.execution_client && <Badge variant="secondary">{node.execution_client}</Badge>}
{node.consensus_client && <Badge variant="secondary">{node.consensus_client}</Badge>}
{node.mev_enabled && <Badge className="bg-purple-500">MEV</Badge>}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={fetchNode}>
<RefreshCcw className="h-4 w-4 mr-2" /> Refresh
</Button>
{node.status === "running" ? (
<Button variant="outline" size="sm" onClick={() => handleAction("stop")}>
<Square className="h-4 w-4 mr-2" /> Stop
</Button>
) : (
<Button variant="outline" size="sm" onClick={() => handleAction("start")}>
<Play className="h-4 w-4 mr-2" /> Start
</Button>
)}
<Button variant="destructive" size="sm" onClick={() => handleAction("delete")}>
<Trash2 className="h-4 w-4 mr-2" /> Delete
</Button>
</div>
</div>
{/* Endpoints */}
<Card>
<CardHeader>
<CardTitle>Endpoints</CardTitle>
<CardDescription>Connection URLs for your node</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{node.endpoint && (
<div className="flex items-center justify-between p-3 bg-muted rounded-lg">
<div>
<p className="text-sm font-medium">RPC Endpoint</p>
<code className="text-sm text-muted-foreground">{node.endpoint}</code>
</div>
<Button variant="ghost" size="icon" onClick={() => copyToClipboard(node.endpoint!, "rpc")}>
{copied === "rpc" ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
)}
{node.ws_endpoint && (
<div className="flex items-center justify-between p-3 bg-muted rounded-lg">
<div>
<p className="text-sm font-medium">WebSocket Endpoint</p>
<code className="text-sm text-muted-foreground">{node.ws_endpoint}</code>
</div>
<Button variant="ghost" size="icon" onClick={() => copyToClipboard(node.ws_endpoint!, "ws")}>
{copied === "ws" ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
)}
{node.beacon_api && (
<div className="flex items-center justify-between p-3 bg-muted rounded-lg">
<div>
<p className="text-sm font-medium">Beacon API</p>
<code className="text-sm text-muted-foreground">{node.beacon_api}</code>
</div>
<Button variant="ghost" size="icon" onClick={() => copyToClipboard(node.beacon_api!, "beacon")}>
{copied === "beacon" ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
)}
</CardContent>
</Card>
{/* Metrics */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Activity className="h-4 w-4" /> Sync Status
</CardTitle>
</CardHeader>
<CardContent>
{node.metrics?.syncing ? (
<>
<p className="text-2xl font-bold">{node.metrics.sync_progress?.toFixed(1)}%</p>
<p className="text-xs text-muted-foreground">
Block {node.metrics.current_block?.toLocaleString()} / {node.metrics.highest_block?.toLocaleString()}
</p>
</>
) : node.metrics?.current_block ? (
<>
<p className="text-2xl font-bold text-green-500">Synced</p>
<p className="text-xs text-muted-foreground">Block {node.metrics.current_block?.toLocaleString()}</p>
</>
) : (
<p className="text-2xl font-bold text-muted-foreground">-</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Network className="h-4 w-4" /> Peers
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{node.metrics?.peer_count ?? "-"}</p>
<p className="text-xs text-muted-foreground">Connected peers</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Cpu className="h-4 w-4" /> CPU
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{node.metrics?.cpu_usage?.toFixed(1) ?? "-"}%</p>
<p className="text-xs text-muted-foreground">Usage</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<MemoryStick className="h-4 w-4" /> Memory
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{node.metrics?.memory_usage?.toFixed(1) ?? "-"} GB</p>
<p className="text-xs text-muted-foreground">Used</p>
</CardContent>
</Card>
</div>
{/* RPC Console */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Terminal className="h-5 w-5" /> RPC Console
</CardTitle>
<CardDescription>Send JSON-RPC requests to your node</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="text-sm font-medium">Request</label>
<textarea
value={rpcInput}
onChange={(e) => setRpcInput(e.target.value)}
className="w-full h-24 mt-1 p-3 font-mono text-sm border rounded-lg bg-muted"
placeholder='{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}'
/>
</div>
<Button onClick={sendRpcRequest} disabled={rpcLoading || !node.endpoint}>
{rpcLoading ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : null}
Send Request
</Button>
{rpcResult && (
<div>
<label className="text-sm font-medium">Response</label>
<pre className="mt-1 p-3 font-mono text-sm bg-muted rounded-lg overflow-auto max-h-48">
{rpcResult}
</pre>
</div>
)}
</CardContent>
</Card>
{/* Containers */}
{node.containers && node.containers.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Containers</CardTitle>
<CardDescription>Docker containers for this node</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{node.containers.map((cid) => (
<div key={cid} className="flex items-center gap-2 p-2 bg-muted rounded font-mono text-sm">
<Server className="h-4 w-4" />
{cid}
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
)
}