mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-10-25 02:31:30 +00:00
Names the two halves for what they are. The repo already holds both (12407e9 merged bootnode/bootnode into this history); this makes the frontend directory say so, so the layout reads the way the owner describes the system: backend and frontend separate, one repo. git mv throughout — 187 renames, history preserved. Every path reference follows: hanzo.yml build context and dockerfile, compose.yml, compose.prod.yml, start.sh, LLM.md, infra/k8s/web-deployments.yaml, infra/cloud/deploy.sh, and the Dockerfile's own COPY paths. Image and container names (bootnode-web, the -web tag suffix) are deliberately NOT renamed here: those are a deploy contract the App CR and ingress bind to on port 3001, and breaking them is a separate, staged change. Console builds green after the move; the Go tree builds. Co-authored-by: Hanzo Dev <[email protected]>
412 lines
15 KiB
TypeScript
412 lines
15 KiB
TypeScript
"use client"
|
|
|
|
import { useCallback, useMemo, useRef, useState } from "react"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface NodeLocation {
|
|
id: string
|
|
name: string
|
|
chain: string
|
|
network: string
|
|
region: string
|
|
status: "running" | "syncing" | "provisioning" | "stopped" | "error"
|
|
latitude?: number
|
|
longitude?: number
|
|
nodeType: string
|
|
peerCount?: number
|
|
syncProgress?: number
|
|
}
|
|
|
|
export interface NodeGlobeProps {
|
|
nodes: NodeLocation[]
|
|
onNodeClick?: (nodeId: string) => void
|
|
selectedNetwork?: string
|
|
className?: string
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Region coordinate mapping
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const REGION_COORDS: Record<string, { lat: number; lng: number; label: string }> = {
|
|
// DigitalOcean regions
|
|
"nyc1": { lat: 40.7128, lng: -74.0060, label: "New York 1" },
|
|
"nyc3": { lat: 40.7128, lng: -74.0060, label: "New York 3" },
|
|
"sfo1": { lat: 37.7749, lng: -122.4194, label: "San Francisco 1" },
|
|
"sfo2": { lat: 37.7749, lng: -122.4194, label: "San Francisco 2" },
|
|
"sfo3": { lat: 37.7749, lng: -122.4194, label: "San Francisco 3" },
|
|
"ams3": { lat: 52.3676, lng: 4.9041, label: "Amsterdam" },
|
|
"sgp1": { lat: 1.3521, lng: 103.8198, label: "Singapore" },
|
|
"lon1": { lat: 51.5074, lng: -0.1278, label: "London" },
|
|
"fra1": { lat: 50.1109, lng: 8.6821, label: "Frankfurt" },
|
|
"tor1": { lat: 43.6532, lng: -79.3832, label: "Toronto" },
|
|
"blr1": { lat: 12.9716, lng: 77.5946, label: "Bangalore" },
|
|
"syd1": { lat: -33.8688, lng: 151.2093, label: "Sydney" },
|
|
// AWS-style
|
|
"us-east-1": { lat: 39.0438, lng: -77.4874, label: "N. Virginia" },
|
|
"us-west-2": { lat: 45.5152, lng: -122.6784, label: "Oregon" },
|
|
"eu-west-1": { lat: 53.3498, lng: -6.2603, label: "Ireland" },
|
|
"ap-southeast-1": { lat: 1.3521, lng: 103.8198, label: "Singapore" },
|
|
"ap-northeast-1": { lat: 35.6762, lng: 139.6503, label: "Tokyo" },
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Status colour palette
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const STATUS_COLORS: Record<NodeLocation["status"], string> = {
|
|
running: "#22c55e",
|
|
syncing: "#eab308",
|
|
provisioning: "#f97316",
|
|
stopped: "#6b7280",
|
|
error: "#ef4444",
|
|
}
|
|
|
|
const STATUS_LABELS: Record<NodeLocation["status"], string> = {
|
|
running: "Running",
|
|
syncing: "Syncing",
|
|
provisioning: "Provisioning",
|
|
stopped: "Stopped",
|
|
error: "Error",
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mercator projection helper
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Convert lat/lng to x/y in 0..1 normalised Mercator space. */
|
|
function toMercator(lat: number, lng: number): { x: number; y: number } {
|
|
const x = (lng + 180) / 360
|
|
const latRad = (lat * Math.PI) / 180
|
|
const mercN = Math.log(Math.tan(Math.PI / 4 + latRad / 2))
|
|
const y = 0.5 - mercN / (2 * Math.PI)
|
|
return { x, y }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Simplified world-outline SVG path data
|
|
//
|
|
// Hand-reduced continent outlines in Mercator projection coordinates, fitting
|
|
// a viewBox of 0 0 1000 500. Dark-themed: dark ocean, lighter landmasses.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const WORLD_PATH = [
|
|
// --- North America ---
|
|
"M 130,105 L 122,100 118,92 120,84 126,78 135,74 148,72 162,70",
|
|
"L 175,74 185,72 195,76 205,82 215,90 222,98 228,106 232,115",
|
|
"L 230,124 224,132 218,140 212,148 205,156 198,162 192,168",
|
|
"L 186,174 180,178 174,180 168,182 163,186 158,190 154,186",
|
|
"L 150,180 146,175 142,168 138,160 135,152 132,144 130,136",
|
|
"L 128,128 126,120 128,112 Z",
|
|
// --- Mexico / Central America ---
|
|
"M 154,186 L 158,192 162,198 165,205 168,212 172,218 175,224",
|
|
"L 178,228 176,232 172,230 168,226 164,222 160,218 156,214",
|
|
"L 153,208 152,200 152,194 Z",
|
|
// --- South America ---
|
|
"M 178,228 L 186,236 194,245 202,255 208,265 214,278 218,292",
|
|
"L 222,306 225,320 226,335 224,348 220,360 215,370 208,378",
|
|
"L 200,384 192,386 186,380 182,372 180,360 178,346 176,332",
|
|
"L 175,318 176,304 178,290 180,276 182,262 183,248 180,238 Z",
|
|
// --- Europe ---
|
|
"M 448,82 L 455,76 465,72 476,70 486,72 495,76 502,82 506,90",
|
|
"L 504,96 498,102 490,106 482,108 474,106 467,102 460,96",
|
|
"L 454,90 450,86 Z",
|
|
// --- British Isles ---
|
|
"M 432,82 L 437,76 443,78 444,84 440,88 434,86 Z",
|
|
// --- Scandinavia ---
|
|
"M 476,50 L 484,44 492,42 498,46 502,54 500,62 496,68 490,66",
|
|
"L 484,60 480,54 Z",
|
|
// --- Africa ---
|
|
"M 448,148 L 458,142 470,138 484,136 496,140 508,146 516,156",
|
|
"L 522,168 526,182 528,196 526,212 522,228 518,244 512,258",
|
|
"L 504,268 496,274 486,276 478,272 470,264 464,254 458,242",
|
|
"L 454,228 452,214 450,200 448,186 446,172 446,158 Z",
|
|
// --- Arabian Peninsula ---
|
|
"M 530,142 L 540,138 548,142 552,150 548,158 540,162 534,158",
|
|
"L 530,150 Z",
|
|
// --- India subcontinent ---
|
|
"M 582,132 L 594,128 604,132 612,142 616,156 612,170 606,180",
|
|
"L 598,186 590,184 584,176 580,164 578,152 580,140 Z",
|
|
// --- Southeast Asia peninsula ---
|
|
"M 622,156 L 630,150 638,154 640,162 636,170 628,172 624,164 Z",
|
|
// --- East Asia / China ---
|
|
"M 618,80 L 630,74 644,70 658,68 672,72 682,80 690,90 692,102",
|
|
"L 688,114 680,122 670,128 660,130 650,128 640,122 632,114",
|
|
"L 626,104 622,94 620,86 Z",
|
|
// --- Japan ---
|
|
"M 698,86 L 704,80 710,84 712,92 710,102 704,108 698,104 696,96 Z",
|
|
// --- Maritime Southeast Asia ---
|
|
"M 632,212 L 644,206 658,204 672,208 684,214 694,220 690,228",
|
|
"L 678,230 666,226 654,222 642,218 Z",
|
|
// --- Australia ---
|
|
"M 678,288 L 694,278 712,274 730,276 746,282 756,292 762,304",
|
|
"L 760,318 752,330 740,336 726,338 712,334 700,326 690,316",
|
|
"L 684,304 680,296 Z",
|
|
// --- New Zealand ---
|
|
"M 786,332 L 792,326 798,330 800,338 796,344 790,342 788,336 Z",
|
|
// --- Northern Asia / Russia ---
|
|
"M 508,52 L 530,46 558,42 588,38 620,36 654,34 686,38 712,44",
|
|
"L 732,52 742,60 736,66 722,64 706,60 686,56 664,54 642,52",
|
|
"L 620,50 598,48 576,48 556,50 536,52 520,54 512,54 Z",
|
|
].join(" ")
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Resolved node with pixel coordinates
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type ResolvedNode = NodeLocation & {
|
|
px: number
|
|
py: number
|
|
regionLabel: string
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function NodeGlobe({
|
|
nodes,
|
|
onNodeClick,
|
|
selectedNetwork,
|
|
className,
|
|
}: NodeGlobeProps) {
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const [hoveredNode, setHoveredNode] = useState<ResolvedNode | null>(null)
|
|
const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 })
|
|
|
|
const MAP_W = 1000
|
|
const MAP_H = 500
|
|
|
|
// ------- derived data -------
|
|
|
|
const filteredNodes = useMemo(() => {
|
|
if (!selectedNetwork || selectedNetwork === "all") return nodes
|
|
return nodes.filter((n) => n.network === selectedNetwork)
|
|
}, [nodes, selectedNetwork])
|
|
|
|
const resolvedNodes = useMemo<ResolvedNode[]>(() => {
|
|
const out: ResolvedNode[] = []
|
|
for (const node of filteredNodes) {
|
|
let lat = node.latitude
|
|
let lng = node.longitude
|
|
let regionLabel = node.region
|
|
|
|
if ((lat == null || lng == null) && REGION_COORDS[node.region]) {
|
|
const rc = REGION_COORDS[node.region]
|
|
lat = rc.lat
|
|
lng = rc.lng
|
|
regionLabel = rc.label
|
|
}
|
|
|
|
if (lat == null || lng == null) continue
|
|
|
|
const { x, y } = toMercator(lat, lng)
|
|
out.push({ ...node, px: x * MAP_W, py: y * MAP_H, regionLabel })
|
|
}
|
|
return out
|
|
}, [filteredNodes])
|
|
|
|
const networks = useMemo(() => {
|
|
const s = new Set(nodes.map((n) => n.network))
|
|
return Array.from(s).sort()
|
|
}, [nodes])
|
|
|
|
// ------- event handlers -------
|
|
|
|
const handleMouseEnter = useCallback(
|
|
(node: ResolvedNode, e: React.MouseEvent) => {
|
|
setHoveredNode(node)
|
|
const rect = containerRef.current?.getBoundingClientRect()
|
|
if (rect) {
|
|
setTooltipPos({
|
|
x: e.clientX - rect.left + 14,
|
|
y: e.clientY - rect.top - 10,
|
|
})
|
|
}
|
|
},
|
|
[],
|
|
)
|
|
|
|
const handleMouseMove = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
if (!hoveredNode) return
|
|
const rect = containerRef.current?.getBoundingClientRect()
|
|
if (rect) {
|
|
setTooltipPos({
|
|
x: e.clientX - rect.left + 14,
|
|
y: e.clientY - rect.top - 10,
|
|
})
|
|
}
|
|
},
|
|
[hoveredNode],
|
|
)
|
|
|
|
const handleMouseLeave = useCallback(() => {
|
|
setHoveredNode(null)
|
|
}, [])
|
|
|
|
// ------- render -------
|
|
|
|
return (
|
|
<div ref={containerRef} className={cn("relative w-full", className)}>
|
|
{/* Pulse keyframes injected once */}
|
|
<style>{`
|
|
@keyframes ng-dot-pulse {
|
|
0%, 100% { r: 5; opacity: 0.9 }
|
|
50% { r: 7; opacity: 1 }
|
|
}
|
|
@keyframes ng-ring-pulse {
|
|
0% { r: 5; opacity: 0.5 }
|
|
100% { r: 18; opacity: 0 }
|
|
}
|
|
.ng-dot-running { animation: ng-dot-pulse 2s ease-in-out infinite }
|
|
.ng-dot-syncing { animation: ng-dot-pulse 1.2s ease-in-out infinite }
|
|
.ng-ring { animation: ng-ring-pulse 2.5s ease-out infinite; pointer-events: none }
|
|
`}</style>
|
|
|
|
{/* SVG flat map */}
|
|
<svg
|
|
viewBox={`0 0 ${MAP_W} ${MAP_H}`}
|
|
className="w-full h-auto"
|
|
preserveAspectRatio="xMidYMid meet"
|
|
role="img"
|
|
aria-label="World map showing blockchain node locations"
|
|
>
|
|
{/* Ocean background */}
|
|
<rect width={MAP_W} height={MAP_H} rx={8} fill="#0a0e17" />
|
|
|
|
{/* Graticule grid */}
|
|
{Array.from({ length: 17 }, (_, i) => {
|
|
const x = (i + 1) * (MAP_W / 18)
|
|
return <line key={`v${i}`} x1={x} y1={0} x2={x} y2={MAP_H} stroke="#1e293b" strokeWidth={0.5} />
|
|
})}
|
|
{Array.from({ length: 8 }, (_, i) => {
|
|
const y = (i + 1) * (MAP_H / 9)
|
|
return <line key={`h${i}`} x1={0} y1={y} x2={MAP_W} y2={y} stroke="#1e293b" strokeWidth={0.5} />
|
|
})}
|
|
|
|
{/* Landmasses */}
|
|
<path d={WORLD_PATH} fill="#1a2332" stroke="#2d3f54" strokeWidth={1} strokeLinejoin="round" />
|
|
|
|
{/* Node markers */}
|
|
{resolvedNodes.map((node) => {
|
|
const color = STATUS_COLORS[node.status]
|
|
const hovered = hoveredNode?.id === node.id
|
|
// Stagger animation starts to avoid synchronised pulses
|
|
const delay = `${(node.id.charCodeAt(0) % 7) * 0.35}s`
|
|
|
|
return (
|
|
<g
|
|
key={node.id}
|
|
transform={`translate(${node.px},${node.py})`}
|
|
className="cursor-pointer"
|
|
onMouseEnter={(e) => handleMouseEnter(node, e)}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseLeave={handleMouseLeave}
|
|
onClick={() => onNodeClick?.(node.id)}
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={`${node.name} - ${STATUS_LABELS[node.status]}`}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" || e.key === " ") onNodeClick?.(node.id)
|
|
}}
|
|
>
|
|
{/* Expanding ring for active statuses */}
|
|
{(node.status === "running" || node.status === "syncing") && (
|
|
<circle
|
|
r={5}
|
|
fill="none"
|
|
stroke={color}
|
|
strokeWidth={1.5}
|
|
className="ng-ring"
|
|
style={{ animationDelay: delay }}
|
|
/>
|
|
)}
|
|
|
|
{/* Ambient glow */}
|
|
<circle r={hovered ? 12 : 8} fill={color} opacity={0.15} />
|
|
|
|
{/* Primary dot */}
|
|
<circle
|
|
r={hovered ? 7 : 5}
|
|
fill={color}
|
|
stroke="#0a0e17"
|
|
strokeWidth={1.5}
|
|
opacity={0.9}
|
|
className={cn(
|
|
node.status === "running" && "ng-dot-running",
|
|
node.status === "syncing" && "ng-dot-syncing",
|
|
)}
|
|
style={{ animationDelay: delay }}
|
|
/>
|
|
|
|
{/* Centre highlight */}
|
|
<circle r={hovered ? 3 : 2} fill="#fff" opacity={0.6} />
|
|
</g>
|
|
)
|
|
})}
|
|
</svg>
|
|
|
|
{/* Tooltip */}
|
|
{hoveredNode && (
|
|
<div
|
|
className="absolute z-50 pointer-events-none rounded-lg border border-border bg-popover p-3 text-sm shadow-lg min-w-[180px]"
|
|
style={{
|
|
left: tooltipPos.x,
|
|
top: tooltipPos.y,
|
|
transform:
|
|
tooltipPos.x > (containerRef.current?.clientWidth ?? 800) * 0.7
|
|
? "translateX(-110%)"
|
|
: undefined,
|
|
}}
|
|
>
|
|
<div className="font-medium text-foreground">{hoveredNode.name}</div>
|
|
<div className="mt-0.5 text-xs text-muted-foreground">
|
|
{hoveredNode.chain} / {hoveredNode.network}
|
|
</div>
|
|
<div className="mt-1.5 flex items-center gap-1.5">
|
|
<span
|
|
className="inline-block h-2 w-2 rounded-full"
|
|
style={{ background: STATUS_COLORS[hoveredNode.status] }}
|
|
/>
|
|
<span className="text-foreground">{STATUS_LABELS[hoveredNode.status]}</span>
|
|
</div>
|
|
<div className="mt-1 text-xs text-muted-foreground">{hoveredNode.regionLabel}</div>
|
|
<div className="text-xs text-muted-foreground">Type: {hoveredNode.nodeType}</div>
|
|
{hoveredNode.peerCount != null && (
|
|
<div className="text-xs text-muted-foreground">Peers: {hoveredNode.peerCount}</div>
|
|
)}
|
|
{hoveredNode.syncProgress != null && hoveredNode.status === "syncing" && (
|
|
<div className="text-xs text-muted-foreground">
|
|
Sync: {(hoveredNode.syncProgress * 100).toFixed(1)}%
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Legend */}
|
|
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 px-1 text-xs text-muted-foreground">
|
|
{(Object.keys(STATUS_COLORS) as NodeLocation["status"][]).map((status) => (
|
|
<div key={status} className="flex items-center gap-1.5">
|
|
<span
|
|
className="inline-block h-2.5 w-2.5 rounded-full"
|
|
style={{ background: STATUS_COLORS[status] }}
|
|
/>
|
|
<span>{STATUS_LABELS[status]}</span>
|
|
</div>
|
|
))}
|
|
{networks.length > 0 && (
|
|
<div className="ml-auto">
|
|
{resolvedNodes.length} node{resolvedNodes.length !== 1 ? "s" : ""}
|
|
{selectedNetwork && selectedNetwork !== "all" ? ` on ${selectedNetwork}` : ""}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|