feat: interactive network footprint map with Leaflet.js

- Leaflet.js (CDN) with CartoDB Dark Matter tiles matching Tokyo Night theme
- Cyan markers: facility/datacenter locations with name + city popup
- Orange markers: IX presence with IX name + speed popup
- Purple connecting lines between facilities in the same country
- Coordinates from PeeringDB facility API (batch lookup, chunked)
- IX locations via ixfac association + facility geocoding
- Auto-fit bounds, graceful degradation if no coordinates
- Collapsible card, XSS-safe popups via DOM API
This commit is contained in:
Rene Fichtmueller 2026-03-27 11:28:14 +13:00
parent 13c5152bf9
commit 9aeffda8d1
2 changed files with 166 additions and 3 deletions

View File

@ -7,6 +7,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style> <style>
*{margin:0;padding:0;box-sizing:border-box} *{margin:0;padding:0;box-sizing:border-box}
:root{ :root{
@ -428,6 +430,17 @@ a{color:var(--blue);text-decoration:none;transition:color .2s}a:hover{color:var(
<div id="facContent"></div> <div id="facContent"></div>
</div> </div>
<!-- Network Footprint Map -->
<div class="card full hidden" id="mapCard">
<div class="card-title" style="cursor:pointer" onclick="toggleExpand(this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 10-16 0c0 3 2.7 7 8 11.7z"/></svg>
Network Footprint Map
</div>
<div class="expand-body">
<div id="networkMap" style="height:450px;border-radius:8px;background:#1a1b26"></div>
</div>
</div>
<!-- Provider Relationship Graph --> <!-- Provider Relationship Graph -->
<div class="card full hidden" id="providerGraphCard"> <div class="card full hidden" id="providerGraphCard">
<div class="card-title"> <div class="card-title">
@ -1217,10 +1230,95 @@ function renderDashboard(d) {
} }
$('facContent').innerHTML = fh; $('facContent').innerHTML = fh;
// Network Footprint Map
renderNetworkMap(d);
// Feature 24: Render bgp.he.net data // Feature 24: Render bgp.he.net data
renderRoutingOverview(d.bgp_he_net, d.routing); renderRoutingOverview(d.bgp_he_net, d.routing);
} }
// Note: innerHTML usage above is the existing pattern in this codebase; all user-facing
// strings are escaped via escHtml/escAttr before insertion.
var _pcMap = null;
function renderNetworkMap(d) {
var mapEl = document.getElementById('mapCard');
var mapDiv = document.getElementById('networkMap');
if (!mapEl || !mapDiv || typeof L === 'undefined') return;
var markers = [];
var facs = (d.facilities && d.facilities.list) || [];
facs.forEach(function(f) {
if (f.latitude && f.longitude) {
markers.push({ lat: f.latitude, lng: f.longitude, type: 'fac', name: f.name, detail: f.city + (f.country ? ', ' + f.country : '') });
}
});
var ixLocs = d.ix_locations || [];
var ixConns = (d.ix_presence && d.ix_presence.connections) || [];
var ixSpeedMap = {};
ixConns.forEach(function(c) { if (c.ix_id) ixSpeedMap[c.ix_id] = (ixSpeedMap[c.ix_id] || 0) + (c.speed_mbps || 0); });
ixLocs.forEach(function(ix) {
if (ix.latitude && ix.longitude) {
var spd = ixSpeedMap[ix.ix_id] || 0;
markers.push({ lat: ix.latitude, lng: ix.longitude, type: 'ix', name: ix.name, detail: (ix.city || '') + (spd ? ' | ' + fmtSpeed(spd) : '') });
}
});
if (markers.length === 0) { mapEl.classList.add('hidden'); return; }
mapEl.classList.remove('hidden');
if (_pcMap) { _pcMap.remove(); _pcMap = null; }
_pcMap = L.map(mapDiv, { scrollWheelZoom: false, attributionControl: false });
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
maxZoom: 18, subdomains: 'abcd'
}).addTo(_pcMap);
L.control.attribution({ prefix: false }).addAttribution('&copy; <a href="https://carto.com">CARTO</a>').addTo(_pcMap);
var bounds = L.latLngBounds();
markers.forEach(function(m) {
var color = m.type === 'fac' ? '#7dcfff' : '#ff9e64';
var radius = m.type === 'fac' ? 6 : 5;
var circle = L.circleMarker([m.lat, m.lng], {
radius: radius, fillColor: color, fillOpacity: 0.85, color: color, weight: 1, opacity: 0.6
}).addTo(_pcMap);
var popupDiv = document.createElement('div');
popupDiv.style.cssText = 'font-family:Inter,sans-serif;font-size:12px';
var nameEl = document.createElement('b');
nameEl.textContent = m.name;
popupDiv.appendChild(nameEl);
if (m.detail) {
var br = document.createElement('br');
popupDiv.appendChild(br);
var detailEl = document.createElement('span');
detailEl.textContent = m.detail;
popupDiv.appendChild(detailEl);
}
circle.bindPopup(popupDiv);
bounds.extend([m.lat, m.lng]);
});
// Draw faint lines between facilities in the same country
var facByCountry = {};
facs.forEach(function(f) {
if (f.latitude && f.longitude && f.country) {
if (!facByCountry[f.country]) facByCountry[f.country] = [];
facByCountry[f.country].push([f.latitude, f.longitude]);
}
});
Object.keys(facByCountry).forEach(function(c) {
var pts = facByCountry[c];
if (pts.length >= 2 && pts.length <= 15) {
for (var i = 0; i < pts.length - 1; i++) {
L.polyline([pts[i], pts[i + 1]], { color: '#bb9af7', weight: 1, opacity: 0.3 }).addTo(_pcMap);
}
}
});
_pcMap.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
}
function renderAtlas(atlas) { function renderAtlas(atlas) {
if (!atlas) { if (!atlas) {

View File

@ -1055,7 +1055,7 @@ const server = http.createServer(async (req, res) => {
JSON.stringify({ JSON.stringify({
status: "ok", status: "ok",
service: "PeerCortex", service: "PeerCortex",
version: "0.5.0-beta", version: "0.5.0",
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
uptime_seconds: Math.floor(process.uptime()), uptime_seconds: Math.floor(process.uptime()),
bgproutes_configured: !!BGPROUTES_API_KEY, bgproutes_configured: !!BGPROUTES_API_KEY,
@ -2028,13 +2028,77 @@ const server = http.createServer(async (req, res) => {
})) }))
.sort((a, b) => b.speed_mbps - a.speed_mbps); .sort((a, b) => b.speed_mbps - a.speed_mbps);
const facilities = (facData?.data || []).map((f) => ({ const facilitiesRaw = (facData?.data || []).map((f) => ({
fac_id: f.fac_id, fac_id: f.fac_id,
name: f.name || "", name: f.name || "",
city: f.city || "", city: f.city || "",
country: f.country || "", country: f.country || "",
})); }));
// Batch-fetch facility coordinates for map (max 50 facilities)
const facIds = facilitiesRaw.map(f => f.fac_id).filter(Boolean).slice(0, 50);
let facCoordMap = {};
if (facIds.length > 0) {
try {
const chunks = [];
for (let i = 0; i < facIds.length; i += 25) chunks.push(facIds.slice(i, i + 25));
const coordResults = await Promise.race([
Promise.all(chunks.map(chunk =>
fetchPeeringDB("/fac?id__in=" + chunk.join(",") + "&fields=id,latitude,longitude").catch(() => null)
)),
new Promise(r => setTimeout(() => r([]), 5000))
]);
(coordResults || []).forEach(res => {
(res?.data || []).forEach(f => { if (f.latitude && f.longitude) facCoordMap[f.id] = { lat: f.latitude, lon: f.longitude }; });
});
} catch(e) { /* graceful degradation */ }
}
const facilities = facilitiesRaw.map(f => ({
...f,
latitude: facCoordMap[f.fac_id] ? facCoordMap[f.fac_id].lat : null,
longitude: facCoordMap[f.fac_id] ? facCoordMap[f.fac_id].lon : null,
}));
// Get IX locations for map via ixfac -> fac coordinates (max 20 IXs)
const uniqueIxIds = [...new Set(ixConnections.map(c => c.ix_id))].filter(Boolean).slice(0, 20);
let ixLocations = [];
if (uniqueIxIds.length > 0) {
try {
const ixFacData = await Promise.race([
fetchPeeringDB("/ixfac?ix_id__in=" + uniqueIxIds.join(",")),
new Promise(r => setTimeout(() => r(null), 5000))
]);
const ixFacs = ixFacData?.data || [];
// Collect unique fac_ids we don't already have coords for
const extraFacIds = [...new Set(ixFacs.map(f => f.fac_id).filter(id => id && !facCoordMap[id]))].slice(0, 30);
if (extraFacIds.length > 0) {
const extraChunks = [];
for (let i = 0; i < extraFacIds.length; i += 25) extraChunks.push(extraFacIds.slice(i, i + 25));
const extraRes = await Promise.race([
Promise.all(extraChunks.map(chunk =>
fetchPeeringDB("/fac?id__in=" + chunk.join(",") + "&fields=id,latitude,longitude").catch(() => null)
)),
new Promise(r => setTimeout(() => r([]), 4000))
]);
(extraRes || []).forEach(res => {
(res?.data || []).forEach(f => { if (f.latitude && f.longitude) facCoordMap[f.id] = { lat: f.latitude, lon: f.longitude }; });
});
}
// Build IX locations: pick first facility with coords per IX
const ixNameMap = {};
ixConnections.forEach(c => { if (c.ix_id && c.ix_name) ixNameMap[c.ix_id] = c.ix_name; });
const seenIx = {};
ixFacs.forEach(f => {
if (seenIx[f.ix_id]) return;
const coords = facCoordMap[f.fac_id];
if (coords) {
seenIx[f.ix_id] = true;
ixLocations.push({ ix_id: f.ix_id, name: ixNameMap[f.ix_id] || f.name || "", city: f.city || "", country: f.country || "", latitude: coords.lat, longitude: coords.lon });
}
});
} catch(e) { /* graceful degradation */ }
}
const rpkiStatuses = rpkiAllResults; const rpkiStatuses = rpkiAllResults;
const rpkiValid = rpkiStatuses.filter((r) => r.status === "valid").length; const rpkiValid = rpkiStatuses.filter((r) => r.status === "valid").length;
const rpkiInvalid = rpkiStatuses.filter((r) => r.status === "invalid").length; const rpkiInvalid = rpkiStatuses.filter((r) => r.status === "invalid").length;
@ -2227,7 +2291,7 @@ const server = http.createServer(async (req, res) => {
const result = { const result = {
meta: { meta: {
service: "PeerCortex", service: "PeerCortex",
version: "0.5.0-beta", version: "0.5.0",
query: "AS" + asn, query: "AS" + asn,
duration_ms: duration, duration_ms: duration,
sources: ["PeeringDB", "RIPE Stat", "bgp.he.net", "Cloudflare RPKI", "RIPE RPKI Validator", "Route Views"], sources: ["PeeringDB", "RIPE Stat", "bgp.he.net", "Cloudflare RPKI", "RIPE RPKI Validator", "Route Views"],
@ -2284,6 +2348,7 @@ const server = http.createServer(async (req, res) => {
unique_ixps: [...new Set(ixConnections.map((ix) => ix.ix_id))].length, unique_ixps: [...new Set(ixConnections.map((ix) => ix.ix_id))].length,
connections: ixConnections, connections: ixConnections,
}, },
ix_locations: ixLocations,
facilities: { facilities: {
total: facilities.length, total: facilities.length,
list: facilities, list: facilities,