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]>
445 lines
15 KiB
TypeScript
445 lines
15 KiB
TypeScript
"use client"
|
|
|
|
import { useCallback, useEffect, useMemo, 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 {
|
|
Activity,
|
|
AlertTriangle,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Circle,
|
|
Cpu,
|
|
Heart,
|
|
Loader2,
|
|
Network as NetworkIcon,
|
|
Package,
|
|
RefreshCcw,
|
|
Server,
|
|
Users,
|
|
} from "@luxfi/ui/icons"
|
|
import { FleetMap, type FleetRegion } from "@/components/lux/fleet-map"
|
|
import {
|
|
CHAIN_LABEL,
|
|
NETWORK_LABEL,
|
|
TRACKED_CHAINS,
|
|
bestHeight,
|
|
chainBootstrapped,
|
|
fetchFleetOverview,
|
|
fleetStatus,
|
|
nodeHealth,
|
|
type FleetNetworkName,
|
|
type LuxFleetOverview,
|
|
type LuxNetworkFleet,
|
|
type LuxNode,
|
|
} from "@/lib/fleet"
|
|
|
|
const REFRESH_MS = 15_000
|
|
|
|
const NETWORK_ACCENT: Record<FleetNetworkName, string> = {
|
|
mainnet: "border-green-500/50 text-green-400",
|
|
testnet: "border-blue-500/50 text-blue-400",
|
|
devnet: "border-orange-500/50 text-orange-400",
|
|
}
|
|
|
|
const STATUS_BADGE: Record<string, { cls: string; label: string }> = {
|
|
operational: { cls: "border-green-500/40 text-green-400 bg-green-500/10", label: "Operational" },
|
|
degraded: { cls: "border-yellow-500/40 text-yellow-400 bg-yellow-500/10", label: "Degraded" },
|
|
down: { cls: "border-red-500/40 text-red-400 bg-red-500/10", label: "Down" },
|
|
}
|
|
|
|
function StatNumber({
|
|
icon: Icon,
|
|
label,
|
|
value,
|
|
suffix,
|
|
accent,
|
|
}: {
|
|
icon: React.ComponentType<{ className?: string }>
|
|
label: string
|
|
value: number | string | null | undefined
|
|
suffix?: string
|
|
accent?: string
|
|
}) {
|
|
return (
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex items-center gap-2">
|
|
<Icon className={`h-5 w-5 ${accent ?? "text-muted-foreground"}`} />
|
|
<span className={`text-2xl font-bold ${accent ?? ""}`}>
|
|
{value ?? "—"}
|
|
{suffix && value != null ? <span className="text-base text-muted-foreground">{suffix}</span> : null}
|
|
</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
function healthDot(h: ReturnType<typeof nodeHealth>) {
|
|
const cls =
|
|
h === "healthy"
|
|
? "text-green-500"
|
|
: h === "syncing"
|
|
? "text-yellow-500"
|
|
: h === "down"
|
|
? "text-red-500"
|
|
: "text-zinc-500"
|
|
return <Circle className={`h-2.5 w-2.5 fill-current ${cls}`} />
|
|
}
|
|
|
|
function boolBadge(v: boolean | null, trueLabel = "Yes", falseLabel = "No") {
|
|
if (v == null) return <span className="text-xs text-muted-foreground">—</span>
|
|
return (
|
|
<Badge variant={v ? "success" : "warning"} className="text-[10px]">
|
|
{v ? trueLabel : falseLabel}
|
|
</Badge>
|
|
)
|
|
}
|
|
|
|
function NodeTable({ network, nodes }: { network: FleetNetworkName; nodes: LuxNode[] }) {
|
|
if (nodes.length === 0) {
|
|
return (
|
|
<p className="p-6 text-center text-sm text-muted-foreground">
|
|
No luxd pods reported for {NETWORK_LABEL[network]}.
|
|
</p>
|
|
)
|
|
}
|
|
return (
|
|
<div className="overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Node</TableHead>
|
|
<TableHead>Node ID</TableHead>
|
|
<TableHead>Version</TableHead>
|
|
<TableHead className="text-right">Peers</TableHead>
|
|
<TableHead className="text-right">C-Chain</TableHead>
|
|
<TableHead className="text-center">C / P / X / D</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{nodes.map((n) => {
|
|
const c = n.chains.find((x) => x.chain === "C")
|
|
return (
|
|
<TableRow key={n.pod_name}>
|
|
<TableCell>
|
|
<div className="flex items-center gap-2">
|
|
{healthDot(nodeHealth(n))}
|
|
<span className="font-mono text-xs">{n.pod_name}</span>
|
|
{!n.rpc_reachable && n.ready ? (
|
|
<span className="text-[10px] text-muted-foreground">(internal RPC)</span>
|
|
) : null}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<span className="font-mono text-[11px] text-muted-foreground">
|
|
{n.node_id ? `${n.node_id.slice(0, 18)}…` : "—"}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell>
|
|
<span className="font-mono text-xs">{n.version ?? "—"}</span>
|
|
</TableCell>
|
|
<TableCell className="text-right font-mono text-sm">{n.peers ?? "—"}</TableCell>
|
|
<TableCell className="text-right font-mono text-sm">
|
|
{c?.height != null ? c.height.toLocaleString() : "—"}
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center justify-center gap-1">
|
|
{TRACKED_CHAINS.map((ch) => {
|
|
const cc = n.chains.find((x) => x.chain === ch)
|
|
const cls =
|
|
cc?.bootstrapped === true
|
|
? "bg-green-500"
|
|
: cc?.bootstrapped === false
|
|
? "bg-yellow-500"
|
|
: "bg-zinc-700"
|
|
return (
|
|
<span
|
|
key={ch}
|
|
title={`${CHAIN_LABEL[ch]}: ${
|
|
cc?.bootstrapped === true
|
|
? "bootstrapped"
|
|
: cc?.bootstrapped === false
|
|
? "syncing"
|
|
: "unknown"
|
|
}`}
|
|
className={`inline-block h-2.5 w-2.5 rounded-sm ${cls}`}
|
|
/>
|
|
)
|
|
})}
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
)
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function NetworkCard({ fleet }: { fleet: LuxNetworkFleet }) {
|
|
const [open, setOpen] = useState(false)
|
|
const status = fleetStatus(fleet)
|
|
const sb = STATUS_BADGE[status]
|
|
const cHeight = bestHeight(fleet, "C")
|
|
const dexBoot = fleet.dex.d_chain_bootstrapped ?? chainBootstrapped(fleet, "D")
|
|
|
|
return (
|
|
<Card className="border-zinc-800">
|
|
<CardHeader className="pb-3">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={() => setOpen((v) => !v)}
|
|
aria-label={open ? "Collapse nodes" : "Expand nodes"}
|
|
>
|
|
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
|
</Button>
|
|
<div>
|
|
<CardTitle className="flex items-center gap-2">
|
|
{NETWORK_LABEL[fleet.network]}
|
|
<Badge variant="outline" className={NETWORK_ACCENT[fleet.network]}>
|
|
network {fleet.network_id ?? "—"}
|
|
</Badge>
|
|
</CardTitle>
|
|
<CardDescription className="mt-1 font-mono text-xs">
|
|
{fleet.version ?? fleet.image ?? "version unknown"}
|
|
</CardDescription>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-4">
|
|
<Badge variant="outline" className={sb.cls}>
|
|
{status === "operational" ? (
|
|
<Circle className="mr-1 h-2 w-2 fill-green-500" />
|
|
) : status === "degraded" ? (
|
|
<AlertTriangle className="mr-1 h-3 w-3" />
|
|
) : (
|
|
<Circle className="mr-1 h-2 w-2 fill-red-500" />
|
|
)}
|
|
{sb.label}
|
|
</Badge>
|
|
<div className="text-right text-sm">
|
|
<span className="font-bold">{fleet.nodes_ready}</span>
|
|
<span className="text-muted-foreground">/{fleet.validators || fleet.nodes_total} validators</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent className="space-y-4">
|
|
{/* Quick facts row */}
|
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
|
<Fact icon={Server} label="Nodes ready" value={`${fleet.nodes_ready}/${fleet.nodes_total}`} />
|
|
<Fact icon={Users} label="Peers" value={fleet.total_peers} />
|
|
<Fact icon={Activity} label="C-Chain height" value={cHeight != null ? cHeight.toLocaleString() : "—"} />
|
|
<Fact
|
|
icon={Cpu}
|
|
label="DEX (D-Chain)"
|
|
value={dexBoot === true ? "Live" : dexBoot === false ? "Syncing" : "—"}
|
|
valueClass={dexBoot === true ? "text-green-400" : dexBoot === false ? "text-yellow-400" : ""}
|
|
/>
|
|
</div>
|
|
|
|
{/* Plugin set + chains bootstrapped */}
|
|
<div className="flex flex-wrap items-center gap-2 border-t border-zinc-800 pt-3">
|
|
{fleet.plugin_set ? (
|
|
<Badge variant="secondary" className="font-mono text-[10px]">
|
|
<Package className="mr-1 h-3 w-3" />
|
|
{fleet.plugin_set.replace(/^s3:\/\//, "")}
|
|
</Badge>
|
|
) : null}
|
|
{TRACKED_CHAINS.map((ch) => {
|
|
const b = chainBootstrapped(fleet, ch)
|
|
return (
|
|
<span key={ch} className="flex items-center gap-1 text-xs">
|
|
<span
|
|
className={`inline-block h-2 w-2 rounded-full ${
|
|
b === true ? "bg-green-500" : b === false ? "bg-yellow-500" : "bg-zinc-700"
|
|
}`}
|
|
/>
|
|
<span className="text-muted-foreground">{CHAIN_LABEL[ch]}</span>
|
|
</span>
|
|
)
|
|
})}
|
|
{fleet.dex.markets != null ? (
|
|
<Badge variant="outline" className="text-[10px]">
|
|
{fleet.dex.markets} markets
|
|
</Badge>
|
|
) : null}
|
|
</div>
|
|
|
|
{fleet.error ? (
|
|
<div className="rounded-md bg-yellow-500/10 p-3 text-xs text-yellow-500">{fleet.error}</div>
|
|
) : null}
|
|
|
|
{open ? (
|
|
<div className="border-t border-zinc-800 pt-3">
|
|
<NodeTable network={fleet.network} nodes={fleet.nodes} />
|
|
</div>
|
|
) : null}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
function Fact({
|
|
icon: Icon,
|
|
label,
|
|
value,
|
|
valueClass,
|
|
}: {
|
|
icon: React.ComponentType<{ className?: string }>
|
|
label: string
|
|
value: React.ReactNode
|
|
valueClass?: string
|
|
}) {
|
|
return (
|
|
<div className="rounded-lg border border-zinc-800 p-3">
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
<Icon className="h-3.5 w-3.5" />
|
|
{label}
|
|
</div>
|
|
<div className={`mt-1 font-mono text-lg font-semibold ${valueClass ?? ""}`}>{value}</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** Build map regions from the fleet (all luxd nodes shown at the sfo3 region). */
|
|
function toRegions(overview: LuxFleetOverview | null): FleetRegion[] {
|
|
if (!overview) return []
|
|
const total = overview.networks.reduce((s, f) => s + f.nodes_total, 0)
|
|
const healthy = overview.networks.reduce((s, f) => s + f.nodes_ready, 0)
|
|
if (total === 0) return []
|
|
// The managed fleet runs in DigitalOcean SFO3 (San Francisco).
|
|
return [
|
|
{
|
|
slug: "sfo3",
|
|
name: "SFO3 · San Francisco",
|
|
lat: 37.77,
|
|
lon: -122.42,
|
|
active: healthy > 0,
|
|
nodeCount: total,
|
|
healthyNodes: healthy,
|
|
networks: overview.networks.map((f) => f.network),
|
|
},
|
|
]
|
|
}
|
|
|
|
export function FleetDashboard({ compact = false }: { compact?: boolean }) {
|
|
const [overview, setOverview] = useState<LuxFleetOverview | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [refreshing, setRefreshing] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [updatedAt, setUpdatedAt] = useState<Date | null>(null)
|
|
|
|
const load = useCallback(async (silent = false) => {
|
|
if (!silent) setRefreshing(true)
|
|
const ctrl = new AbortController()
|
|
try {
|
|
const data = await fetchFleetOverview(ctrl.signal)
|
|
setOverview(data)
|
|
setError(null)
|
|
setUpdatedAt(new Date())
|
|
} catch (e) {
|
|
setError(String(e))
|
|
} finally {
|
|
setLoading(false)
|
|
setRefreshing(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
load()
|
|
const t = setInterval(() => load(true), REFRESH_MS)
|
|
return () => clearInterval(t)
|
|
}, [load])
|
|
|
|
const totals = overview?.totals ?? {}
|
|
const regions = useMemo(() => toRegions(overview), [overview])
|
|
|
|
if (loading && !overview) {
|
|
return (
|
|
<div className="flex items-center justify-center p-16">
|
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<h2 className="text-2xl font-bold tracking-tight">Node Fleet</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
Live status of the managed luxd fleet across devnet, testnet and mainnet
|
|
{updatedAt ? ` · updated ${updatedAt.toLocaleTimeString()}` : ""}
|
|
</p>
|
|
</div>
|
|
<Button variant="outline" size="sm" onClick={() => load()} disabled={refreshing}>
|
|
<RefreshCcw className={`mr-2 h-4 w-4 ${refreshing ? "animate-spin" : ""}`} />
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
|
|
{error && !overview ? (
|
|
<Card>
|
|
<CardContent className="p-6">
|
|
<div className="rounded-md bg-destructive/10 p-4 text-sm text-destructive">{error}</div>
|
|
</CardContent>
|
|
</Card>
|
|
) : null}
|
|
|
|
{/* Totals */}
|
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
|
<StatNumber icon={NetworkIcon} label="Networks" value={totals.networks ?? overview?.networks.length} />
|
|
<StatNumber icon={Server} label="Validators" value={totals.validators} />
|
|
<StatNumber
|
|
icon={Heart}
|
|
label="Nodes ready"
|
|
value={totals.nodes_ready}
|
|
suffix={totals.nodes_total ? ` / ${totals.nodes_total}` : undefined}
|
|
accent="text-green-500"
|
|
/>
|
|
<StatNumber icon={Users} label="Peer connections" value={totals.total_peers} />
|
|
</div>
|
|
|
|
{/* World map (only when not compact) */}
|
|
{!compact && regions.length > 0 ? (
|
|
<Card className="border-zinc-800 overflow-hidden">
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground">Fleet topology</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
<FleetMap regions={regions} />
|
|
</CardContent>
|
|
</Card>
|
|
) : null}
|
|
|
|
{/* Per-network cards */}
|
|
<div className="space-y-4">
|
|
{overview?.networks.map((f) => (
|
|
<NetworkCard key={f.network} fleet={f} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|