From d826dcdae4dbd8c41a8bca2da1fc7e8fc07299a6 Mon Sep 17 00:00:00 2001 From: Amos-Rai-KEYS Date: Wed, 9 Sep 2026 02:54:37 -0700 Subject: [PATCH 1/5] optimizing visualizer --- .../visualizer/frontend/css/style.css | 6 +- src/infragraph/visualizer/frontend/index.html | 1 + src/infragraph/visualizer/frontend/js/app.js | 50 +++++++--- .../visualizer/frontend/js/controller.js | 64 +++++++++--- src/infragraph/visualizer/frontend/js/data.js | 42 ++++++-- .../visualizer/frontend/js/filters.js | 5 +- .../visualizer/frontend/js/navigation.js | 7 +- .../visualizer/frontend/js/network.js | 42 ++++++++ .../visualizer/frontend/js/search.js | 2 +- src/infragraph/visualizer/visualize.py | 98 ++++++++++++++++++- 10 files changed, 275 insertions(+), 42 deletions(-) diff --git a/src/infragraph/visualizer/frontend/css/style.css b/src/infragraph/visualizer/frontend/css/style.css index 112ec4c..debbb91 100644 --- a/src/infragraph/visualizer/frontend/css/style.css +++ b/src/infragraph/visualizer/frontend/css/style.css @@ -728,4 +728,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..56e1cb7 100644 --- a/src/infragraph/visualizer/frontend/index.html +++ b/src/infragraph/visualizer/frontend/index.html @@ -154,6 +154,7 @@
Link Types
+ diff --git a/src/infragraph/visualizer/frontend/js/app.js b/src/infragraph/visualizer/frontend/js/app.js index deb5759..10f953e 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,20 @@ function initApp() { }); } +// 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 +65,23 @@ 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) { + // Precomputed positions: nothing to stabilize, just frame the graph. + 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,7 +96,7 @@ 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; }); + var nodeData = nodeById.get(nodeId); if (nodeData && nodeData.drillable && nodeData.drillTarget) { navigateTo(nodeData.drillTarget, nodeData.label); } @@ -79,7 +105,7 @@ 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'; } diff --git a/src/infragraph/visualizer/frontend/js/controller.js b/src/infragraph/visualizer/frontend/js/controller.js index 446a3f6..7b09ace 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,19 @@ 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; + respaceLayout({}, { nodeDistance: parseInt(this.value) }); }); -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..38c7b85 100644 --- a/src/infragraph/visualizer/frontend/js/data.js +++ b/src/infragraph/visualizer/frontend/js/data.js @@ -7,6 +7,16 @@ function fetchGraphData(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 +24,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 +38,34 @@ 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 }; + // 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 +78,15 @@ 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, _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..56e95f7 100644 --- a/src/infragraph/visualizer/frontend/js/filters.js +++ b/src/infragraph/visualizer/frontend/js/filters.js @@ -63,12 +63,13 @@ 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 }; + 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..e8f807f 100644 --- a/src/infragraph/visualizer/frontend/js/navigation.js +++ b/src/infragraph/visualizer/frontend/js/navigation.js @@ -24,13 +24,12 @@ function navigateTo(file, label) { updateBreadcrumb(); updateBackButton(); - 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 (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..25f7689 100644 --- a/src/infragraph/visualizer/frontend/js/search.js +++ b/src/infragraph/visualizer/frontend/js/search.js @@ -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..205b5e7 100644 --- a/src/infragraph/visualizer/visualize.py +++ b/src/infragraph/visualizer/visualize.py @@ -122,6 +122,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 +160,90 @@ 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_MIN_LEVEL_SEP = 150 + LAYOUT_MAX_LEVEL_SEP = 6000 + LAYOUT_ASPECT = 4.0 # target width / height of the whole drawing + + def _compute_layout(self, nodes, edges, group_of): + """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}""" + 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 = [n["id"] for n in nodes if n["type"] == "host"] + if not roots: + 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 + roots = [n["id"] for n in nodes if group_of[n["id"]] == largest] + + level = {r: 0 for r in roots} + queue = list(roots) + while queue: + cur = queue.pop(0) + 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) + + order = {n["id"]: i for i, n in enumerate(nodes)} + by_level = {} + for nid, lv in level.items(): + by_level.setdefault(lv, []).append(nid) + + spacing = self.LAYOUT_NODE_SPACING + 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 +339,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 +365,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 +397,18 @@ 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"]]) + return { "nodes": nodes, - "edges": self._collapse_parallel_edges(raw_edges), + "edges": edges, } @staticmethod From c53078fd55725cd7e193b77c4ea91719a38ef0ae Mon Sep 17 00:00:00 2001 From: Amos-Rai-KEYS Date: Tue, 15 Sep 2026 03:13:33 -0700 Subject: [PATCH 2/5] add compression for visualizer --- README.md | 6 + docs/src/cli.md | 4 + src/infragraph/visualizer/README.md | 407 ++++++++++++++++++ .../visualizer/frontend/css/style.css | 86 ++++ src/infragraph/visualizer/frontend/index.html | 22 + src/infragraph/visualizer/frontend/js/app.js | 33 +- .../visualizer/frontend/js/controller.js | 3 +- src/infragraph/visualizer/frontend/js/data.js | 36 +- .../visualizer/frontend/js/filters.js | 3 +- .../visualizer/frontend/js/navigation.js | 341 ++++++++++++++- src/infragraph/visualizer/visualize.py | 270 ++++++++++-- 11 files changed, 1178 insertions(+), 33 deletions(-) create mode 100644 src/infragraph/visualizer/README.md diff --git a/README.md b/README.md index 6ff052e..36824a8 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,12 @@ 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. + +For a full walkthrough of how the layout, the simplified drawing mode and the compression logic work, with worked examples, see [the visualizer notes](src/infragraph/visualizer/README.md). + > **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/README.md b/src/infragraph/visualizer/README.md new file mode 100644 index 0000000..a132d57 --- /dev/null +++ b/src/infragraph/visualizer/README.md @@ -0,0 +1,407 @@ +# How the visualizer handles large fabrics + +This document explains, in plain language, how the InfraGraph visualizer draws +very large topologies quickly, and how the **compression** feature works. + +It is written for someone who has never read the code. Every number in here is +measured from the sample fabrics in the repository root. + +--- + +## 1. The problem + +A 4096 rank Clos fabric turns into a graph with **5376 nodes and 20480 edges**. +Handing that straight to a browser used to take about **6 seconds** before the +picture appeared, and panning was jerky afterwards. + +Three separate things were slow, and they needed three separate fixes: + +| Problem | Fix | Section | +| --- | --- | --- | +| The browser was computing where every node goes | Work it out in Python instead | [2](#2-positions-are-worked-out-in-python) | +| Shadows, curved edges and text outlines redrawn every frame | Simplify the drawing when the graph is big | [3](#3-simplified-drawing-for-big-graphs) | +| Thousands of nodes on screen at once | Merge the ones that are identical | [4](#4-compression) | + +--- + +## 2. Positions are worked out in Python + +### What a layout engine is + +Give a graph library a list of boxes and a list of wires, with no coordinates, +and it has to *invent* the coordinates. That code is called a layout engine. +The one built into vis-network runs in the browser, every time you open the +page, and its cost grows roughly with the square of the node count. + +### What we do instead + +The generator now computes the position of every node and writes it into the +data file: + +```json +{"id": "server_0", "label": "server[0]", "level": 0, "x": -245700, "y": 0} +``` + +With `x` and `y` already filled in there is nothing left to decide, so the +browser turns its layout engine and its physics simulation off completely and +becomes a plain renderer. Draw this icon here, draw a line between those two +points, done. + +Think of it as the difference between posting someone a finished seating chart +and posting them a guest list with a note saying who must sit near whom. + +``` +512 rank fabric, time until the graph is usable + + layout computed in the browser 5.8 s + layout computed in Python 0.59 s +``` + +### How the positions are chosen + +`Visualizer._compute_layout` in `visualize.py` does three things: + +1. **Find the rows.** Starting from the hosts, walk outward one hop at a time. + Servers are row 0, the switches they plug into are row 1, and so on. +2. **Place each row, bottom upward.** A switch wants to sit at the average + horizontal position of the things beneath it, so it ends up centred over its + own servers. Where two nodes want the same spot they are nudged apart, and + the whole row is then re-centred. This is a classic technique called the + barycentre heuristic. +3. **Choose the vertical gap** between rows from the width of the widest row, + aiming for a drawing about four times wider than it is tall. + +The payoff is visible in the result. For the 512 rank fabric every one of the +128 leaf switches ends up **exactly** above the average position of its four +servers, so the wires run straight up and never cross. + +> Device drill-down views such as `switch.json` deliberately do **not** get +> precomputed positions. They only have 8 to 33 nodes, so the browser's own +> engine is instant there and produces a nicer left-to-right arrangement. +> The frontend decides per view by simply asking whether the nodes carry +> coordinates. + +--- + +## 3. Simplified drawing for big graphs + +Once a view passes **300 nodes or 1000 edges**, the frontend drops the +expensive decoration: + +* straight edges instead of curved ones +* no edge labels, the `×N` count moves into the tooltip +* no drop shadows on nodes +* no outline around label text +* hover highlighting off, because it repaints the whole canvas + +The result is roughly a **40 percent cheaper redraw**, which is what makes +panning feel smooth. There is a "Simplified rendering" checkbox in the Controls +panel if you want to force it on or off for a particular view. + +--- + +## 4. Compression + +### 4.1 The one rule + +> **If two boxes plug into the same things above them, draw one box instead of +> two.** + +More precisely, two nodes are merged when all three of these are true: + +1. they belong to the same instance group, so servers never merge with switches +2. they sit on the same row +3. they connect to exactly the same set of nodes one row up + +Nodes at the very top have nothing above them, so for those the rule flips and +they are grouped by what is *below* them instead. + +### 4.2 A worked example + +`clos.yaml` in the repository root is small enough to follow completely. +It has 8 servers, 8 tier_0 switches, 8 tier_1 switches and 4 tier_2 switches, +which is **28 nodes and 40 edges**. + +Working bottom upward: + +``` +row 0 8 servers nothing merges. + Each server has its own private tier_0 uplink, + so no two of them look alike. + +row 1 8 tier_0 merges into 4 pairs. + tier_0_0 and tier_0_1 both plug into {tier_1_0, tier_1_1}, + so they are interchangeable. + +row 2 8 tier_1 merges into 2 groups of 4. + {0, 2, 4, 6} all plug into {tier_2_0, tier_2_1} + {1, 3, 5, 7} all plug into {tier_2_2, tier_2_3} + +row 3 4 tier_2 merges into 2 pairs. + Nothing above them, so they are grouped by what is below, + and the pair 0,1 share the same tier_1 group. +``` + +Two details worth noticing. + +**Row 2 merges switches that are not next to each other.** Switch 0 goes with +switch 2, not with switch 1. That is not a quirk of the code, it is the wiring. +This is where the odd looking `tier_1 ×4` labels come from, explained in +[section 6](#6-why-some-labels-say-n-instead-of-a-range). + +**Row 3 has to run last.** Its rule needs to know which groups row 2 formed, so +the whole pass must go bottom upward. + +### 4.3 Edges are merged too + +Once nodes are merged, many wires now join the same pair of boxes. Those are +folded into one line carrying a running total: + +``` +before 28 nodes, 40 edges +after 16 nodes, 18 edges + +one merged line: tier_1 group to tier_2 group, labelled "×8 tier_1_link" +``` + +The `×8` means eight real cables are represented by that single line. The totals +always add up, no matter how far you compress, which is how you can trust the +picture. + +### 4.4 The slider, and why there is more than one step + +Running the rule once is not the end, because **merging changes the answer to +its own question**. After the tier_0 switches paired up, `server_0` and +`server_1` now plug into the *same box*, when previously they plugged into two +different boxes. They have become interchangeable. + +So the generator feeds the result back in and runs the rule again, and keeps +going until a pass changes nothing. Each pass becomes one step on the +compression slider: + +``` +clos.yaml + + original 28 nodes, 40 edges + step 1 16 nodes, 18 edges + step 2 9 nodes, 8 edges the servers now pair up + step 3 6 nodes, 5 edges + step 4 4 nodes, 3 edges see below +``` + +The **last step is different**. It ignores wiring entirely and merges everything +that shares an instance group and a row, giving exactly one box per tier. It +answers "what tiers exist and how big are they" rather than "how is this wired", +which is why it sits at the far right of the slider. + +Compression is only generated for fabrics with **more than 128 hosts**. +Anything smaller is readable as it is. + +--- + +## 5. Why the leaf switches collapse into one box + +This surprises everybody, so it gets its own section. Here is the 1k fabric: + +| Slider step | servers | tier_0 | tier_1 | tier_2 | total nodes | +| --- | --- | --- | --- | --- | --- | +| original | 1000 | 200 | 200 | 100 | 1500 | +| 1 | 200 | 20 | 10 | 10 | 240 | +| 2 | 20 | **1** | 10 | 10 | 41 | +| 3 | 1 | 1 | 10 | 10 | 22 | +| 4 | 1 | 1 | 1 | 1 | 4 | + +At step 2 all 200 leaf switches become a single box. The reason is that the +spines above them were merged first, and that erased the only difference the +leaves had. + +``` +Round 0, the real wiring + pod A leaves plug into spines 1 and 2 + pod B leaves plug into spines 3 and 4 + Different, so the leaves stay separate. This is step 1. + +Round 1, the spines get merged + spine 1 and spine 3 do the same job in their own pod, so they become box X + spine 2 and spine 4 become box Y + +Round 2, look at the leaves again + pod A leaves plug into X and Y + pod B leaves plug into X and Y + Identical now, so every leaf in the fabric merges into one box. +``` + +Measured on the real graph, the leaves had **20** different uplink patterns +before the spines were merged, and **1** afterwards. + +This is the rule working correctly, but it has a practical consequence worth +remembering: + +> **Step 1 is the last view where pod structure is visible.** +> Steps 2 and beyond describe scale, not wiring. + +A fat tree is especially prone to this because it is deliberately uniform. +Every pod reaches every plane by design, so once the planes are collapsed the +pods become indistinguishable. + +--- + +## 6. Why some labels say `×N` instead of a range + +A group label has to tell you *which* nodes are inside. Two forms are used: + +``` +tier_2[0..15] members are 0, 1, 2, 3 ... 15 step of 1, consecutive +tier_1 ×32 members are 0, 16, 32, 48 ... 496 step of 16, not consecutive +``` + +The range form is only used when it is literally true. Writing +`tier_1[0..496]` would claim the box holds 497 switches when it holds 32, so +the code falls back to a plain count. + +The stride is real. In the 4k fabric the 512 tier_1 switches are arranged as +32 pods of 16, and the switch number encodes both as `pod * 16 + plane`. +Grouping by shared uplinks collects one switch from every pod, all in the same +plane, so you get every 16th switch. + +Known rough edge: every such group reads `tier_1 ×32`, so sixteen of them look +identical on screen. A stride form such as `tier_1[0..496 step 16]` would be +both true and specific. Not implemented yet. + +--- + +## 7. Racks and pods + +A group is labelled from what the data says, never from an assumption: + +| Label | Meaning | +| --- | --- | +| **rack** | a bottom row group whose members all uplink to exactly one switch | +| **pod** | several such racks merged, the tooltip says how many | +| **group** | anything on a higher row, since a spine group is not a rack | + +On the 4k fabric, step 1 produces 512 racks and step 2 produces 32 pods. A rack +tooltip reads: + +``` +Rack: 8 × server +Uplink: tier_0[0] +Device: server +Members: server[0], server[1], ... server[7] +``` + +--- + +## 8. Opening a rack + +Clicking a rack or pod opens it. You see **its actual member servers together +with the switches they uplink to**, not a generic device template: + +``` +Infrastructure › Rack server[0..7] › server[0] + 512 racks 8 servers + tier_0[0] cpu, xpu, nic, pcie +``` + +Each click reveals one more layer, which is what a breadcrumb should do. A hop +slider appears in the header while you are inside a rack and controls how much +surrounding fabric comes along: + +``` +1 hop 8 servers + their tier_0 switch 9 nodes +2 hops plus the 16 tier_1 spines above 25 nodes +``` + +Members are drawn normally and the surrounding context switches are faded, so +it is clear which nodes are the subject. + +Two deliberate restrictions: + +**Only servers open as racks.** Clicking a switch group goes straight to 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. This also removed +the two slowest cases, one of which took 83 seconds. + +**Very large groups will not open.** If the view would exceed **250 nodes** the +cursor becomes `not-allowed`, the tooltip explains why, and clicking shows a +short message instead. At the highest compression a single box can stand for +every server in the fabric, and expanding it would build thousands of nodes and +take about 90 seconds. + +``` +opening a rack 88 ms +opening a switch group 86 ms (the device template) +opening a 4096 server pod refused +``` + +A rack view is built in the browser from data that already ships, so no extra +files are generated and the data file does not grow. It uses the same +barycentre placement as the main view, ported to JavaScript, because +vis-network's own engine stretched larger rack views by more than ten times. + +--- + +## 9. Things you can tune + +### In `visualize.py` + +| Constant | Default | Effect | +| --- | --- | --- | +| `COMPRESS_MIN_HOSTS` | 128 | below this, no compression is generated | +| `LAYOUT_NODE_SPACING` | 120 | horizontal gap in the main view | +| `LAYOUT_GROUP_SPACING` | 320 | horizontal gap in compressed views | +| `LAYOUT_ASPECT` | 4.0 | target width divided by height, **raise it to reduce the vertical gap** | +| `LAYOUT_MIN_LEVEL_SEP` | 150 | floor on the vertical gap | +| `LAYOUT_MAX_LEVEL_SEP` | 6000 | ceiling on the vertical gap | + +The vertical gap is `min(max(width / (ASPECT * rows), MIN), MAX)`. Only one of +the three is in charge for any given view, so check which before changing +anything. For the full 4k view and step 1 the **ceiling** is in charge, so +raising `LAYOUT_ASPECT` there does nothing and you must lower +`LAYOUT_MAX_LEVEL_SEP`. + +Changes here require regenerating. + +### In the frontend + +| Where | Default | Effect | +| --- | --- | --- | +| `network.js` `LARGE_GRAPH_NODES` / `LARGE_GRAPH_EDGES` | 300 / 1000 | when simplified drawing switches on | +| `navigation.js` `RACK_MAX_NODES` | 250 | largest rack view that may be opened | +| `navigation.js` divisor in `layoutSubgraph` | 2 | vertical gap in rack views, same idea as `LAYOUT_ASPECT` | +| `network.js` `internalOptions.levelSeparation` | 180 | vertical gap in device drill-down views | + +The Controls panel also has live sliders for node spacing and level separation, +which is the quickest way to experiment without editing anything. + +> The frontend files are copied into the output directory when you generate, so +> for quick experiments you can edit `viz/js/*.js` and just refresh the browser. +> Copy your final values back into the source, or the next generate will +> overwrite them. + +--- + +## 10. Glossary + +| Term | Meaning | +| --- | --- | +| **row** / level | how many hops a node is from the servers. Servers are row 0 | +| **step** | one position on the compression slider | +| **group node** | a single box standing in for several real nodes | +| **rack** | a group whose members share exactly one uplink switch | +| **pod** | several racks merged into one box | +| **hop** | how far out from a rack the surrounding fabric is included | +| **barycentre** | placing a node at the average position of its neighbours below | + +--- + +## 11. Where the code lives + +| File | Responsibility | +| --- | --- | +| `visualize.py` | builds all views, computes positions, does the compression | +| `frontend/js/network.js` | chooses which drawing mode a view gets | +| `frontend/js/data.js` | turns the generated JSON into vis-network objects | +| `frontend/js/navigation.js` | breadcrumb, compression slider, rack views | +| `frontend/js/app.js` | renders, handles clicks and hover | +| `frontend/js/controller.js` | the Controls panel sliders | +| `frontend/js/filters.js`, `search.js` | filter panel and node search | diff --git a/src/infragraph/visualizer/frontend/css/style.css b/src/infragraph/visualizer/frontend/css/style.css index debbb91..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; diff --git a/src/infragraph/visualizer/frontend/index.html b/src/infragraph/visualizer/frontend/index.html index 56e1cb7..7d426c7 100644 --- a/src/infragraph/visualizer/frontend/index.html +++ b/src/infragraph/visualizer/frontend/index.html @@ -27,6 +27,23 @@
+ + +
+ +
+ '; }).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'); From cb0fe1f693433442d6a394055f1478ed18bdc1c1 Mon Sep 17 00:00:00 2001 From: Amos-Rai-KEYS Date: Tue, 15 Sep 2026 08:49:07 -0700 Subject: [PATCH 5/5] Readme modifications --- README.md | 2 - src/infragraph/visualizer/README.md | 407 ---------------------------- 2 files changed, 409 deletions(-) delete mode 100644 src/infragraph/visualizer/README.md diff --git a/README.md b/README.md index 36824a8..1270151 100644 --- a/README.md +++ b/README.md @@ -53,8 +53,6 @@ For large topologies the visualizer can compress the infrastructure view. A **Co 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. -For a full walkthrough of how the layout, the simplified drawing mode and the compression logic work, with worked examples, see [the visualizer notes](src/infragraph/visualizer/README.md). - > **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/src/infragraph/visualizer/README.md b/src/infragraph/visualizer/README.md deleted file mode 100644 index a132d57..0000000 --- a/src/infragraph/visualizer/README.md +++ /dev/null @@ -1,407 +0,0 @@ -# How the visualizer handles large fabrics - -This document explains, in plain language, how the InfraGraph visualizer draws -very large topologies quickly, and how the **compression** feature works. - -It is written for someone who has never read the code. Every number in here is -measured from the sample fabrics in the repository root. - ---- - -## 1. The problem - -A 4096 rank Clos fabric turns into a graph with **5376 nodes and 20480 edges**. -Handing that straight to a browser used to take about **6 seconds** before the -picture appeared, and panning was jerky afterwards. - -Three separate things were slow, and they needed three separate fixes: - -| Problem | Fix | Section | -| --- | --- | --- | -| The browser was computing where every node goes | Work it out in Python instead | [2](#2-positions-are-worked-out-in-python) | -| Shadows, curved edges and text outlines redrawn every frame | Simplify the drawing when the graph is big | [3](#3-simplified-drawing-for-big-graphs) | -| Thousands of nodes on screen at once | Merge the ones that are identical | [4](#4-compression) | - ---- - -## 2. Positions are worked out in Python - -### What a layout engine is - -Give a graph library a list of boxes and a list of wires, with no coordinates, -and it has to *invent* the coordinates. That code is called a layout engine. -The one built into vis-network runs in the browser, every time you open the -page, and its cost grows roughly with the square of the node count. - -### What we do instead - -The generator now computes the position of every node and writes it into the -data file: - -```json -{"id": "server_0", "label": "server[0]", "level": 0, "x": -245700, "y": 0} -``` - -With `x` and `y` already filled in there is nothing left to decide, so the -browser turns its layout engine and its physics simulation off completely and -becomes a plain renderer. Draw this icon here, draw a line between those two -points, done. - -Think of it as the difference between posting someone a finished seating chart -and posting them a guest list with a note saying who must sit near whom. - -``` -512 rank fabric, time until the graph is usable - - layout computed in the browser 5.8 s - layout computed in Python 0.59 s -``` - -### How the positions are chosen - -`Visualizer._compute_layout` in `visualize.py` does three things: - -1. **Find the rows.** Starting from the hosts, walk outward one hop at a time. - Servers are row 0, the switches they plug into are row 1, and so on. -2. **Place each row, bottom upward.** A switch wants to sit at the average - horizontal position of the things beneath it, so it ends up centred over its - own servers. Where two nodes want the same spot they are nudged apart, and - the whole row is then re-centred. This is a classic technique called the - barycentre heuristic. -3. **Choose the vertical gap** between rows from the width of the widest row, - aiming for a drawing about four times wider than it is tall. - -The payoff is visible in the result. For the 512 rank fabric every one of the -128 leaf switches ends up **exactly** above the average position of its four -servers, so the wires run straight up and never cross. - -> Device drill-down views such as `switch.json` deliberately do **not** get -> precomputed positions. They only have 8 to 33 nodes, so the browser's own -> engine is instant there and produces a nicer left-to-right arrangement. -> The frontend decides per view by simply asking whether the nodes carry -> coordinates. - ---- - -## 3. Simplified drawing for big graphs - -Once a view passes **300 nodes or 1000 edges**, the frontend drops the -expensive decoration: - -* straight edges instead of curved ones -* no edge labels, the `×N` count moves into the tooltip -* no drop shadows on nodes -* no outline around label text -* hover highlighting off, because it repaints the whole canvas - -The result is roughly a **40 percent cheaper redraw**, which is what makes -panning feel smooth. There is a "Simplified rendering" checkbox in the Controls -panel if you want to force it on or off for a particular view. - ---- - -## 4. Compression - -### 4.1 The one rule - -> **If two boxes plug into the same things above them, draw one box instead of -> two.** - -More precisely, two nodes are merged when all three of these are true: - -1. they belong to the same instance group, so servers never merge with switches -2. they sit on the same row -3. they connect to exactly the same set of nodes one row up - -Nodes at the very top have nothing above them, so for those the rule flips and -they are grouped by what is *below* them instead. - -### 4.2 A worked example - -`clos.yaml` in the repository root is small enough to follow completely. -It has 8 servers, 8 tier_0 switches, 8 tier_1 switches and 4 tier_2 switches, -which is **28 nodes and 40 edges**. - -Working bottom upward: - -``` -row 0 8 servers nothing merges. - Each server has its own private tier_0 uplink, - so no two of them look alike. - -row 1 8 tier_0 merges into 4 pairs. - tier_0_0 and tier_0_1 both plug into {tier_1_0, tier_1_1}, - so they are interchangeable. - -row 2 8 tier_1 merges into 2 groups of 4. - {0, 2, 4, 6} all plug into {tier_2_0, tier_2_1} - {1, 3, 5, 7} all plug into {tier_2_2, tier_2_3} - -row 3 4 tier_2 merges into 2 pairs. - Nothing above them, so they are grouped by what is below, - and the pair 0,1 share the same tier_1 group. -``` - -Two details worth noticing. - -**Row 2 merges switches that are not next to each other.** Switch 0 goes with -switch 2, not with switch 1. That is not a quirk of the code, it is the wiring. -This is where the odd looking `tier_1 ×4` labels come from, explained in -[section 6](#6-why-some-labels-say-n-instead-of-a-range). - -**Row 3 has to run last.** Its rule needs to know which groups row 2 formed, so -the whole pass must go bottom upward. - -### 4.3 Edges are merged too - -Once nodes are merged, many wires now join the same pair of boxes. Those are -folded into one line carrying a running total: - -``` -before 28 nodes, 40 edges -after 16 nodes, 18 edges - -one merged line: tier_1 group to tier_2 group, labelled "×8 tier_1_link" -``` - -The `×8` means eight real cables are represented by that single line. The totals -always add up, no matter how far you compress, which is how you can trust the -picture. - -### 4.4 The slider, and why there is more than one step - -Running the rule once is not the end, because **merging changes the answer to -its own question**. After the tier_0 switches paired up, `server_0` and -`server_1` now plug into the *same box*, when previously they plugged into two -different boxes. They have become interchangeable. - -So the generator feeds the result back in and runs the rule again, and keeps -going until a pass changes nothing. Each pass becomes one step on the -compression slider: - -``` -clos.yaml - - original 28 nodes, 40 edges - step 1 16 nodes, 18 edges - step 2 9 nodes, 8 edges the servers now pair up - step 3 6 nodes, 5 edges - step 4 4 nodes, 3 edges see below -``` - -The **last step is different**. It ignores wiring entirely and merges everything -that shares an instance group and a row, giving exactly one box per tier. It -answers "what tiers exist and how big are they" rather than "how is this wired", -which is why it sits at the far right of the slider. - -Compression is only generated for fabrics with **more than 128 hosts**. -Anything smaller is readable as it is. - ---- - -## 5. Why the leaf switches collapse into one box - -This surprises everybody, so it gets its own section. Here is the 1k fabric: - -| Slider step | servers | tier_0 | tier_1 | tier_2 | total nodes | -| --- | --- | --- | --- | --- | --- | -| original | 1000 | 200 | 200 | 100 | 1500 | -| 1 | 200 | 20 | 10 | 10 | 240 | -| 2 | 20 | **1** | 10 | 10 | 41 | -| 3 | 1 | 1 | 10 | 10 | 22 | -| 4 | 1 | 1 | 1 | 1 | 4 | - -At step 2 all 200 leaf switches become a single box. The reason is that the -spines above them were merged first, and that erased the only difference the -leaves had. - -``` -Round 0, the real wiring - pod A leaves plug into spines 1 and 2 - pod B leaves plug into spines 3 and 4 - Different, so the leaves stay separate. This is step 1. - -Round 1, the spines get merged - spine 1 and spine 3 do the same job in their own pod, so they become box X - spine 2 and spine 4 become box Y - -Round 2, look at the leaves again - pod A leaves plug into X and Y - pod B leaves plug into X and Y - Identical now, so every leaf in the fabric merges into one box. -``` - -Measured on the real graph, the leaves had **20** different uplink patterns -before the spines were merged, and **1** afterwards. - -This is the rule working correctly, but it has a practical consequence worth -remembering: - -> **Step 1 is the last view where pod structure is visible.** -> Steps 2 and beyond describe scale, not wiring. - -A fat tree is especially prone to this because it is deliberately uniform. -Every pod reaches every plane by design, so once the planes are collapsed the -pods become indistinguishable. - ---- - -## 6. Why some labels say `×N` instead of a range - -A group label has to tell you *which* nodes are inside. Two forms are used: - -``` -tier_2[0..15] members are 0, 1, 2, 3 ... 15 step of 1, consecutive -tier_1 ×32 members are 0, 16, 32, 48 ... 496 step of 16, not consecutive -``` - -The range form is only used when it is literally true. Writing -`tier_1[0..496]` would claim the box holds 497 switches when it holds 32, so -the code falls back to a plain count. - -The stride is real. In the 4k fabric the 512 tier_1 switches are arranged as -32 pods of 16, and the switch number encodes both as `pod * 16 + plane`. -Grouping by shared uplinks collects one switch from every pod, all in the same -plane, so you get every 16th switch. - -Known rough edge: every such group reads `tier_1 ×32`, so sixteen of them look -identical on screen. A stride form such as `tier_1[0..496 step 16]` would be -both true and specific. Not implemented yet. - ---- - -## 7. Racks and pods - -A group is labelled from what the data says, never from an assumption: - -| Label | Meaning | -| --- | --- | -| **rack** | a bottom row group whose members all uplink to exactly one switch | -| **pod** | several such racks merged, the tooltip says how many | -| **group** | anything on a higher row, since a spine group is not a rack | - -On the 4k fabric, step 1 produces 512 racks and step 2 produces 32 pods. A rack -tooltip reads: - -``` -Rack: 8 × server -Uplink: tier_0[0] -Device: server -Members: server[0], server[1], ... server[7] -``` - ---- - -## 8. Opening a rack - -Clicking a rack or pod opens it. You see **its actual member servers together -with the switches they uplink to**, not a generic device template: - -``` -Infrastructure › Rack server[0..7] › server[0] - 512 racks 8 servers + tier_0[0] cpu, xpu, nic, pcie -``` - -Each click reveals one more layer, which is what a breadcrumb should do. A hop -slider appears in the header while you are inside a rack and controls how much -surrounding fabric comes along: - -``` -1 hop 8 servers + their tier_0 switch 9 nodes -2 hops plus the 16 tier_1 spines above 25 nodes -``` - -Members are drawn normally and the surrounding context switches are faded, so -it is clear which nodes are the subject. - -Two deliberate restrictions: - -**Only servers open as racks.** Clicking a switch group goes straight to 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. This also removed -the two slowest cases, one of which took 83 seconds. - -**Very large groups will not open.** If the view would exceed **250 nodes** the -cursor becomes `not-allowed`, the tooltip explains why, and clicking shows a -short message instead. At the highest compression a single box can stand for -every server in the fabric, and expanding it would build thousands of nodes and -take about 90 seconds. - -``` -opening a rack 88 ms -opening a switch group 86 ms (the device template) -opening a 4096 server pod refused -``` - -A rack view is built in the browser from data that already ships, so no extra -files are generated and the data file does not grow. It uses the same -barycentre placement as the main view, ported to JavaScript, because -vis-network's own engine stretched larger rack views by more than ten times. - ---- - -## 9. Things you can tune - -### In `visualize.py` - -| Constant | Default | Effect | -| --- | --- | --- | -| `COMPRESS_MIN_HOSTS` | 128 | below this, no compression is generated | -| `LAYOUT_NODE_SPACING` | 120 | horizontal gap in the main view | -| `LAYOUT_GROUP_SPACING` | 320 | horizontal gap in compressed views | -| `LAYOUT_ASPECT` | 4.0 | target width divided by height, **raise it to reduce the vertical gap** | -| `LAYOUT_MIN_LEVEL_SEP` | 150 | floor on the vertical gap | -| `LAYOUT_MAX_LEVEL_SEP` | 6000 | ceiling on the vertical gap | - -The vertical gap is `min(max(width / (ASPECT * rows), MIN), MAX)`. Only one of -the three is in charge for any given view, so check which before changing -anything. For the full 4k view and step 1 the **ceiling** is in charge, so -raising `LAYOUT_ASPECT` there does nothing and you must lower -`LAYOUT_MAX_LEVEL_SEP`. - -Changes here require regenerating. - -### In the frontend - -| Where | Default | Effect | -| --- | --- | --- | -| `network.js` `LARGE_GRAPH_NODES` / `LARGE_GRAPH_EDGES` | 300 / 1000 | when simplified drawing switches on | -| `navigation.js` `RACK_MAX_NODES` | 250 | largest rack view that may be opened | -| `navigation.js` divisor in `layoutSubgraph` | 2 | vertical gap in rack views, same idea as `LAYOUT_ASPECT` | -| `network.js` `internalOptions.levelSeparation` | 180 | vertical gap in device drill-down views | - -The Controls panel also has live sliders for node spacing and level separation, -which is the quickest way to experiment without editing anything. - -> The frontend files are copied into the output directory when you generate, so -> for quick experiments you can edit `viz/js/*.js` and just refresh the browser. -> Copy your final values back into the source, or the next generate will -> overwrite them. - ---- - -## 10. Glossary - -| Term | Meaning | -| --- | --- | -| **row** / level | how many hops a node is from the servers. Servers are row 0 | -| **step** | one position on the compression slider | -| **group node** | a single box standing in for several real nodes | -| **rack** | a group whose members share exactly one uplink switch | -| **pod** | several racks merged into one box | -| **hop** | how far out from a rack the surrounding fabric is included | -| **barycentre** | placing a node at the average position of its neighbours below | - ---- - -## 11. Where the code lives - -| File | Responsibility | -| --- | --- | -| `visualize.py` | builds all views, computes positions, does the compression | -| `frontend/js/network.js` | chooses which drawing mode a view gets | -| `frontend/js/data.js` | turns the generated JSON into vis-network objects | -| `frontend/js/navigation.js` | breadcrumb, compression slider, rack views | -| `frontend/js/app.js` | renders, handles clicks and hover | -| `frontend/js/controller.js` | the Controls panel sliders | -| `frontend/js/filters.js`, `search.js` | filter panel and node search |