PageSourceSearch

https://zebra.rs/topology/static/app.js

js zebra.rs collected 2026-10-03 18:56:41 UTC 23,264 bytes, 640 lines download raw bytes

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        '&': '&amp;',
232        '<': '&lt;',
233        '>': '&gt;',
234        '"': '&quot;',
235        "'": '&#39;'
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)} &harr; ${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.