diff --git a/web/package.json b/web/package.json index ca81c95..9758413 100644 --- a/web/package.json +++ b/web/package.json @@ -40,6 +40,7 @@ "date-fns": "^4.1.0", "helia": "^5.5.1", "lucide-react": "^0.544.0", + "mapbox-gl": "^3.8.0", "multiformats": "^13.4.1", "next": "^15.5.9", "react": "19.1.0", diff --git a/web/src/app/api/explorer/validators-geo/route.ts b/web/src/app/api/explorer/validators-geo/route.ts new file mode 100644 index 0000000..f67ca0f --- /dev/null +++ b/web/src/app/api/explorer/validators-geo/route.ts @@ -0,0 +1,97 @@ +import { NextResponse } from 'next/server'; + +const BLOCKCHAIN_RPC = process.env.BLOCKCHAIN_NODE || 'http://localhost:26657'; +const IP_GEOLOCATION_API = 'http://ip-api.com'; + +interface ValidatorGeoLocation { + ip: string; + lat: number; + lng: number; + moniker: string; + country: string; + city: string; + nodeId: string; +} + +/** + * Get validator locations using IP geolocation API + */ +export async function GET() { + try { + // Fetch net_info from RPC + const netInfoResponse = await fetch(`${BLOCKCHAIN_RPC}/net_info`); + + if (!netInfoResponse.ok) { + return NextResponse.json( + { error: 'Failed to fetch network info' }, + { status: 500 } + ); + } + + const netInfo = await netInfoResponse.json(); + const peers = netInfo.result?.peers || []; + + // Extract unique IPs from peers + const validatorIPs = new Set(); + const validatorMap = new Map(); + + peers.forEach((peer: any) => { + const remoteIp = peer.remote_ip; + if (remoteIp && remoteIp !== '127.0.0.1' && remoteIp !== 'localhost') { + validatorIPs.add(remoteIp); + validatorMap.set(remoteIp, { + moniker: peer.node_info?.moniker || 'Unknown', + nodeId: peer.node_info?.id || 'unknown', + }); + } + }); + + // Geolocate each IP using ip-api.com (free, no key required) + const geolocations: ValidatorGeoLocation[] = []; + + for (const ip of validatorIPs) { + try { + const geoResponse = await fetch(`${IP_GEOLOCATION_API}/json/${ip}?fields=status,lat,lon,country,city`); + + if (geoResponse.ok) { + const geoData = await geoResponse.json(); + + if (geoData.status === 'success') { + const validator = validatorMap.get(ip); + geolocations.push({ + ip, + lat: geoData.lat, + lng: geoData.lon, + moniker: validator?.moniker || 'Unknown', + country: geoData.country || 'Unknown', + city: geoData.city || 'Unknown', + nodeId: validator?.nodeId || 'unknown', + }); + } + } + + // Rate limiting: wait 100ms between requests to respect API limits + await new Promise(resolve => setTimeout(resolve, 100)); + } catch (error) { + console.error(`Failed to geolocate IP ${ip}:`, error); + } + } + + return NextResponse.json({ + success: true, + count: geolocations.length, + validators: geolocations, + }); + } catch (error) { + console.error('Error fetching validator geolocations:', error); + return NextResponse.json( + { + error: 'Failed to fetch validator geolocations', + details: error instanceof Error ? error.message : String(error) + }, + { status: 500 } + ); + } +} + +export const dynamic = 'force-dynamic'; diff --git a/web/src/app/explorer/page.tsx b/web/src/app/explorer/page.tsx index a87d4aa..15864f1 100644 --- a/web/src/app/explorer/page.tsx +++ b/web/src/app/explorer/page.tsx @@ -28,8 +28,10 @@ import { Hash, CheckCircle2, XCircle, + MapPin, } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; +import { ValidatorMap } from '@/components/ui/validator-map'; interface BlockchainStats { latestHeight: number; @@ -55,6 +57,16 @@ interface ValidatorInfo { connection_status: any; } +interface ValidatorGeoLocation { + ip: string; + lat: number; + lng: number; + moniker: string; + country: string; + city: string; + nodeId: string; +} + interface Transaction { txhash: string; height: string; @@ -90,6 +102,7 @@ export default function ExplorerPage() { const [stats, setStats] = useState(null); const [transactions, setTransactions] = useState([]); const [blocks, setBlocks] = useState([]); + const [validatorLocations, setValidatorLocations] = useState([]); const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); const [currentPage, setCurrentPage] = useState(1); @@ -99,6 +112,7 @@ export default function ExplorerPage() { fetchStats(); fetchTransactions(); fetchBlocks(); + fetchValidatorGeolocations(); // Auto-refresh every 5 seconds for more real-time stats const interval = setInterval(() => { @@ -169,6 +183,21 @@ export default function ExplorerPage() { } }; + const fetchValidatorGeolocations = async () => { + try { + const response = await fetch('/api/explorer/validators-geo'); + if (response.ok) { + const data = await response.json(); + if (data.success && data.validators) { + setValidatorLocations(data.validators); + console.log(`📍 Loaded ${data.count} validator locations`); + } + } + } catch (error) { + console.error('Error fetching validator geolocations:', error); + } + }; + const handleSearch = async () => { if (!searchQuery.trim()) return; @@ -736,6 +765,18 @@ export default function ExplorerPage() { )} + + {/* Global Validator Map */} + {validatorLocations.length > 0 && ( +
+
+ +

Global Validator Network

+ {validatorLocations.length} nodes +
+ +
+ )} diff --git a/web/src/components/ui/validator-map.tsx b/web/src/components/ui/validator-map.tsx new file mode 100644 index 0000000..c184735 --- /dev/null +++ b/web/src/components/ui/validator-map.tsx @@ -0,0 +1,206 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import mapboxgl from 'mapbox-gl'; +import 'mapbox-gl/dist/mapbox-gl.css'; + +// Replace with your Mapbox access token +mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN || ''; + +interface ValidatorLocation { + ip: string; + lat: number; + lng: number; + moniker: string; + country?: string; + city?: string; + nodeId?: string; +} + +interface ValidatorMapProps { + validators: ValidatorLocation[]; +} + +export function ValidatorMap({ validators }: ValidatorMapProps) { + const mapContainer = useRef(null); + const map = useRef(null); + const markers = useRef([]); + const [mapLoaded, setMapLoaded] = useState(false); + const animationRef = useRef(null); + + useEffect(() => { + if (!mapContainer.current) return; + + map.current = new mapboxgl.Map({ + container: mapContainer.current, + style: 'mapbox://styles/mapbox/light-v11', + center: [0, 20], + zoom: 2, + projection: 'mercator' + }); + + map.current.addControl(new mapboxgl.NavigationControl()); + + // Wait for style to load before allowing other operations + map.current.on('load', () => { + setMapLoaded(true); + }); + + return () => { + map.current?.remove(); + }; + }, []); + + useEffect(() => { + if (!map.current || !validators.length || !mapLoaded) return; + + // Remove old markers + markers.current.forEach(marker => marker.remove()); + markers.current = []; + + // Add connection lines layer if it doesn't exist + if (map.current.getSource('connections')) { + map.current.removeLayer('connections-layer'); + map.current.removeSource('connections'); + } + + // Create GeoJSON for connection lines + const connections: any = { + type: 'FeatureCollection', + features: [] + }; + + // Connect each validator to next validator in a mesh pattern + for (let i = 0; i < validators.length; i++) { + const nextIndex = (i + 1) % validators.length; + connections.features.push({ + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: [ + [validators[i].lng, validators[i].lat], + [validators[nextIndex].lng, validators[nextIndex].lat] + ] + } + }); + } + + // Add the connection lines to the map + map.current.addSource('connections', { + type: 'geojson', + data: connections + }); + + map.current.addLayer({ + id: 'connections-layer', + type: 'line', + source: 'connections', + layout: { + 'line-join': 'round', + 'line-cap': 'round' + }, + paint: { + 'line-color': '#3b82f6', + 'line-width': 2, + 'line-opacity': 0.4 + } + }); + + // Animate the connection lines + let dashOffset = 0; + const animateDashArray = () => { + dashOffset = (dashOffset + 0.5) % 10; + + if (map.current && map.current.getLayer('connections-layer')) { + map.current.setPaintProperty( + 'connections-layer', + 'line-dasharray', + [2, 4] + ); + map.current.setPaintProperty( + 'connections-layer', + 'line-offset', + Math.sin(dashOffset * 0.5) * 0.5 + ); + } + + animationRef.current = requestAnimationFrame(animateDashArray); + }; + + animateDashArray(); + + // Add markers for each validator + validators.forEach((validator) => { + const popupContent = ` +
+
${validator.moniker}
+ ${validator.city ? `
📍 ${validator.city}, ${validator.country}
` : ''} +
${validator.ip}
+ ${validator.nodeId ? `
Node: ${validator.nodeId.substring(0, 12)}...
` : ''} +
+ `; + + // Create custom marker with computer emoji + const el = document.createElement('div'); + el.className = 'server-marker'; + el.innerHTML = '🖥️'; + el.style.fontSize = '32px'; + el.style.cursor = 'pointer'; + + const marker = new mapboxgl.Marker({ element: el }) + .setLngLat([validator.lng, validator.lat]) + .setPopup( + new mapboxgl.Popup({ offset: 25 }).setHTML(popupContent) + ) + .addTo(map.current!); + + markers.current.push(marker); + }); + + // Smart zoom based on marker distribution + if (validators.length === 1) { + // Single marker: zoom in close + map.current.flyTo({ + center: [validators[0].lng, validators[0].lat], + zoom: 8, + duration: 1500 + }); + } else if (validators.length > 1) { + const bounds = new mapboxgl.LngLatBounds(); + validators.forEach(v => bounds.extend([v.lng, v.lat])); + + // Calculate distance between bounds + const ne = bounds.getNorthEast(); + const sw = bounds.getSouthWest(); + const distance = Math.sqrt( + Math.pow(ne.lng - sw.lng, 2) + Math.pow(ne.lat - sw.lat, 2) + ); + + // If markers are close together (distance < 30 degrees), zoom in more + // If spread out globally, zoom out to show all + if (distance < 30) { + map.current.fitBounds(bounds, { + padding: 80, + maxZoom: 10, + duration: 1500 + }); + } else { + map.current.fitBounds(bounds, { + padding: 50, + maxZoom: 4, + duration: 1500 + }); + } + } + }, [validators, mapLoaded]); + + return ( +
+ ); +} + // Cleanup animation on unmount or when validators change + return () => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + } + };