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:
parent
13c5152bf9
commit
9aeffda8d1
@ -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('© <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) {
|
||||||
|
|||||||
71
server.js
71
server.js
@ -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,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user