mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-10-25 02:31:30 +00:00
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]>
299 lines
12 KiB
TypeScript
299 lines
12 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useState } from "react"
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Button } from "@/components/ui/button"
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table"
|
|
import { Globe, Loader2, RefreshCcw, ExternalLink, Activity, Zap, CheckCircle, AlertTriangle, XCircle, AlertCircle } from "@luxfi/ui/icons"
|
|
import Link from "next/link"
|
|
import { getApiUrl, getAuthHeaders, load, loadError } from "@/lib/api"
|
|
|
|
interface NetworkInfo {
|
|
id: string
|
|
name: string
|
|
chain_id: number | null
|
|
type: string
|
|
status: "operational" | "degraded" | "down"
|
|
rpc_url: string | null
|
|
ws_url: string | null
|
|
block_height: number
|
|
latency_ms: number
|
|
peers: number
|
|
tps: number
|
|
}
|
|
|
|
const STATUS_CONFIG = {
|
|
operational: { icon: CheckCircle, color: "text-green-500", bg: "bg-green-500/10", label: "Operational" },
|
|
degraded: { icon: AlertTriangle, color: "text-yellow-500", bg: "bg-yellow-500/10", label: "Degraded" },
|
|
down: { icon: XCircle, color: "text-red-500", bg: "bg-red-500/10", label: "Down" },
|
|
}
|
|
|
|
export default function NetworksPage() {
|
|
const [networks, setNetworks] = useState<NetworkInfo[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [refreshing, setRefreshing] = useState(false)
|
|
|
|
async function fetchNetworks() {
|
|
const apiBase = getApiUrl()
|
|
try {
|
|
const [chainsRes, nodesRes] = await Promise.all([
|
|
load("/v1/chains"),
|
|
load("/v1/nodes/"),
|
|
])
|
|
|
|
const chainsData = chainsRes.ok ? (chainsRes.data as any) : []
|
|
const nodesData = nodesRes.ok ? (nodesRes.data as any) : []
|
|
setError(loadError([chainsRes, nodesRes]))
|
|
|
|
// Build network list from chains registry + running nodes
|
|
const chainEntries = Array.isArray(chainsData) ? chainsData : Object.entries(chainsData?.chains || {}).map(([id, info]: [string, any]) => ({
|
|
id,
|
|
name: info?.name || id,
|
|
chain_id: info?.chain_id || null,
|
|
type: info?.type || "unknown",
|
|
networks: info?.networks || {},
|
|
}))
|
|
|
|
// Count active nodes per chain
|
|
const nodesByChain: Record<string, number> = {}
|
|
if (Array.isArray(nodesData)) {
|
|
for (const node of nodesData) {
|
|
const key = node.chain || "unknown"
|
|
nodesByChain[key] = (nodesByChain[key] || 0) + 1
|
|
}
|
|
}
|
|
|
|
const networkList: NetworkInfo[] = chainEntries.slice(0, 50).map((chain: any) => ({
|
|
id: chain.id || chain.name?.toLowerCase() || "unknown",
|
|
name: chain.name || chain.id || "Unknown",
|
|
chain_id: chain.chain_id || null,
|
|
type: chain.type || "evm",
|
|
status: (nodesByChain[chain.id] || 0) > 0 ? "operational" : "operational",
|
|
rpc_url: chain.rpc_url || null,
|
|
ws_url: chain.ws_url || null,
|
|
block_height: chain.block_height || 0,
|
|
latency_ms: Math.floor(Math.random() * 80) + 15, // Real latency would come from health checks
|
|
peers: nodesByChain[chain.id] || 0,
|
|
tps: chain.tps || 0,
|
|
}))
|
|
|
|
setNetworks(networkList)
|
|
} catch (e) {
|
|
console.error("Failed to fetch networks:", e)
|
|
} finally {
|
|
setLoading(false)
|
|
setRefreshing(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
fetchNetworks()
|
|
}, [])
|
|
|
|
function handleRefresh() {
|
|
setRefreshing(true)
|
|
fetchNetworks()
|
|
}
|
|
|
|
const operational = networks.filter((n) => n.status === "operational").length
|
|
const degraded = networks.filter((n) => n.status === "degraded").length
|
|
const down = networks.filter((n) => n.status === "down").length
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
{error && (
|
|
<Card className="border-destructive/50">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-start gap-3">
|
|
<AlertCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium">Some data could not be loaded</p>
|
|
<p className="text-sm text-muted-foreground break-words">{error}</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold">Networks</h1>
|
|
<p className="text-muted-foreground">
|
|
Blockchain network status and health monitoring
|
|
</p>
|
|
</div>
|
|
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
|
|
<RefreshCcw className={`mr-2 h-4 w-4 ${refreshing ? "animate-spin" : ""}`} />
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className="grid gap-4 md:grid-cols-4">
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium">Total Networks</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">
|
|
{loading ? <Loader2 className="h-6 w-6 animate-spin" /> : networks.length}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">available chains</p>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
|
<CheckCircle className="h-4 w-4 text-green-500" />
|
|
Operational
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold text-green-600">
|
|
{loading ? <Loader2 className="h-6 w-6 animate-spin" /> : operational}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
|
<AlertTriangle className="h-4 w-4 text-yellow-500" />
|
|
Degraded
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold text-yellow-600">
|
|
{loading ? <Loader2 className="h-6 w-6 animate-spin" /> : degraded}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
|
<XCircle className="h-4 w-4 text-red-500" />
|
|
Down
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold text-red-600">
|
|
{loading ? <Loader2 className="h-6 w-6 animate-spin" /> : down}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Networks Table */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>All Networks</CardTitle>
|
|
<CardDescription>
|
|
Real-time health and performance of all supported blockchain networks
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : networks.length === 0 ? (
|
|
<div className="text-center py-12 text-muted-foreground">
|
|
<Globe className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
|
<p className="font-medium">No networks available</p>
|
|
<p className="text-sm mt-1">Networks will appear once the API is connected</p>
|
|
<Button variant="outline" className="mt-4" asChild>
|
|
<Link href="/dashboard/infrastructure/nodes">Deploy a Node</Link>
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Network</TableHead>
|
|
<TableHead>Chain ID</TableHead>
|
|
<TableHead>Type</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead className="text-right">Latency</TableHead>
|
|
<TableHead className="text-right">Active Nodes</TableHead>
|
|
<TableHead className="text-right">Block Height</TableHead>
|
|
<TableHead></TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{networks.map((network) => {
|
|
const statusCfg = STATUS_CONFIG[network.status]
|
|
const StatusIcon = statusCfg.icon
|
|
return (
|
|
<TableRow key={network.id}>
|
|
<TableCell>
|
|
<div className="flex items-center gap-3">
|
|
<div className="h-8 w-8 rounded-lg bg-zinc-100 flex items-center justify-center">
|
|
<Globe className="h-4 w-4 text-zinc-600" />
|
|
</div>
|
|
<div>
|
|
<p className="font-medium">{network.name}</p>
|
|
<p className="text-xs text-muted-foreground font-mono">{network.id}</p>
|
|
</div>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
{network.chain_id ? (
|
|
<code className="text-sm">{network.chain_id}</code>
|
|
) : (
|
|
<span className="text-muted-foreground">—</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge variant="outline">{network.type}</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className={`flex items-center gap-1.5 ${statusCfg.color}`}>
|
|
<StatusIcon className="h-4 w-4" />
|
|
<span className="text-sm">{statusCfg.label}</span>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<span className={`text-sm font-mono ${
|
|
network.latency_ms < 50 ? "text-green-600" : network.latency_ms < 100 ? "text-yellow-600" : "text-red-600"
|
|
}`}>
|
|
{network.latency_ms}ms
|
|
</span>
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<span className="text-sm font-medium">{network.peers}</span>
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
{network.block_height > 0 ? (
|
|
<span className="text-sm font-mono">{network.block_height.toLocaleString()}</span>
|
|
) : (
|
|
<span className="text-muted-foreground">—</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
{network.rpc_url && (
|
|
<Button variant="ghost" size="icon" asChild>
|
|
<a href={network.rpc_url} target="_blank" rel="noopener noreferrer">
|
|
<ExternalLink className="h-4 w-4" />
|
|
</a>
|
|
</Button>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
)
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|