Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
97 changes: 97 additions & 0 deletions web/src/app/api/explorer/validators-geo/route.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
const validatorMap = new Map<string, { moniker: string; nodeId: string }>();

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';
41 changes: 41 additions & 0 deletions web/src/app/explorer/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -90,6 +102,7 @@ export default function ExplorerPage() {
const [stats, setStats] = useState<BlockchainStats | null>(null);
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [blocks, setBlocks] = useState<Block[]>([]);
const [validatorLocations, setValidatorLocations] = useState<ValidatorGeoLocation[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -736,6 +765,18 @@ export default function ExplorerPage() {
</Card>
</div>
)}

{/* Global Validator Map */}
{validatorLocations.length > 0 && (
<div className="mt-6">
<div className="flex items-center gap-2 mb-3">
<MapPin className="h-5 w-5" />
<h3 className="text-lg font-semibold">Global Validator Network</h3>
<Badge variant="secondary">{validatorLocations.length} nodes</Badge>
</div>
<ValidatorMap validators={validatorLocations} />
</div>
)}
</CardContent>
</Card>
</TabsContent>
Expand Down
206 changes: 206 additions & 0 deletions web/src/components/ui/validator-map.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null);
const map = useRef<mapboxgl.Map | null>(null);
const markers = useRef<mapboxgl.Marker[]>([]);
const [mapLoaded, setMapLoaded] = useState(false);
const animationRef = useRef<number | null>(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 = `
<div style="padding: 8px; min-width: 200px;">
<div style="font-weight: 600; margin-bottom: 4px; color: #2563eb;">${validator.moniker}</div>
${validator.city ? `<div style="font-size: 12px; color: #64748b; margin-bottom: 2px;">πŸ“ ${validator.city}, ${validator.country}</div>` : ''}
<div style="font-size: 11px; color: #64748b; margin-bottom: 4px;">${validator.ip}</div>
${validator.nodeId ? `<div style="font-size: 10px; font-family: monospace; color: #64748b;">Node: ${validator.nodeId.substring(0, 12)}...</div>` : ''}
</div>
`;

// 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 (
<div ref={mapContainer} style={{ width: '100%', height: '600px', borderRadius: '0' }} />
);
}
// Cleanup animation on unmount or when validators change
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};