Files
zeekayandHanzo Dev 7d293212cb refactor: web/ is now console/ — bootnode/bootnode is the backend, bootnode/console the frontend
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]>
2026-07-26 17:47:51 -07:00

163 lines
4.4 KiB
TypeScript

"use client"
import { useEffect, useRef, useState } from "react"
import * as d3 from "d3"
interface NodePoint {
id: string
name: string
point: [number, number] // [longitude, latitude]
count: number
status: "running" | "stopped" | "syncing" | "error"
chain?: string
}
interface NodeMapProps {
data: NodePoint[]
width?: number
height?: number
className?: string
}
const statusColors = {
running: "#22c55e", // green-500
syncing: "#eab308", // yellow-500
stopped: "#6b7280", // gray-500
error: "#ef4444", // red-500
}
export function NodeMap({ data, width = 800, height = 400, className }: NodeMapProps) {
const svgRef = useRef<SVGSVGElement>(null)
const tooltipRef = useRef<HTMLDivElement>(null)
const [geoData, setGeoData] = useState<any>(null)
useEffect(() => {
d3.json("/geo.json").then((data) => {
setGeoData(data)
}).catch(err => {
console.error("Failed to load geo.json:", err)
})
}, [])
useEffect(() => {
if (!svgRef.current || !geoData) return
const svg = d3.select(svgRef.current)
svg.selectAll("*").remove()
const projection = d3.geoMercator()
.translate([width * 0.49, height * 0.63])
.scale(width / 6)
const path = d3.geoPath().projection(projection)
// Draw world map
svg.append("path")
.datum(geoData)
.attr("d", path as any)
.attr("fill", "hsl(var(--muted))")
.attr("stroke", "hsl(var(--border))")
.attr("stroke-width", 0.5)
// Create tooltip
const tooltip = d3.select(tooltipRef.current)
// Draw node points
const circles = svg.selectAll("circle")
.data(data)
.enter()
.append("circle")
.attr("cx", (d) => projection(d.point)?.[0] ?? 0)
.attr("cy", (d) => projection(d.point)?.[1] ?? 0)
.attr("r", 0)
.attr("fill", (d) => statusColors[d.status])
.attr("stroke", "#fff")
.attr("stroke-width", 2)
.attr("opacity", 0.85)
.style("cursor", "pointer")
.on("mouseenter", function(event, d) {
d3.select(this)
.transition()
.duration(150)
.attr("r", Math.sqrt(d.count * 10 + 10) + 4)
.attr("opacity", 1)
tooltip
.style("display", "block")
.style("left", `${event.offsetX + 15}px`)
.style("top", `${event.offsetY - 10}px`)
.html(`
<div class="font-medium">${d.name}</div>
<div class="text-sm text-muted-foreground">${d.chain || "Multi-chain"}</div>
<div class="text-sm">
<span class="inline-block w-2 h-2 rounded-full mr-1" style="background: ${statusColors[d.status]}"></span>
${d.status.charAt(0).toUpperCase() + d.status.slice(1)}
</div>
<div class="text-sm">${d.count} node${d.count !== 1 ? "s" : ""}</div>
`)
})
.on("mouseleave", function(event, d) {
d3.select(this)
.transition()
.duration(150)
.attr("r", Math.sqrt(d.count * 10 + 10))
.attr("opacity", 0.85)
tooltip.style("display", "none")
})
// Animate circles in
circles.transition()
.delay((_, i) => i * 100)
.duration(500)
.attr("r", (d) => Math.sqrt(d.count * 10 + 10))
// Add pulse animation for running nodes
svg.selectAll("circle")
.filter((d: any) => d.status === "running")
.each(function() {
const circle = d3.select(this)
const originalR = parseFloat(circle.attr("r") || "0")
function pulse() {
circle
.transition()
.duration(1500)
.attr("r", originalR + 3)
.transition()
.duration(1500)
.attr("r", originalR)
.on("end", pulse)
}
setTimeout(pulse, Math.random() * 2000)
})
}, [data, geoData, width, height])
if (!geoData) {
return (
<div className={`flex items-center justify-center ${className}`} style={{ width, height }}>
<div className="animate-pulse text-muted-foreground">Loading map...</div>
</div>
)
}
return (
<div className={`relative ${className}`}>
<svg
ref={svgRef}
width={width}
height={height}
className="overflow-visible"
/>
<div
ref={tooltipRef}
className="absolute pointer-events-none bg-popover border border-border rounded-lg shadow-lg p-3 z-50"
style={{ display: "none" }}
/>
</div>
)
}
export default NodeMap