1// zebra-rs Traffic Path Visualizer â Frontend (3D Globe) 2// 3// Ported from the Graphiant topology viewer. Data comes from the local 4// backend, which queries each router over MCP (vtyctl mcp). There is no 5// TE telemetry here â the lab carries connectivity, IGP metrics, and 6// per-algorithm SPF paths, so that is what the globe shows. 7// 8// The frontend also runs from a static snapshot (`zebra-topology 9// snapshot`): data/manifest.js sets window.ZEBRA_SNAPSHOT, and every 10// query becomes a fetch of a pre-baked data/ file instead of the live 11// API. Destination filtering is client-side in both modes, so one 12// all-destinations topology per (source, algorithm) covers everything. 13 14// Set by data/manifest.js: null when served live, snapshot metadata 15// ({generatedAtEpoch, ...}) when exported as a static site. 16const SNAPSHOT = window.ZEBRA_SNAPSHOT || null; 17 18function routersUrl() { 19 return SNAPSHOT ? './data/routers.json' : './api/routers'; 20} 21 22function algorithmsUrl(source) { 23 return SNAPSHOT 24 ? './data/algorithms-' + encodeURIComponent(source) + '.json' 25 : './api/algorithms?source=' + encodeURIComponent(source); 26} 27 28function topologyUrl(source, algorithm) { 29 return SNAPSHOT 30 ? './data/topology-' + encodeURIComponent(source) + '-' + 31 encodeURIComponent(algorithm) + '.json' 32 : './api/topology?source=' + encodeURIComponent(source) + 33 '&algorithm=' + encodeURIComponent(algorithm) + 34 '&destination=__all__'; 35} 36 37const COLORS = ['#e6194b', '#3cb44b', '#4363d8', '#f58231', '#911eb4', '#42d4f4', '#f032e6', '#bfef45']; 38 39const REGION_COLORS = { 40 US: '#3b82f6', 41 EU: '#2ecc71', 42 AP: '#f59e0b', 43 SA: '#a855f7', 44}; 45 46const BASE_ARC_COLOR = 'rgba(170, 180, 200, 0.35)'; 47const INACTIVE_COLOR = '#95a5a6'; 48 49// Globe designs: a few looks for the earth itself, selectable from the 50// Globe dropdown (and the `globe` URL parameter). Every texture comes 51// from the same integrity-known three-globe example set on unpkg.com 52// the viewer already loads its earth from; the bump map and star-field 53// background are shared, only the surface and atmosphere change. 54const TEXTURE_BASE = '//unpkg.com/three-globe/example/img/'; 55const GLOBE_DESIGNS = { 56 'day': { 57 label: 'Day', 58 globe: TEXTURE_BASE + 'earth-day.jpg', 59 atmosphereColor: '#93c5fd', 60 atmosphereAltitude: 0.18, 61 }, 62 'blue-marble': { 63 label: 'Blue Marble', 64 globe: TEXTURE_BASE + 'earth-blue-marble.jpg', 65 atmosphereColor: '#3b82f6', 66 atmosphereAltitude: 0.18, 67 }, 68 'night': { 69 label: 'Night lights', 70 globe: TEXTURE_BASE + 'earth-night.jpg', 71 atmosphereColor: '#f59e0b', 72 atmosphereAltitude: 0.15, 73 }, 74 'dark': { 75 label: 'Dark', 76 globe: TEXTURE_BASE + 'earth-dark.jpg', 77 atmosphereColor: '#64748b', 78 atmosphereAltitude: 0.12, 79 }, 80}; 81const DEFAULT_DESIGN = 'day'; 82 83let map; 84 85// Rendered state 86let renderedPaths = []; 87let allArcs = []; // base connectivity arcs + path arcs 88let allPoints = []; 89let visiblePathIdx = new Set(); 90let currentNodeMap = {}; 91let currentEdgeCost = {}; 92let currentAlgorithm = 0; 93 94// Stale-response guards: selects can be changed faster than the MCP 95// round-trips complete, and an older response must never overwrite the 96// state a newer selection produced. 97let topologySeq = 0; 98let algorithmsSeq = 0; 99 100// The all-destinations topology for the current source|algorithm. 101// Flipping the destination re-renders from here â no re-query. 102let topologyCache = { key: null, data: null }; 103 104// The camera auto-aims only when the vantage point changes (new source 105// or destination) â never on an algorithm flip or refresh, so the view 106// the user set up stays put while the arcs re-route under it. 107let lastFocusKey = null; 108 109// Selections are mirrored into the URL query, so Source, Destination, 110// Algorithm and Globe all survive a page reload (and a view can be 111// shared as a link). Consumed once at startup, written back on every 112// load and on a globe-design change. 113const initialParams = new URLSearchParams(window.location.search); 114 115function selectValue(sel, value) { 116 if (value != null && [...sel.options].some(o => o.value === value)) { 117 sel.value = value; 118 return true; 119 } 120 return false;
121} 122 123function syncUrl() { 124 const params = new URLSearchParams(); 125 params.set('source', document.getElementById('source').value); 126 params.set('destination', document.getElementById('destination').value); 127 params.set('algorithm', document.getElementById('algorithm').value || '0'); 128 const globe = document.getElementById('globe').value; 129 if (globe !== DEFAULT_DESIGN) params.set('globe', globe); 130 // keep the globe-only switch (index.html reads it in <head>) 131 if (initialParams.get('ui') === 'off') params.set('ui', 'off'); 132 history.replaceState(null, '', '?' + params.toString()); 133} 134 135function applyGlobeDesign(name) { 136 const design = GLOBE_DESIGNS[name] || GLOBE_DESIGNS[DEFAULT_DESIGN]; 137 map.globeImageUrl(design.globe) 138 .atmosphereColor(design.atmosphereColor) 139 .atmosphereAltitude(design.atmosphereAltitude); 140} 141 142function initGlobeSelect() { 143 const sel = document.getElementById('globe'); 144 Object.entries(GLOBE_DESIGNS).forEach(([value, design]) => { 145 const opt = document.createElement('option'); 146 opt.value = value; 147 opt.textContent = design.label; 148 sel.appendChild(opt); 149 }); 150 selectValue(sel, initialParams.get('globe')) || selectValue(sel, DEFAULT_DESIGN); 151} 152 153function initMap() { 154 const container = document.getElementById('map'); 155 map = Globe()(container) 156 .bumpImageUrl(TEXTURE_BASE + 'earth-topology.png') 157 .backgroundImageUrl(TEXTURE_BASE + 'night-sky.png') 158 .arcColor('color') 159 .arcStroke(d => d.base ? 0.22 : 0.5) 160 .arcAltitudeAutoScale(d => d.base ? 0.25 : 0.4) 161 .arcDashLength(d => d.base ? 1 : 0.4) 162 .arcDashGap(d => d.base ? 0 : 0.15) 163 .arcDashAnimateTime(d => d.base ? 0 : 2500) 164 .arcLabel('tooltip') 165 .onArcClick(d => { if (d && !d.base) populatePathDetail(d.pathIdx); }) 166 .pointLat('lat') 167 .pointLng('lng') 168 .pointColor('color') 169 .pointAltitude(0.01) 170 .pointRadius(0.4) 171 .pointLabel('label') 172 .pointOfView({ lat: 30, lng: -30, altitude: 2.5 }); 173 174 applyGlobeDesign(document.getElementById('globe').value); 175 176 // Auto-rotate gently until the user interacts 177 const controls = map.controls(); 178 controls.autoRotate = true; 179 controls.autoRotateSpeed = 0.3; 180 map.onGlobeClick(() => { controls.autoRotate = false; }); 181 182 // Keep canvas sized to its container on viewport changes 183 const resize = () => map.width(container.clientWidth).height(container.clientHeight); 184 window.addEventListener('resize', resize); 185 resize(); 186 187 const panel = document.getElementById('pathDetail'); 188 if (panel) { 189 panel.querySelector('.path-detail-close').addEventListener('click', () => { 190 panel.style.display = 'none'; 191 }); 192 } 193} 194 195function clearMap() { 196 if (map) { 197 map.arcsData([]).pointsData([]); 198 } 199 document.getElementById('legend').innerHTML = ''; 200 201 renderedPaths = []; 202 allArcs = []; 203 allPoints = []; 204 visiblePathIdx = new Set(); 205 currentNodeMap = {}; 206 currentEdgeCost = {}; 207 208 const panel = document.getElementById('pathDetail'); 209 if (panel) panel.style.display = 'none'; 210} 211 212function setStatus(msg, isError) { 213 const el = document.getElementById('status'); 214 el.textContent = msg; 215 el.className = isError ? 'status-bar error' : 'status-bar'; 216} 217 218async function apiFetch(url) { 219 const resp = await fetch(url); 220 const body = await resp.text(); 221 if (!resp.ok) { 222 let msg = body; 223 try { msg = JSON.parse(body).error || body; } catch (e) { /* raw body */ } 224 throw new Error(`API error ${resp.status}: ${msg}`); 225 } 226 return JSON.parse(body); 227} 228 229function escapeHtml(s) { 230 return String(s).replace(/[&<>"']/g, c => ({ 231 '&': '&', 232 '<': '<', 233 '>': '>', 234 '"': '"', 235 "'": ''' 236 })[c]); 237} 238 239// --- Dropdown logic --- 240 241async function loadRouters() { 242 try { 243 setStatus('Loading routers...'); 244 const data = await apiFetch(routersUrl()); 245 const routers = data.routers || []; 246 247 const srcSel = document.getElementById('source'); 248 const prevSrc = srcSel.value; 249 srcSel.innerHTML = '';
250 routers.forEach(r => { 251 const opt = document.createElement('option'); 252 opt.value = r.name; 253 opt.textContent = `${r.name} â ${r.fullName} (${r.region})`; 254 srcSel.appendChild(opt); 255 }); 256 257 const dstSel = document.getElementById('destination'); 258 const prevDst = dstSel.value; 259 dstSel.innerHTML = '<option value="__all__">All destinations</option>'; 260 routers.forEach(r => { 261 const opt = document.createElement('option'); 262 opt.value = r.name; 263 opt.textContent = `${r.name} â ${r.fullName} (${r.region})`; 264 dstSel.appendChild(opt); 265 }); 266 dstSel.disabled = false; 267 268 // Selection priority: the URL (a reloaded or shared view), then 269 // whatever was already selected, then the default â Tokyo makes 270 // the flex-algo detour the most dramatic. 271 selectValue(srcSel, initialParams.get('source')) 272 || selectValue(srcSel, prevSrc) 273 || selectValue(srcSel, 'tk'); 274 selectValue(dstSel, initialParams.get('destination')) 275 || selectValue(dstSel, prevDst); 276 277 setStatus(`${routers.length} routers loaded.`); 278 if (routers.length > 0) { 279 await loadAlgorithms(srcSel.value, initialParams.get('algorithm')); 280 initialParams.delete('source'); 281 initialParams.delete('destination'); 282 initialParams.delete('algorithm'); 283 await loadTopology(); 284 } 285 } catch (e) { 286 setStatus('Failed to load routers: ' + e.message, true); 287 } 288} 289 290async function loadAlgorithms(source, preferred) { 291 const algoSel = document.getElementById('algorithm'); 292 const seq = ++algorithmsSeq; 293 try { 294 setStatus('Loading algorithms...'); 295 const data = await apiFetch(algorithmsUrl(source)); 296 if (seq !== algorithmsSeq) return; 297 const algorithms = data.algorithms || []; 298 // Capture the selection at rebuild time â not at call time â so a 299 // choice the user made while the fetch was in flight survives. 300 const previous = algoSel.value; 301 algoSel.innerHTML = ''; 302 algorithms.forEach(a => { 303 const opt = document.createElement('option'); 304 opt.value = String(a.algo); 305 opt.textContent = a.label; 306 algoSel.appendChild(opt); 307 }); 308 // Selection priority: the caller's preference (the URL at page 309 // load), then the current selection when the new source also 310 // runs it. 311 selectValue(algoSel, preferred) || selectValue(algoSel, previous); 312 algoSel.disabled = false; 313 setStatus(`${algorithms.length} algorithm(s) available.`); 314 } catch (e) { 315 if (seq !== algorithmsSeq) return; 316 algoSel.innerHTML = '<option value="0">0 â shortest path (unconstrained SPF)</option>'; 317 algoSel.disabled = false; 318 setStatus('Failed to load algorithms (showing algorithm 0 only): ' + e.message, true); 319 } 320} 321 322// The destination filter is applied here, client-side, to the cached 323// all-destinations response. Paths are re-indexed after filtering so 324// colors and legend numbering always start from the first visible path. 325function filterPaths(data, destination) { 326 const paths = (data.paths || []).filter(p => 327 destination === '__all__' || p.destination === destination); 328 return paths.map((p, i) => ({ ...p, index: i })); 329} 330 331async function loadTopology(opts) { 332 const force = !!(opts && opts.force); 333 const source = document.getElementById('source').value; 334 const algorithm = document.getElementById('algorithm').value || '0'; 335 336 if (!source) return; 337 338 syncUrl(); 339 340 const key = source + '|' + algorithm; 341 if (!force && topologyCache.key === key && topologyCache.data) { 342 renderFromCache(); 343 return; 344 } 345 346 const seq = ++topologySeq; 347 // Keep the current view (and the user's camera) on screen while the 348 // routers are queried; the map is replaced only when new data lands. 349 setStatus(SNAPSHOT ? 'Loading snapshot data...' : 'Querying ' + source + ' over MCP...'); 350 351 try { 352 const data = await apiFetch(topologyUrl(source, algorithm)); 353 if (seq !== topologySeq) return; 354 topologyCache = { key, data }; 355 renderFromCache(); 356 } catch (e) { 357 if (seq !== topologySeq) return; 358 setStatus('Failed to load topology: ' + e.message, true); 359 } 360} 361 362function renderFromCache() { 363 const data = topologyCache.data; 364 // The destination is read at render time, not fetch time, so a flip 365 // made while a fetch was in flight is what ends up on screen. 366 const destination = document.getElementById('destination').value; 367 renderTopology(data, filterPaths(data, destination), `${data.source}|${destination}`); 368} 369 370// --- Map rendering --- 371 372function nodeLabel(n) { 373 const region = n.region && REGION_COLORS[n.region] ? n.region : (n.region || 'unknown'); 374 return `<b>${escapeHtml(n.fullName || n.name)}</b> (${escapeHtml(n.name)})<br>` + 375 `Region: ${escapeHtml(region)}<br>` + 376 `IS-IS: ${n.active ? 'up' : 'not in topology'}`; 377} 378 379function renderTopology(data, paths, focusKey) { 380 clearMap(); 381 382 currentAlgorithm = data.algorithm || 0; 383 384 if (!data.nodes || data.nodes.length === 0) { 385 setStatus('No topology data returned.'); 386 return; 387 } 388 389 const nodeMap = {};
390 data.nodes.forEach(n => { nodeMap[n.name] = n; }); 391 currentNodeMap = nodeMap; 392 393 const edges = data.edges || []; 394 const edgeCost = {}; 395 edges.forEach(e => { 396 edgeCost[e.source + '|' + e.target] = e.cost; 397 edgeCost[e.target + '|' + e.source] = e.cost; 398 }); 399 currentEdgeCost = edgeCost; 400 401 // Base connectivity arcs (the algorithm's own view of the graph â 402 // links pruned by a flex-algo constraint are genuinely absent here). 403 let plottedEdges = 0; 404 edges.forEach(e => { 405 const a = nodeMap[e.source]; 406 const b = nodeMap[e.target]; 407 if (!a || !b || a.lat == null || b.lat == null) return; 408 plottedEdges++; 409 allArcs.push({ 410 base: true, 411 startLat: a.lat, startLng: a.lng, 412 endLat: b.lat, endLng: b.lng, 413 color: BASE_ARC_COLOR, 414 pathIdx: -1, 415 tooltip: `<b>${escapeHtml(e.source)} ↔ ${escapeHtml(e.target)}</b><br>metric ${e.cost}` 416 }); 417 }); 418 419 // Path arcs, one color per path, with a legend toggle each. 420 const legendEl = document.getElementById('legend'); 421 legendEl.innerHTML = ''; 422 let allPathBtn = null; 423 424 paths.forEach(path => { 425 const idx = path.index; 426 const color = COLORS[idx % COLORS.length]; 427 const hops = (path.hops || []).filter(h => { 428 const n = nodeMap[h]; 429 return n && n.lat != null && n.lng != null; 430 }); 431 if (hops.length < 2) return; 432 433 const title = `${path.hops[0]} â ${path.destination}`; 434 for (let i = 0; i < hops.length - 1; i++) { 435 const a = nodeMap[hops[i]]; 436 const b = nodeMap[hops[i + 1]]; 437 const cost = edgeCost[hops[i] + '|' + hops[i + 1]]; 438 allArcs.push({ 439 base: false, 440 startLat: a.lat, startLng: a.lng, 441 endLat: b.lat, endLng: b.lng, 442 color, 443 pathIdx: idx, 444 tooltip: `<b>${escapeHtml(hops[i])} â ${escapeHtml(hops[i + 1])}</b><br>` + 445 `Path ${idx + 1}: ${escapeHtml(title)}<br>` + 446 `Algorithm ${currentAlgorithm}, path cost ${path.cost}` + 447 (cost != null ? `<br>link metric ${cost}` : '') 448 }); 449 } 450 451 visiblePathIdx.add(idx); 452 453 const btn = document.createElement('button'); 454 btn.className = 'path-toggle active'; 455 btn.innerHTML = `<span class="swatch" style="background:${color}"></span>` + 456 escapeHtml(`${title} #${idx + 1}`); 457 btn.addEventListener('click', () => { 458 if (visiblePathIdx.has(idx)) { 459 visiblePathIdx.delete(idx); 460 btn.classList.remove('active'); 461 } else { 462 visiblePathIdx.add(idx); 463 btn.classList.add('active'); 464 populatePathDetail(idx); 465 } 466 refreshArcs(); 467 if (allPathBtn) { 468 const allVisible = renderedPaths.every(p => visiblePathIdx.has(p.idx)); 469 allPathBtn.classList.toggle('active', allVisible); 470 } 471 }); 472 legendEl.appendChild(btn); 473 474 renderedPaths.push({ idx, color, path, button: btn }); 475 }); 476 477 if (renderedPaths.length > 1) { 478 allPathBtn = document.createElement('button'); 479 allPathBtn.className = 'path-toggle active'; 480 allPathBtn.textContent = 'All paths'; 481 allPathBtn.addEventListener('click', () => { 482 const anyHidden = renderedPaths.some(p => !visiblePathIdx.has(p.idx)); 483 if (anyHidden) { 484 renderedPaths.forEach(p => { 485 visiblePathIdx.add(p.idx); 486 p.button.classList.add('active'); 487 }); 488 allPathBtn.classList.add('active'); 489 } else { 490 renderedPaths.forEach(p => { 491 visiblePathIdx.delete(p.idx); 492 p.button.classList.remove('active'); 493 }); 494 allPathBtn.classList.remove('active'); 495 } 496 refreshArcs(); 497 });
498 legendEl.insertBefore(allPathBtn, legendEl.firstChild); 499 } 500 501 // Node points, colored by region, grey when not in the IS-IS topology. 502 data.nodes.forEach(n => { 503 if (n.lat == null || n.lng == null) return; 504 const color = n.active ? (REGION_COLORS[n.region] || INACTIVE_COLOR) : INACTIVE_COLOR; 505 allPoints.push({ 506 lat: n.lat, 507 lng: n.lng, 508 color, 509 label: nodeLabel(n) 510 }); 511 }); 512 513 map.pointsData(allPoints); 514 refreshArcs(); 515 516 // Re-aim the camera only when the vantage point changed (new source 517 // or destination). An algorithm flip or a refresh keeps the user's 518 // camera where they put it, so the arcs visibly re-route in place. 519 if (allPoints.length > 0 && focusKey !== lastFocusKey) { 520 focusCamera(allPoints); 521 lastFocusKey = focusKey; 522 } 523 524 const unplotted = data.nodes.filter(n => n.lat == null || n.lng == null).map(n => n.name); 525 const extra = unplotted.length > 0 ? ` (${unplotted.length} node(s) without coordinates: ${unplotted.join(', ')})` : ''; 526 const snapNote = SNAPSHOT && SNAPSHOT.generatedAtEpoch 527 ? ` Snapshot of ${new Date(SNAPSHOT.generatedAtEpoch * 1000).toUTCString()}.` 528 : ''; 529 setStatus(`Algorithm ${currentAlgorithm} from ${data.source}: ` + 530 `${paths.length} path(s), ${data.nodes.length} node(s), ${plottedEdges} link(s)${extra}.${snapNote}`); 531} 532 533function refreshArcs() { 534 if (!map) return; 535 map.arcsData(allArcs.filter(a => a.base || visiblePathIdx.has(a.pathIdx))); 536} 537 538function focusCamera(points) { 539 let minLat = 90, maxLat = -90, minLng = 180, maxLng = -180; 540 points.forEach(p => { 541 if (p.lat < minLat) minLat = p.lat; 542 if (p.lat > maxLat) maxLat = p.lat; 543 if (p.lng < minLng) minLng = p.lng; 544 if (p.lng > maxLng) maxLng = p.lng; 545 }); 546 const lat = (minLat + maxLat) / 2; 547 const lng = (minLng + maxLng) / 2; 548 const span = Math.max(maxLat - minLat, maxLng - minLng); 549 // Altitude: small span = close zoom, large span = far view 550 const altitude = Math.max(0.6, Math.min(2.5, span / 25 + 0.6)); 551 map.pointOfView({ lat, lng, altitude }, 800); 552} 553 554// --- Path detail table --- 555 556function populatePathDetail(idx) { 557 const panel = document.getElementById('pathDetail'); 558 if (!panel) return; 559 const entry = renderedPaths.find(p => p.idx === idx); 560 if (!entry) return; 561 const path = entry.path; 562 563 panel.style.display = ''; 564 565 const titleEl = panel.querySelector('.path-detail-title'); 566 if (titleEl) { 567 titleEl.innerHTML = `<span class="swatch" style="background:${entry.color}"></span>` + 568 escapeHtml(`Path ${idx + 1}: ${path.hops[0]} â ${path.destination}`); 569 } 570 571 const subEl = document.getElementById('pathDetailSub'); 572 if (subEl) { 573 const iface = path.interface ? `egress ${path.interface} · ` : ''; 574 subEl.textContent = `algorithm ${currentAlgorithm} · ${iface}total cost ${path.cost} · ${path.hops.length - 1} hop(s)`; 575 } 576 577 const tbody = panel.querySelector('tbody'); 578 tbody.innerHTML = ''; 579 580 let cumulative = 0; 581 path.hops.forEach((name, i) => { 582 const node = currentNodeMap[name]; 583 const fullName = node ? node.fullName : name; 584 const region = node ? node.region : 'â'; 585 586 let link = 'â'; 587 if (i > 0) { 588 const cost = currentEdgeCost[path.hops[i - 1] + '|' + name]; 589 if (cost != null) { 590 cumulative += cost; 591 link = String(cost); 592 } else { 593 link = '?'; 594 } 595 } 596 597 const tr = document.createElement('tr'); 598 tr.innerHTML = ` 599 <td class="metric">${i}</td> 600 <td>${escapeHtml(name)} â ${escapeHtml(fullName)}</td> 601 <td>${escapeHtml(region)}</td> 602 <td class="metric">${escapeHtml(link)}</td> 603 <td class="metric">${i === 0 ? '0' : String(cumulative)}</td> 604 `; 605 tbody.appendChild(tr); 606 }); 607} 608 609// --- Event listeners --- 610 611document.addEventListener('DOMContentLoaded', () => { 612 initGlobeSelect(); 613 initMap(); 614 615 // A snapshot cannot re-query the routers â hide Refresh and let the 616 // status bar carry the snapshot timestamp instead. 617 if (SNAPSHOT) { 618 const refresh = document.getElementById('refresh'); 619 (refresh.closest('label') || refresh).style.display = 'none'; 620 } 621 622 loadRouters(); 623 624 // A globe-design flip only restyles the earth â no router re-query. 625 document.getElementById('globe').addEventListener('change', (e) => { 626 applyGlobeDesign(e.target.value); 627 syncUrl(); 628 }); 629 630 document.getElementById('source').addEventListener('change', async (e) => { 631 if (e.target.value) { 632 await loadAlgorithms(e.target.value); 633 loadTopology(); 634 } 635 }); 636 637 document.getElementById('destination').addEventListener('change', () => loadTopology()); 638 document.getElementById('algorithm').addEventListener('change', () => loadTopology()); 639 document.getElementById('refresh').addEventListener('click', () => loadTopology({ force: true })); 640});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.