diff --git a/README.md b/README.md index 6ff052e..1270151 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,10 @@ infragraph visualize -i my_infrastructure.yaml -o ./viz --hosts "dgx_a100" --swi ``` The visualizer produces a multi-level view: a top-level graph of instances and inter-device connectivity, with drill-down into each device's internal components (xPUs, NICs, CPUs, memory, PCIe topology, etc.). +For large topologies the visualizer can compress the infrastructure view. A **Compressed** button appears in the header for fabrics with more than 128 hosts. It merges nodes that have identical connectivity into a single node, and a slider next to it chooses how aggressively, from merging equivalent neighbours up to one node per tier. Groups are named from the data: a bottom-tier group whose members all uplink to one switch is shown as a rack, and several such racks merged is shown as a pod. + +Clicking a rack or pod opens it rather than jumping straight into a device template. You see its actual member servers together with the switches they uplink to, and a hop slider controls how much surrounding fabric comes with them. Clicking a server there drills into that device's internals as usual, so the trail reads Infrastructure, then rack, then device. Switch groups behave differently and go straight to the device template, because every switch in a group is an instance of the same device. At the highest compression rungs a single node can stand for every server in the fabric, and those are left closed since expanding one would build thousands of nodes; lower the compression to reach a group you can open. Passing `--hosts` helps the visualizer identify the bottom tier. + > **Note:** More converters and tools are _work-in-progress_. See the [Ecosystem documentation](docs/src/ecosystem.md) for the full roadmap and we invite contributions from the community. diff --git a/docs/src/cli.md b/docs/src/cli.md index c69bc16..d0255a8 100644 --- a/docs/src/cli.md +++ b/docs/src/cli.md @@ -72,3 +72,7 @@ infragraph visualize -i my_infrastructure.yaml -o ./viz --hosts "dgx_a100" --swi ``` Then open `./viz/index.html` in a browser. The visualizer produces a multi-level view: a top-level graph of instances and inter-device connectivity, with drill-down into each device's internal components (xPUs, NICs, CPUs, memory, PCIe topology, etc.). +For large topologies the visualizer can compress the infrastructure view. A **Compressed** button appears in the header for fabrics with more than 128 hosts. It merges nodes that have identical connectivity into a single node, and a slider next to it chooses how aggressively, from merging equivalent neighbours up to one node per tier. Groups are named from the data: a bottom-tier group whose members all uplink to one switch is shown as a rack, and several such racks merged is shown as a pod. + +Clicking a rack or pod opens it rather than jumping straight into a device template. You see its actual member servers together with the switches they uplink to, and a hop slider controls how much surrounding fabric comes with them. Clicking a server there drills into that device's internals as usual, so the trail reads Infrastructure, then rack, then device. Switch groups behave differently and go straight to the device template, because every switch in a group is an instance of the same device. At the highest compression rungs a single node can stand for every server in the fabric, and those are left closed since expanding one would build thousands of nodes; lower the compression to reach a group you can open. Passing `--hosts` helps the visualizer identify the bottom tier. + diff --git a/src/infragraph/visualizer/frontend/css/style.css b/src/infragraph/visualizer/frontend/css/style.css index 112ec4c..b621b0e 100644 --- a/src/infragraph/visualizer/frontend/css/style.css +++ b/src/infragraph/visualizer/frontend/css/style.css @@ -186,6 +186,92 @@ html, body { cursor: default; } +#toast { + position: fixed; + left: 50%; + bottom: 28px; + transform: translate(-50%, 12px); + z-index: 200; + max-width: 560px; + padding: 9px 16px; + border-radius: 18px; + background: rgba(20, 24, 31, 0.94); + color: #f0f3f6; + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.4; + text-align: center; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); + opacity: 0; + pointer-events: none; + transition: opacity 0.18s ease, transform 0.18s ease; +} + +#toast.show { + opacity: 1; + transform: translate(-50%, 0); +} + +#rackControls { + display: flex; + align-items: center; + gap: 8px; + padding: 0 6px; + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary); + white-space: nowrap; +} + +#rackHopsLabel { + min-width: 46px; +} + +#compressControls { + display: flex; + align-items: center; + gap: 8px; + padding: 0 6px; + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary); + white-space: nowrap; +} + +#compressControls input[type="range"], +#rackControls input[type="range"] { + width: 110px; + height: 4px; + -webkit-appearance: none; + appearance: none; + background: var(--border); + border-radius: 2px; + outline: none; +} + +#compressControls input[type="range"]::-webkit-slider-thumb, +#rackControls input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 13px; + height: 13px; + border-radius: 50%; + background: var(--accent-orange); + cursor: pointer; + border: 2px solid var(--bg-primary); +} + +#compressRatio { + min-width: 120px; +} + +#btn-compress.active { + background: var(--accent-hover); + color: #ffffff; + border-color: var(--accent-blue); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3) inset; +} + /* Breadcrumb */ #breadcrumb { display: flex; @@ -728,4 +814,8 @@ div.vis-tooltip { .controller-items input[type="range"]::-webkit-slider-thumb:hover { background: var(--accent-hover); transform: scale(1.15); -} \ No newline at end of file +} +.controller-items label.checkbox-row input[type="checkbox"] { + flex: 0 0 auto; + accent-color: var(--accent-orange); +} diff --git a/src/infragraph/visualizer/frontend/index.html b/src/infragraph/visualizer/frontend/index.html index 6bf719d..7d426c7 100644 --- a/src/infragraph/visualizer/frontend/index.html +++ b/src/infragraph/visualizer/frontend/index.html @@ -27,6 +27,23 @@
+ + +
+ +
+ diff --git a/src/infragraph/visualizer/frontend/js/app.js b/src/infragraph/visualizer/frontend/js/app.js index deb5759..83e0402 100644 --- a/src/infragraph/visualizer/frontend/js/app.js +++ b/src/infragraph/visualizer/frontend/js/app.js @@ -20,13 +20,18 @@ function initApp() { document.getElementById('btn-theme').addEventListener('click', function () { document.documentElement.classList.toggle('dark'); // Re-render graph with updated font colors - if (currentData && currentData._rawData) { - var isInfra = navigationStack.length === 1; - currentData = prepareData(currentData._rawData); - render(currentData, isInfra ? fabricOptions : internalOptions); - } + if (currentData && currentData._rawData) rerenderCurrent(); }); + // Large-graph mode override (auto by default) + var largeToggle = document.getElementById('largeGraphToggle'); + if (largeToggle) { + largeToggle.addEventListener('change', function () { + largeGraphOverride = this.checked; + if (currentData && currentData._rawData) rerenderCurrent(); + }); + } + initNavigation(); navigateTo('infrastructure.json', 'Infrastructure'); @@ -39,6 +44,41 @@ function initApp() { }); } +// A rack or pod that is too large to expand is not clickable, so say so with the +// cursor rather than letting the click quietly do nothing. +function cursorFor(node) { + if (typeof isRackGroup === 'function' && isRackGroup(node)) { + return canOpenRack(node) ? 'pointer' : 'not-allowed'; + } + return node.drillable ? 'pointer' : 'default'; +} + +// Brief message at the bottom of the canvas. Used for actions that are refused +// or silently adjusted, which would otherwise look like nothing happened. +var toastTimer = null; +function showToast(message) { + var el = document.getElementById('toast'); + if (!el) return; + el.textContent = message; + el.classList.add('show'); + clearTimeout(toastTimer); + toastTimer = setTimeout(function () { el.classList.remove('show'); }, 3600); +} + +// Re-prepares the current view from its raw data (theme or render-mode change) +// and redraws it with the full dataset. +function rerenderCurrent() { + currentData = prepareData(currentData._rawData); + render(currentData, optionsForData(currentData)); + if (typeof populateFilters === 'function') populateFilters(currentData); + syncLargeGraphToggle(); +} + +function syncLargeGraphToggle() { + var cb = document.getElementById('largeGraphToggle'); + if (cb && currentData) cb.checked = !!currentData.large; +} + // Render: creates vis.js network, binds click/hover events function render(data, options) { if (net) { net.destroy(); net = null; } @@ -46,16 +86,26 @@ function render(data, options) { var container = document.getElementById('graph-container') || document.getElementById('mynetwork'); if (!container) return; + var nodeById = data.byId || new Map(data.nodes.map(function (n) { return [n.id, n]; })); + net = new vis.Network(container, { nodes: new vis.DataSet(data.nodes), edges: new vis.DataSet(data.edges) }, options); - net.once('stabilizationIterationsDone', function () { - net.setOptions({ physics: { enabled: false } }); - unpinNodes(net); - net.fit({ animation: { duration: 400, easingFunction: 'easeInOutQuad' } }); - }); + if (options.physics && options.physics.enabled === false) { + // Nothing to stabilize. A hierarchical layout still pins every node on + // the level axis, so release them or they cannot be dragged freely. + var h = options.layout && options.layout.hierarchical; + if (h && h.enabled) unpinNodes(net); + net.fit({ animation: false }); + } else { + net.once('stabilizationIterationsDone', function () { + net.setOptions({ physics: { enabled: false } }); + unpinNodes(net); + net.fit({ animation: { duration: 400, easingFunction: 'easeInOutQuad' } }); + }); + } var clickTimer = null; @@ -70,8 +120,11 @@ function render(data, options) { if (params.nodes.length) { clickTimer = setTimeout(function () { var nodeId = params.nodes[0]; - var nodeData = data.nodes.find(function (n) { return n.id === nodeId; }); - if (nodeData && nodeData.drillable && nodeData.drillTarget) { + var nodeData = nodeById.get(nodeId); + if (!nodeData) return; + if (typeof isRackGroup === 'function' && isRackGroup(nodeData)) { + openRack(nodeData); // rack or pod: show its servers in context + } else if (nodeData.drillable && nodeData.drillTarget) { navigateTo(nodeData.drillTarget, nodeData.label); } }, 300); @@ -79,9 +132,9 @@ function render(data, options) { }); net.on('hoverNode', function (params) { - var nodeData = data.nodes.find(function (n) { return n.id === params.node; }); + var nodeData = nodeById.get(params.node); if (nodeData) { - container.style.cursor = nodeData.drillable ? 'pointer' : 'default'; + container.style.cursor = cursorFor(nodeData); } }); diff --git a/src/infragraph/visualizer/frontend/js/controller.js b/src/infragraph/visualizer/frontend/js/controller.js index 446a3f6..c6dc5ec 100644 --- a/src/infragraph/visualizer/frontend/js/controller.js +++ b/src/infragraph/visualizer/frontend/js/controller.js @@ -6,29 +6,62 @@ function toggleControllerPanel() { document.getElementById('controlToggle').classList.toggle('active', controlleropen); } -document.getElementById('fontslider').addEventListener('input', function () { +// Slider "input" fires on every pixel of movement; each handler below rewrites +// every node or edge, so coalesce bursts into one update. +function debounce(fn, wait) { + var timer = null; + return function () { + var ctx = this, args = arguments; + clearTimeout(timer); + timer = setTimeout(function () { fn.apply(ctx, args); }, wait); + }; +} +var SLIDER_DEBOUNCE_MS = 120; + +document.getElementById('fontslider').addEventListener('input', debounce(function () { + if (!net) return; var size = parseInt(this.value); var updates = net.body.data.nodes.get().map(function (n) { return { id: n.id, font: { size: size } }; }); net.body.data.nodes.update(updates); -}); +}, SLIDER_DEBOUNCE_MS)); -document.getElementById('edgefont').addEventListener('input', function () { +document.getElementById('edgefont').addEventListener('input', debounce(function () { + if (!net) return; var size = parseInt(this.value); var updates = net.body.data.edges.get().map(function (e) { return { id: e.id, font: { size: size } }; }); net.body.data.edges.update(updates); -}); +}, SLIDER_DEBOUNCE_MS)); -document.getElementById('nodeslider').addEventListener('input', function () { +document.getElementById('nodeslider').addEventListener('input', debounce(function () { + if (!net) return; var size = parseInt(this.value); var updates = net.body.data.nodes.get().map(function (n) { return { id: n.id, size: size }; }); net.body.data.nodes.update(updates); -}); +}, SLIDER_DEBOUNCE_MS)); + +// Views with generator-supplied positions have no layout engine to re-run: +// the spacing sliders just scale the stored base positions, which is instant. +function isPrecomputedView() { + return net && typeof currentData !== 'undefined' && currentData && hasPrecomputedLayout(currentData); +} + +function rescalePrecomputed() { + var sx = parseInt(document.getElementById('spaceslider').value) / SPACE_SLIDER_DEFAULT; + var sy = parseInt(document.getElementById('levelslider').value) / LEVEL_SLIDER_DEFAULT; + var updates = net.body.data.nodes.get().map(function (n) { + return { id: n.id, x: n.baseX * sx, y: n.baseY * sy }; + }); + net.body.data.nodes.update(updates); + net.fit({ animation: false }); +} +var SPACE_SLIDER_DEFAULT = parseInt(document.getElementById('spaceslider').value); +var LEVEL_SLIDER_DEFAULT = parseInt(document.getElementById('levelslider').value); // Re-runs the hierarchical layout with new spacing, then hands the graph back // to the user: physics off, and nodes unpinned so they stay draggable on both @@ -56,12 +89,20 @@ function respaceLayout(hierarchical, repulsion) { }); } -document.getElementById('spaceslider').addEventListener('input', function () { - var spacing = parseInt(this.value); - respaceLayout({}, { nodeDistance: spacing }); +// Re-running the vis layout is expensive, so for hierarchical views it happens +// on release ("change") only; precomputed views rescale live while dragging. +var rescaleLive = debounce(function () { if (isPrecomputedView()) rescalePrecomputed(); }, 40); + +document.getElementById('spaceslider').addEventListener('input', rescaleLive); +document.getElementById('levelslider').addEventListener('input', rescaleLive); + +document.getElementById('spaceslider').addEventListener('change', function () { + if (!net || isPrecomputedView()) return; + var v = parseInt(this.value); + respaceLayout({ nodeSpacing: v }, { nodeDistance: v }); }); -document.getElementById('levelslider').addEventListener('input', function () { - var spacing = parseInt(this.value); - respaceLayout({ levelSeparation: spacing }, {}); +document.getElementById('levelslider').addEventListener('change', function () { + if (!net || isPrecomputedView()) return; + respaceLayout({ levelSeparation: parseInt(this.value) }, {}); }); \ No newline at end of file diff --git a/src/infragraph/visualizer/frontend/js/data.js b/src/infragraph/visualizer/frontend/js/data.js index 90964b1..e86ef96 100644 --- a/src/infragraph/visualizer/frontend/js/data.js +++ b/src/infragraph/visualizer/frontend/js/data.js @@ -1,12 +1,29 @@ // Data fetching and preparation for vis.js function fetchGraphData(file) { + // Rack views are synthesised from infrastructure.json on demand rather than + // shipped, so there is one entry per rack without growing graph_data.js. + if (typeof isRackFile === 'function' && isRackFile(file)) { + var view = buildRackView(file, rackHops); + return view ? Promise.resolve(view) + : Promise.reject(new Error('Rack view unavailable: ' + file)); + } if (typeof GRAPH_DATA !== 'undefined' && GRAPH_DATA[file]) { return Promise.resolve(GRAPH_DATA[file]); } return Promise.reject(new Error('Graph data not found: ' + file)); } +// Large graphs get a simplified rendering: canvas shadows, stroked text and +// bezier edges are the slowest primitives and are redrawn on every pan/zoom. +// null = decide from size, true/false = user override from the controls panel. +var largeGraphOverride = null; + +function isLargeGraph(rawData) { + if (largeGraphOverride !== null) return largeGraphOverride; + return rawData.nodes.length >= LARGE_GRAPH_NODES || rawData.edges.length >= LARGE_GRAPH_EDGES; +} + // Transforms raw JSON into vis.js-ready objects // Adds deviceType/linkType fields that filters.js expects @@ -14,9 +31,10 @@ function prepareData(rawData) { var isDark = document.documentElement.classList.contains('dark'); var fontColor = isDark ? '#e6edf3' : '#1f2328'; var strokeColor = isDark ? '#0e1621' : '#ffffff'; + var large = isLargeGraph(rawData); var nodes = rawData.nodes.map(function (n) { - return { + var node = { id: n.id, label: n.label, title: n.title, @@ -27,24 +45,58 @@ function prepareData(rawData) { font: { color: fontColor, size: 12, face: "'JetBrains Mono', 'Fira Code', monospace", - strokeWidth: 3, strokeColor: strokeColor + strokeWidth: large ? 0 : 3, strokeColor: strokeColor }, borderWidth: n.drillable ? 2.5 : 1.5, - shadow: n.drillable ? { enabled: true, color: 'rgba(74,144,217,0.5)', size: 12, x: 0, y: 0 } : undefined, + shadow: (n.drillable && !large) ? { enabled: true, color: 'rgba(74,144,217,0.5)', size: 12, x: 0, y: 0 } : undefined, deviceType: n.type || 'unknown', drillable: n.drillable, drillTarget: n.drillTarget, device: n.device }; + // Group nodes of the compressed view: dashed accent border, member list + // kept for search/tooltips. + if (n.members) { + node.members = n.members; + node.kind = n.kind || 'group'; + node.rackCount = n.rackCount || 0; + if (typeof canOpenRack === 'function' && isRackGroup(node) && !canOpenRack(node)) { + node.title += '\n\nToo large to open. Lower the compression to reach a smaller group.'; + } + node.borderWidth = 3; + node.shapeProperties = { useBorderWithImage: true, borderDashes: [6, 4] }; + node.color = Object.assign({}, node.color, { + border: '#4a90d9', + highlight: Object.assign({}, node.color.highlight, { border: '#e67e22' }), + hover: Object.assign({}, node.color.hover, { border: '#6aa8e8' }) + }); + } + // Rack view context: the switches a rack uplinks to are shown faded so + // the rack's own members read as the subject of the view. + if (typeof n.inRack === 'boolean') node.inRack = n.inRack; + if (n.inRack === false) { + node.opacity = 0.5; + node.font = Object.assign({}, node.font, { color: isDark ? '#8b949e' : '#8c959f' }); + } + // Positions precomputed by the generator (infrastructure view). The + // originals are kept so the spacing sliders can rescale them cheaply. + if (typeof n.x === 'number' && typeof n.y === 'number') { + node.x = n.x; node.y = n.y; node.level = n.level; + node.baseX = n.x; node.baseY = n.y; + } + return node; }); var edges = rawData.edges.map(function (e, idx) { + var title = e.title; + // Edge labels are dropped in large mode; keep the ×N info in the tooltip. + if (large && e.label && e.label !== e.link) title = e.label + '\n' + title; return { id: 'e_' + idx, from: e.from, to: e.to, - label: e.label || undefined, - title: e.title, + label: large ? undefined : (e.label || undefined), + title: title, color: { color: e.color || '#555', highlight: isDark ? '#e6edf3' : '#1f2328', @@ -57,12 +109,18 @@ function prepareData(rawData) { face: "'JetBrains Mono', monospace", strokeWidth: 2, strokeColor: strokeColor, align: 'middle' }, - smooth: { enabled: true, type: 'continuous', roundness: 0.15 }, - linkType: e.link || 'unknown' + smooth: large ? { enabled: false } : { enabled: true, type: 'continuous', roundness: 0.15 }, + linkType: e.link || 'unknown', + count: e.count || 1 }; }); - + var byId = new Map(); + nodes.forEach(function (n) { byId.set(n.id, n); }); + // Store raw data for re-rendering on theme change - return { nodes: nodes, edges: edges, _rawData: rawData }; + return { + nodes: nodes, edges: edges, byId: byId, large: large, + rackView: !!rawData.rackView, _rawData: rawData + }; } \ No newline at end of file diff --git a/src/infragraph/visualizer/frontend/js/filters.js b/src/infragraph/visualizer/frontend/js/filters.js index 712a3c6..063a512 100644 --- a/src/infragraph/visualizer/frontend/js/filters.js +++ b/src/infragraph/visualizer/frontend/js/filters.js @@ -63,12 +63,14 @@ function applyFilters() { return visIds.has(e.from) && visIds.has(e.to) && selLinks.has(e.linkType || e.title || 'unknown'); }); - render({ nodes: fNodes, edges: fEdges }, navigationStack.length === 0 ? fabricOptions : internalOptions); + var filtered = { nodes: fNodes, edges: fEdges, large: currentData.large, + rackView: currentData.rackView }; + render(filtered, optionsForData(filtered)); } // Resets all filters and re-renders the full current dataset function resetFilters() { if (!currentData) return; populateFilters(currentData); - render(currentData, navigationStack.length === 0 ? fabricOptions : internalOptions); // re-render with full dataset after resetting filters + render(currentData, optionsForData(currentData)); // re-render with full dataset after resetting filters } \ No newline at end of file diff --git a/src/infragraph/visualizer/frontend/js/navigation.js b/src/infragraph/visualizer/frontend/js/navigation.js index 3a15dd5..182ba25 100644 --- a/src/infragraph/visualizer/frontend/js/navigation.js +++ b/src/infragraph/visualizer/frontend/js/navigation.js @@ -1,12 +1,343 @@ // Navigation: breadcrumb trail, back button, drill-down var navigationStack = []; // [{file, label}] -var breadcrumb, btnBack; +var breadcrumb, btnBack, btnCompress; + +// Compressed views: for fabrics with more than Visualizer.COMPRESS_MIN_HOSTS +// hosts the generator emits a ladder infrastructure_compressed_1.json .. +// infrastructure_compressed_.json, each merging more equivalent nodes +// than the last (the final rung is one node per tier). The header toggle +// switches the infrastructure view to the ladder and the slider picks the +// rung; the breadcrumb is unchanged. +var INFRA_FILE = 'infrastructure.json'; +var compressedMode = false; +var compressionLevel = null; // current rung, initialised from the generator's default + +function compressedFile(level) { + return 'infrastructure_compressed_' + level + '.json'; +} + +// Available rungs, ascending. +function compressionLevels() { + var levels = []; + if (typeof GRAPH_DATA === 'undefined') return levels; + for (var k = 1; GRAPH_DATA[compressedFile(k)]; k++) levels.push(k); + return levels; +} + +function compressedAvailable() { + return compressionLevels().length > 0; +} + +function defaultCompressionLevel() { + var levels = compressionLevels(); + for (var i = 0; i < levels.length; i++) { + if (GRAPH_DATA[compressedFile(levels[i])].default) return levels[i]; + } + return levels.length ? levels[0] : null; +} + +// Data file to load for a logical view: the infrastructure view resolves to +// the selected compressed rung while the toggle is on. +function resolveViewFile(file) { + if (file === INFRA_FILE && compressedMode && compressedAvailable()) { + if (compressionLevel === null) compressionLevel = defaultCompressionLevel(); + return compressedFile(compressionLevel); + } + return file; +} + +function reloadInfrastructure() { + if (navigationStack.length === 1) { + navigationStack.pop(); + navigateTo(INFRA_FILE, 'Infrastructure'); + } else { + updateCompressButton(); + } +} + +function toggleCompressed() { + if (!compressedAvailable()) return; + compressedMode = !compressedMode; + reloadInfrastructure(); +} + +function setCompressionLevel(level) { + var levels = compressionLevels(); + if (levels.indexOf(level) === -1) return; + compressionLevel = level; + if (compressedMode) reloadInfrastructure(); + else updateCompressButton(); +} + +// Text for a rung: "×5.2 · 160 nodes" +function compressionRatioText(level) { + var view = GRAPH_DATA[compressedFile(level)]; + if (!view) return ''; + var ratio = view.ratio || (GRAPH_DATA[INFRA_FILE].nodes.length / view.nodes.length); + return '\u00d7' + Number(ratio).toFixed(1) + ' \u00b7 ' + view.nodes.length + ' nodes'; +} + +function updateCompressButton() { + if (!btnCompress) return; + var onInfra = navigationStack.length === 1; + var available = compressedAvailable(); + btnCompress.disabled = !(available && onInfra); + btnCompress.classList.toggle('active', compressedMode && available); + + var rack = document.getElementById('rackControls'); + if (rack) { + var cur = navigationStack[navigationStack.length - 1]; + var inRack = !!(cur && isRackFile(cur.file)); + rack.style.display = inRack ? 'flex' : 'none'; + if (inRack) { + document.getElementById('rackHops').value = rackHops; + document.getElementById('rackHopsLabel').textContent = + rackHops + (rackHops === 1 ? ' hop' : ' hops'); + } + } + + var controls = document.getElementById('compressControls'); + if (!controls) return; + var show = compressedMode && available && onInfra; + controls.style.display = show ? 'flex' : 'none'; + if (show) { + var levels = compressionLevels(); + if (compressionLevel === null) compressionLevel = defaultCompressionLevel(); + var hopSlider = document.getElementById('rackHops'); + if (hopSlider) { + hopSlider.addEventListener('input', function () { + document.getElementById('rackHopsLabel').textContent = this.value + (this.value === '1' ? ' hop' : ' hops'); + }); + hopSlider.addEventListener('change', function () { setRackHops(parseInt(this.value)); }); + } + + var slider = document.getElementById('compressSlider'); + slider.min = levels[0]; + slider.max = levels[levels.length - 1]; + slider.value = compressionLevel; + document.getElementById('compressRatio').textContent = compressionRatioText(compressionLevel); + } +} + +// --------------------------------------------------------------------------- +// Rack views +// +// A group node in a compressed view stands for several real instances. Opening +// one used to jump straight into the shared device template, skipping the layer +// that answers "what is in this box and where does it plug in". A rack view is +// that missing layer: the group's actual members plus the switches they uplink +// to, taken from infrastructure.json and laid out fresh. Clicking a server +// inside it then drills into that device's internals as before. +// +// Views are built on demand and keyed by a synthetic file id so the breadcrumb, +// back button and the rest of navigateTo work unchanged. + +var RACK_PREFIX = 'rack:'; +var RACK_MAX_NODES = 250; // above this a rack view is too slow to be worth opening +var rackHops = 1; // how many hops of surrounding fabric to include +var rackViews = {}; // synthetic file id -> { members, label, kind } +var rackBaseIndex = null; // lazily built adjacency over infrastructure.json + +function isRackFile(file) { + return typeof file === 'string' && file.indexOf(RACK_PREFIX) === 0; +} + +// Only bottom-tier groups open as racks. Switch groups keep the old behaviour of +// drilling into the shared device template, because every switch in a group is +// an instance of the same device and seeing 512 identical copies tells you +// nothing that the template does not. +function isRackGroup(node) { + return !!(node && node.members && (node.kind === 'rack' || node.kind === 'pod')); +} + +// Size of the one-hop view this group would produce: its members plus the +// switches they uplink to, both counted by the generator. +function rackViewSize(node) { + return (node.members ? node.members.length : 0) + (node.rackCount || 0); +} + +// At the highest compression rungs a single node stands for every server in the +// fabric, and expanding it would build thousands of nodes and take a minute or +// more to lay out. Those are left closed. +function canOpenRack(node) { + return isRackGroup(node) && rackViewSize(node) <= RACK_MAX_NODES; +} + +function rackTitle(node) { + if (node.kind === 'rack') return 'Rack ' + node.label; + if (node.kind === 'pod') return 'Pod ' + node.label; + return node.label; +} + +// Called when a group node is clicked. Registers the view, then navigates. +function openRack(node) { + if (!isRackGroup(node) || !node.members.length) return; + if (!canOpenRack(node)) { + if (typeof showToast === 'function') { + showToast(node.label + ' holds ' + node.members.length + + ' servers, too many to open. Lower the compression to open a smaller group.'); + } + return; + } + var file = RACK_PREFIX + node.id; + rackViews[file] = { members: node.members.slice(), label: node.label, kind: node.kind }; + navigateTo(file, rackTitle(node)); +} + +function rackIndex() { + if (rackBaseIndex) return rackBaseIndex; + var base = GRAPH_DATA[INFRA_FILE]; + var adj = new Map(); + base.edges.forEach(function (e) { + if (!adj.has(e.from)) adj.set(e.from, []); + if (!adj.has(e.to)) adj.set(e.to, []); + adj.get(e.from).push(e.to); + adj.get(e.to).push(e.from); + }); + rackBaseIndex = { base: base, adj: adj }; + return rackBaseIndex; +} + +// Members plus everything within `hops` hops of them, as a standalone view. +// Precomputed coordinates are stripped so vis lays the small graph out itself; +// the fabric-wide positions would scatter five servers across half a mile. +function buildRackView(file, hops) { + var spec = rackViews[file]; + if (!spec) return null; + var idx = rackIndex(), adj = idx.adj; + + var keep = new Set(spec.members); + var frontier = spec.members.slice(); + var reachedHops = 0; + for (var h = 0; h < hops; h++) { + var next = new Set(); + frontier.forEach(function (id) { + (adj.get(id) || []).forEach(function (m) { if (!keep.has(m)) next.add(m); }); + }); + if (!next.size) break; + // Adding this ring would make the view too slow, so stop one ring short. + if (keep.size + next.size > RACK_MAX_NODES) break; + next.forEach(function (m) { keep.add(m); }); + frontier = Array.from(next); + reachedHops = h + 1; + } + + var inRack = new Set(spec.members); + var nodes = idx.base.nodes.filter(function (n) { return keep.has(n.id); }) + .map(function (n) { + var copy = {}; + for (var k in n) copy[k] = n[k]; + copy.inRack = inRack.has(n.id); + return copy; + }); + var edges = idx.base.edges.filter(function (e) { return keep.has(e.from) && keep.has(e.to); }); + + // The fabric-wide coordinates would scatter these across half a mile, so + // give the subgraph its own placement at a scale that suits it. + layoutSubgraph(nodes, edges, 170); + + return { + nodes: nodes, edges: edges, rackView: true, + rackMemberCount: spec.members.length, + rackHopsShown: reachedHops, rackHopsAsked: hops + }; +} + +// Client-side twin of Visualizer._compute_layout in visualize.py. Rack views +// are assembled in the browser, so they need the same placement the generator +// gives the fabric: rows taken from each node's level, ordered by the mean +// position of their neighbours one level down, pushed apart to respect the +// spacing, then re-centred. vis's own hierarchical engine was tried here first +// and stretched larger views by more than an order of magnitude. +function layoutSubgraph(nodes, edges, spacing) { + spacing = spacing || 170; + var adj = new Map(), order = new Map(); + nodes.forEach(function (n, i) { adj.set(n.id, []); order.set(n.id, i); }); + edges.forEach(function (e) { + if (adj.has(e.from) && adj.has(e.to)) { + adj.get(e.from).push(e.to); + adj.get(e.to).push(e.from); + } + }); + + var levelOf = new Map(), byLevel = new Map(); + var minLevel = Infinity; + nodes.forEach(function (n) { if (n.level < minLevel) minLevel = n.level; }); + nodes.forEach(function (n) { + var lv = (n.level || 0) - minLevel; + levelOf.set(n.id, lv); + if (!byLevel.has(lv)) byLevel.set(lv, []); + byLevel.get(lv).push(n); + }); + + var levels = Array.from(byLevel.keys()).sort(function (a, b) { return a - b; }); + var x = new Map(); + levels.forEach(function (lv, li) { + var row = byLevel.get(lv), bary = new Map(); + if (li === 0) { + row.sort(function (a, b) { return order.get(a.id) - order.get(b.id); }); + row.forEach(function (n, i) { bary.set(n.id, (i - (row.length - 1) / 2) * spacing); }); + } else { + row.forEach(function (n) { + var below = adj.get(n.id) + .filter(function (m) { return x.has(m); }) + .map(function (m) { return x.get(m); }); + var mean = 0; + below.forEach(function (v) { mean += v; }); + bary.set(n.id, below.length ? mean / below.length : 0); + }); + row.sort(function (a, b) { + return (bary.get(a.id) - bary.get(b.id)) || (order.get(a.id) - order.get(b.id)); + }); + } + var pos = []; + row.forEach(function (n) { + var px = bary.get(n.id); + if (pos.length && px < pos[pos.length - 1] + spacing) px = pos[pos.length - 1] + spacing; + pos.push(px); + }); + var sumB = 0, sumP = 0; + row.forEach(function (n) { sumB += bary.get(n.id); }); + pos.forEach(function (p) { sumP += p; }); + var shift = (sumB - sumP) / row.length; + row.forEach(function (n, i) { x.set(n.id, pos[i] + shift); }); + }); + + var widest = 0; + byLevel.forEach(function (row) { widest = Math.max(widest, row.length); }); + var gaps = Math.max(levels.length - 1, 1); + var sep = Math.round(Math.min(Math.max((widest - 1) * spacing / (2 * gaps), 220), 4000)); + nodes.forEach(function (n) { + n.x = Math.round(x.get(n.id)); + n.y = -levelOf.get(n.id) * sep; + }); +} + +function setRackHops(hops) { + rackHops = hops; + var cur = navigationStack[navigationStack.length - 1]; + if (cur && isRackFile(cur.file)) { // rebuild the view we are looking at + navigationStack.pop(); + navigateTo(cur.file, cur.label); + } +} function initNavigation() { breadcrumb = document.getElementById('breadcrumb'); btnBack = document.getElementById('btn-back'); btnBack.addEventListener('click', goBack); + btnCompress = document.getElementById('btn-compress'); + if (btnCompress) btnCompress.addEventListener('click', toggleCompressed); + + var slider = document.getElementById('compressSlider'); + if (slider) { + // live ratio preview while dragging, reload on release + slider.addEventListener('input', function () { + document.getElementById('compressRatio').textContent = compressionRatioText(parseInt(this.value)); + }); + slider.addEventListener('change', function () { setCompressionLevel(parseInt(this.value)); }); + } document.addEventListener('keydown', function (e) { if (e.key === 'Backspace' && !e.target.matches('input, textarea')) { @@ -19,18 +350,23 @@ function initNavigation() { function navigateTo(file, label) { showLoading(); - fetchGraphData(file).then(function (data) { + fetchGraphData(resolveViewFile(file)).then(function (data) { navigationStack.push({ file: file, label: label }); updateBreadcrumb(); updateBackButton(); + updateCompressButton(); - var isInfra = navigationStack.length === 1; - var options = isInfra ? fabricOptions : internalOptions; - + largeGraphOverride = null; // each view decides its own render mode currentData = prepareData(data); - render(currentData, options); + render(currentData, optionsForData(currentData)); + + if (data.rackView && data.rackHopsShown < data.rackHopsAsked && typeof showToast === 'function') { + showToast('Showing ' + data.rackHopsShown + ' hop' + (data.rackHopsShown === 1 ? '' : 's') + + '; going further would make this view too large to draw quickly.'); + } if (typeof populateFilters === 'function') populateFilters(currentData); + if (typeof syncLargeGraphToggle === 'function') syncLargeGraphToggle(); hideLoading(); }).catch(function (err) { diff --git a/src/infragraph/visualizer/frontend/js/network.js b/src/infragraph/visualizer/frontend/js/network.js index 6ec39c9..07558ef 100644 --- a/src/infragraph/visualizer/frontend/js/network.js +++ b/src/infragraph/visualizer/frontend/js/network.js @@ -37,6 +37,48 @@ const internalOptions = { interaction: { hover: true, dragNodes: true, dragView: true, zoomView: true }, }; +// Precomputed view: the generator already assigned x/y/level to every node +// (see Visualizer._compute_layout), so no layout engine or physics is needed. +// Nodes stay draggable because physics is simply off. +const precomputedOptions = { + layout: { hierarchical: { enabled: false }, improvedLayout: false }, + physics: { enabled: false }, + interaction: { hover: true, tooltipDelay: 100, dragNodes: true, dragView: true, zoomView: true }, +}; + +// Large-graph mode (see isLargeGraph in data.js): hover effects trigger a full +// redraw on every node enter/leave, so turn them off. Tooltips still work. +// (Image interpolation is deliberately left on: measured 3x faster redraws +// when zoomed out on 800+ image nodes.) +const largeGraphOverrides = { + interaction: { hover: false, hoverConnectedEdges: false, selectConnectedEdges: false }, +}; + +// Node/edge size thresholds above which a view switches to large-graph mode. +const LARGE_GRAPH_NODES = 300; +const LARGE_GRAPH_EDGES = 1000; + +function hasPrecomputedLayout(data) { + return data.nodes.length > 0 && data.nodes.every(function (n) { + return typeof n.x === 'number' && typeof n.y === 'number'; + }); +} + +// vis options for a prepared dataset: precomputed positions if the generator +// supplied them, otherwise the hierarchical layout matching the current view +// depth (infrastructure vs. device internals). Returns a fresh copy so the +// shared option objects above are never mutated. +function optionsForData(data) { + var base; + if (hasPrecomputedLayout(data)) base = precomputedOptions; + else base = (typeof navigationStack !== 'undefined' && navigationStack.length > 1) ? internalOptions : fabricOptions; + var opts = JSON.parse(JSON.stringify(base)); + if (data.large) { + opts.interaction = Object.assign({}, opts.interaction, largeGraphOverrides.interaction); + } + return opts; +} + // Hierarchical layout pins every node on the level axis (fixed.y for the UD/DU // fabric view, fixed.x for the LR internal view), so drags along that axis are // ignored. Freeze nodes at their current positions and clear `fixed` so they diff --git a/src/infragraph/visualizer/frontend/js/search.js b/src/infragraph/visualizer/frontend/js/search.js index d69e179..b367e17 100644 --- a/src/infragraph/visualizer/frontend/js/search.js +++ b/src/infragraph/visualizer/frontend/js/search.js @@ -21,9 +21,9 @@ function onNodeSearch() { results.innerHTML = matches.slice(0, 12).map(function (m) { return '
' + - '' + m.label + '
'; + '' + m.label + ''; }).join('') + (matches.length > 12 - ? '
... and ' + (matches.length - 12) + ' more
' + ? '
... and ' + (matches.length - 12) + ' more
' : ''); // show up to 12 matches, and if more, indicate how many additional matches there are if (net) net.selectNodes(matches.map(function (m) { return m.id; }), false); @@ -50,7 +50,7 @@ function onPickerSearch() { }); // find nodes that are not already picked and where ID or label includes the search query (case-insensitive) if (!matches.length) { - dd.innerHTML = '
No results
'; + dd.innerHTML = '
No results
'; dd.classList.add('open'); return; } @@ -59,7 +59,7 @@ function onPickerSearch() { return '
' + '' + (m.label || m.id) + '' + (m.id) + '
'; }).join('') + (matches.length > 30 - ? '
... ' + (matches.length - 30) + ' more - keep typing
' + ? '
... ' + (matches.length - 30) + ' more - keep typing
' : ''); dd.classList.add('open'); @@ -106,7 +106,7 @@ function renderPickedTags() { countEl.textContent = '(' + pickedNodeIds.size + ' selected)'; container.innerHTML = Array.from(pickedNodeIds).map(function (id) { - const node = currentData ? currentData.nodes.find(function (n) { return n.id === id; }) : null; + const node = currentData && currentData.byId ? currentData.byId.get(id) : null; return '' + (node ? node.label || id : id) + '✕'; }).join(''); diff --git a/src/infragraph/visualizer/visualize.py b/src/infragraph/visualizer/visualize.py index 9c2c77c..bba36d7 100644 --- a/src/infragraph/visualizer/visualize.py +++ b/src/infragraph/visualizer/visualize.py @@ -1,5 +1,6 @@ import os import shutil +from collections import deque import sys import yaml import json @@ -75,6 +76,12 @@ def __init__(self, service=None, output="./viz", hosts=(), switches=()): all_views["infrastructure.json"] = infra_json print(f" Generated: infrastructure.json ({len(infra_json['nodes'])} nodes, {len(infra_json['edges'])} edges)") + # compressed infrastructure views (large fabrics only), chosen with the UI slider + for name, view in self._generate_compressed_views(infra_json).items(): + all_views[name] = view + print(f" Generated: {name} ({len(view['nodes'])} nodes, {len(view['edges'])} edges; " + f"×{view['ratio']}{', default' if view['default'] else ''})") + #device view for device_name, device_data in self.service._device_data.items(): dev_json = self._generate_component_json(device_name, device_data) @@ -122,6 +129,7 @@ def _collapse_parallel_edges(edges): count = val["count"] e["label"] = f"×{count} {e.get('link', '')}" if count > 1 else e.get("link","") e["width"] = min(1 + count, 6) if count > 1 else 1 + e["count"] = count result.append(e) return result @@ -159,6 +167,299 @@ def _extra_attrs_text(self, attrs, exclude=()): ] return "\n".join(lines) + # Layout constants for the precomputed infrastructure view (canvas units). + LAYOUT_NODE_SPACING = 120 + LAYOUT_GROUP_SPACING = 320 # compressed views: bigger (and wide switch) icons, longer labels + LAYOUT_MIN_LEVEL_SEP = 150 + LAYOUT_MAX_LEVEL_SEP = 1000 + LAYOUT_ASPECT = 4.0 # target width / height of the whole drawing + + # A compressed infrastructure view is generated when the fabric has more + # hosts than this; smaller fabrics are readable as they are. + COMPRESS_MIN_HOSTS = 128 + + @staticmethod + def _leaf_ids(nodes, group_of): + """Ids of the nodes that form the bottom of the hierarchy: the hosts, or + without a --hosts hint the largest instance group.""" + leaves = [n["id"] for n in nodes if n["type"] == "host"] + if leaves: + return leaves + counts = {} + for n in nodes: + counts[group_of[n["id"]]] = counts.get(group_of[n["id"]], 0) + 1 + largest = max(counts, key=counts.get) if counts else None + return [n["id"] for n in nodes if group_of[n["id"]] == largest] + + def _compute_levels(self, nodes, edges, group_of): + """Hierarchy level of every node: BFS distance from the leaves. + Returns: + (level dict id -> int, adjacency dict id -> set of neighbour ids)""" + adj = {n["id"]: set() for n in nodes} + for e in edges: + if e["from"] in adj and e["to"] in adj: + adj[e["from"]].add(e["to"]) + adj[e["to"]].add(e["from"]) + + roots = self._leaf_ids(nodes, group_of) + level = {r: 0 for r in roots} + queue = deque(roots) + while queue: + cur = queue.popleft() + for nb in adj[cur]: + if nb not in level: + level[nb] = level[cur] + 1 + queue.append(nb) + for n in nodes: # disconnected nodes go to the bottom row + level.setdefault(n["id"], 0) + return level, adj + + def _compress_view(self, nodes, edges, group_of, base_labels, tag, coarse=False): + """Merge equivalent nodes of a view into group nodes. + + Two nodes are merged when they belong to the same instance group, sit at + the same level and attach to the same set of neighbours one level up + (e.g. the servers behind one leaf switch, or the leaf switches of one + pod that share the same spines). Top-level nodes, which have nothing + above them, are grouped by the groups they attach to below. With + coarse=True everything in the same instance group and level is merged + into a single node instead. Edges are remapped onto the group nodes and + parallel ones merged into one edge labelled with the total link count. + + The input may itself be a compressed view: group nodes carry a + "members" list of original instance ids, which is flattened into the + new groups so tooltips and search keep working. + Params: + nodes, edges: view lists (layout keys are ignored). + group_of: dict id -> instance name. + base_labels: dict original instance id -> label. + tag: string making the new group ids unique across levels. + coarse: merge per instance group and level instead of by connectivity. + Returns: + (nodes, edges, group_of) for the compressed view.""" + level, adj = self._compute_levels(nodes, edges, group_of) + by_id = {n["id"]: n for n in nodes} + order = {n["id"]: i for i, n in enumerate(nodes)} + assign = {} # node id -> group node id + groups = [] # (group node id, [node ids]) + + for lv in sorted(set(level.values())): + buckets = {} + for n in nodes: + nid = n["id"] + if level[nid] != lv: + continue + if coarse: + key = (group_of[nid],) + else: + up = frozenset(m for m in adj[nid] if level[m] > lv) + if up: + key = (group_of[nid], "up", up) + else: + key = (group_of[nid], "down", + frozenset(assign.get(m, m) for m in adj[nid] if level[m] < lv)) + buckets.setdefault(key, []).append(nid) + for key, ids in buckets.items(): + if len(ids) < 2: + continue + ids.sort(key=order.get) + gid = f"{key[0]}_{tag}g{len(groups)}" + for m in ids: + assign[m] = gid + groups.append((gid, ids)) + + def members_of(n): + return n.get("members", [n["id"]]) + + out_nodes, new_group_of, sort_key = [], {}, {} + for n in nodes: + if n["id"] not in assign: + out_nodes.append(dict(n)) + new_group_of[n["id"]] = group_of[n["id"]] + sort_key[n["id"]] = order[n["id"]] + for gid, ids in groups: + sort_key[gid] = min(order[i] for i in ids) + first = by_id[ids[0]] + grp = group_of[ids[0]] + members = [m for nid in ids for m in members_of(by_id[nid])] + idxs = sorted(int(m.rsplit("_", 1)[1]) for m in members) + contiguous = idxs == list(range(idxs[0], idxs[0] + len(idxs))) + label = f"{grp}[{idxs[0]}..{idxs[-1]}]" if contiguous else f"{grp} ×{len(members)}" + shown = ", ".join(base_labels.get(m, m) for m in members[:12]) + if len(members) > 12: + shown += f", … +{len(members) - 12} more" + # What kind of thing this group is, derived from the data rather than + # assumed: a bottom-tier group whose members all uplink to exactly one + # switch is a rack, and several such racks merged is a pod. Groups on + # higher tiers are neither, so they stay plain groups. + kind, uplink, rack_count = "group", None, 0 + if level[ids[0]] == 0: + uplinks = set() + for nid in ids: + for m in adj[nid]: + if level[m] > level[nid]: + uplinks.update(members_of(by_id[m])) + rack_count = len(uplinks) + if rack_count == 1: + kind = "rack" + uplink = base_labels.get(next(iter(uplinks))) + elif rack_count > 1: + kind = "pod" + + if kind == "rack": + title = (f"Rack: {len(members)} × {grp}\nUplink: {uplink}" + f"\nDevice: {first['device']}\nMembers: {shown}") + elif kind == "pod": + title = (f"Pod: {len(members)} × {grp} across {rack_count} racks" + f"\nDevice: {first['device']}\nMembers: {shown}") + else: + title = (f"Group: {len(members)} × {grp}\nDevice: {first['device']}" + f"\nType: {first['type']}\nMembers: {shown}") + + node = dict(first) + node.update({ + "id": gid, + "label": label, + "title": title, + "size": min(int(first.get("size", 16) * 1.4), 60), + "members": members, + "kind": kind, + "rackCount": rack_count, + }) + out_nodes.append(node) + new_group_of[gid] = grp + # keep the leaf ordering of the original view so the layout stays comparable + out_nodes.sort(key=lambda n: sort_key[n["id"]]) + + merged = {} + for e in edges: + a = assign.get(e["from"], e["from"]) + b = assign.get(e["to"], e["to"]) + if a == b: + continue # internal to a group + key = (min(a, b), max(a, b), e["link"]) + if key not in merged: + merged[key] = dict(e, **{"from": a, "to": b, "count": 0, "_merged": 0}) + merged[key]["count"] += e.get("count", 1) + merged[key]["_merged"] += 1 + out_edges = [] + for e in merged.values(): + n_merged = e.pop("_merged") + if n_merged > 1: + e["label"] = f"×{e['count']} {e['link']}" + e["width"] = min(1 + e["count"], 6) + e["title"] = f"{e['title'].splitlines()[0]}\n×{e['count']} links merged" + out_edges.append(e) + + return out_nodes, out_edges, new_group_of + + def _generate_compressed_views(self, infra_json): + """Ladder of increasingly compressed variants of the infrastructure view. + + Level 1 merges nodes with identical connectivity; each further level + applies the same rule to the previous result (groups whose groups are + equivalent merge again) until nothing changes. A final level collapses + every instance group and tier into a single node. The frontend exposes + the ladder as a compression slider. + Returns: + dict "infrastructure_compressed_.json" -> view JSON, empty when + the fabric has <= COMPRESS_MIN_HOSTS hosts.""" + nodes = [{k: v for k, v in n.items() if k not in ("x", "y", "level")} for n in infra_json["nodes"]] + edges = infra_json["edges"] + group_of = self._instance_group_of + host_count = len(self._leaf_ids(nodes, group_of)) + if host_count <= self.COMPRESS_MIN_HOSTS: + return {} + base_labels = {n["id"]: n["label"] for n in nodes} + total = len(nodes) + + ladder = [] + cur_nodes, cur_edges, cur_group_of = nodes, edges, group_of + while True: + c_nodes, c_edges, c_group_of = self._compress_view( + cur_nodes, cur_edges, cur_group_of, base_labels, tag=f"L{len(ladder) + 1}") + if len(c_nodes) >= len(cur_nodes): + break + ladder.append((c_nodes, c_edges, c_group_of)) + cur_nodes, cur_edges, cur_group_of = c_nodes, c_edges, c_group_of + # Default rung shown when the toggle is switched on: one step past plain + # connectivity merging, which keeps the tiers recognisable. + default_level = min(2, len(ladder)) + + t_nodes, t_edges, t_group_of = self._compress_view( + nodes, edges, group_of, base_labels, tag="T", coarse=True) + if len(t_nodes) < len(cur_nodes): + ladder.append((t_nodes, t_edges, t_group_of)) + if not ladder: + return {} + + views = {} + for k, (c_nodes, c_edges, c_group_of) in enumerate(ladder, start=1): + layout = self._compute_layout(c_nodes, c_edges, c_group_of, spacing=self.LAYOUT_GROUP_SPACING) + for n in c_nodes: + n.update(layout[n["id"]]) + views[f"infrastructure_compressed_{k}.json"] = { + "nodes": c_nodes, "edges": c_edges, + "hostCount": host_count, "level": k, "levels": len(ladder), + "ratio": round(total / len(c_nodes), 1), + "default": k == max(default_level, 1), + } + return views + + def _compute_layout(self, nodes, edges, group_of, spacing=None): + """Assign a hierarchy level and x/y position to every instance node so + the browser can draw the graph without running a layout engine. + + Levels are BFS distance from the hosts (or, without a --hosts hint, from + the largest instance group). Within a level nodes are ordered by the mean + position of their neighbours one level down (barycenter heuristic), then + pushed apart to respect the node spacing. Level 0 sits at y = 0 and + higher levels go up (negative y), matching the DU hierarchical view. + Params: + nodes: list of node dicts (need "id" and "type"). + edges: list of edge dicts (need "from" and "to"). + group_of: dict id -> instance name. + Returns: + dict id -> {"level": int, "x": int, "y": int}""" + level, adj = self._compute_levels(nodes, edges, group_of) + order = {n["id"]: i for i, n in enumerate(nodes)} + spacing = spacing or self.LAYOUT_NODE_SPACING + by_level = {} + for nid, lv in level.items(): + by_level.setdefault(lv, []).append(nid) + + x = {} + for lv in sorted(by_level): + ids = by_level[lv] + if lv == 0: + ids.sort(key=order.get) + bary = {nid: (i - (len(ids) - 1) / 2.0) * spacing for i, nid in enumerate(ids)} + else: + bary = {} + for nid in ids: + below = [x[m] for m in adj[nid] if level[m] < lv and m in x] + bary[nid] = sum(below) / len(below) if below else 0.0 + ids.sort(key=lambda nid: (bary[nid], order[nid])) + # push overlapping nodes apart, then re-centre on the barycenter mean + pos = [] + for nid in ids: + px = bary[nid] + if pos and px < pos[-1] + spacing: + px = pos[-1] + spacing + pos.append(px) + shift = (sum(bary.values()) / len(ids)) - (sum(pos) / len(pos)) + for nid, px in zip(ids, pos): + x[nid] = px + shift + + widest = max(len(ids) for ids in by_level.values()) + width = max(widest - 1, 1) * spacing + n_levels = max(len(by_level) - 1, 1) + level_sep = int(min(max(width / (self.LAYOUT_ASPECT * n_levels), self.LAYOUT_MIN_LEVEL_SEP), + self.LAYOUT_MAX_LEVEL_SEP)) + + return {nid: {"level": level[nid], "x": int(round(x[nid])), "y": -level[nid] * level_sep} + for nid in level} + def _generate_component_json(self, device_name, device_data): """Generate a device component view JSON from a DeviceData object. Params: @@ -254,6 +555,7 @@ def _generate_instance_json(self): # Instance nodes nodes = [] + group_of = {} for instance in self.service.infrastructure.instances: device_name = instance.device for idx in range(instance.count): @@ -279,6 +581,7 @@ def _generate_instance_json(self): "drillable": drillable, "drillTarget": f"{device_name}.json" if drillable else None, }) + group_of[f"{instance.name}_{idx}"] = instance.name # Infrastructure edges from NetworkX graph raw_edges = [] @@ -310,9 +613,19 @@ def _generate_instance_json(self): "color": self._get_link_color(link), "title": title,"label":link, }) + edges = self._collapse_parallel_edges(raw_edges) + + # Precomputed positions: the browser draws these directly instead of + # running vis.js's hierarchical layout and physics, which is what makes + # large fabrics slow to open. + layout = self._compute_layout(nodes, edges, group_of) + for n in nodes: + n.update(layout[n["id"]]) + self._instance_group_of = group_of + return { "nodes": nodes, - "edges": self._collapse_parallel_edges(raw_edges), + "edges": edges, } @staticmethod @@ -398,3 +711,4 @@ def run_visualizer(input_file=None, infrastructure=None, annotations=None, outpu if annotations is not None: service.annotate_graph(annotations) Visualizer(service, output=output, hosts=hosts, switches=switches) +