Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 4 additions & 0 deletions docs/src/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

92 changes: 91 additions & 1 deletion src/infragraph/visualizer/frontend/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -728,4 +814,8 @@ div.vis-tooltip {
.controller-items input[type="range"]::-webkit-slider-thumb:hover {
background: var(--accent-hover);
transform: scale(1.15);
}
}
.controller-items label.checkbox-row input[type="checkbox"] {
flex: 0 0 auto;
accent-color: var(--accent-orange);
}
23 changes: 23 additions & 0 deletions src/infragraph/visualizer/frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,23 @@
</nav>
</div>
<div class="header-right">
<button id="btn-compress" title="Compressed view: merge servers and switches that share the same connections (available for fabrics with more than 128 hosts)" disabled>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="4 14 10 14 10 20"/>
<polyline points="20 10 14 10 14 4"/>
<line x1="14" y1="10" x2="21" y2="3"/>
<line x1="3" y1="21" x2="10" y2="14"/>
</svg>
Compressed
</button>
<div id="rackControls" style="display:none;" title="How much surrounding fabric to show around the rack">
<input type="range" id="rackHops" min="1" max="3" value="1" step="1">
<span id="rackHopsLabel">1 hop</span>
</div>
<div id="compressControls" style="display:none;" title="Compression level: drag right to merge more nodes">
<input type="range" id="compressSlider" min="1" max="1" value="1" step="1">
<span id="compressRatio"></span>
</div>
<button id="btn-fit" title="Fit to view">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>
Expand Down Expand Up @@ -78,8 +95,13 @@ <h4>Interactions</h4>
<div class="legend-hint">Hover node/edge → view attributes</div>
<div class="legend-hint">Scroll → zoom in/out</div>
<div class="legend-hint">Hold a node → see connected nodes</div>
<div class="legend-hint">Compressed → merge equivalent nodes (large fabrics)</div>
<div class="legend-hint">Click a rack/pod node → see its servers and uplinks</div>
</div>

<!-- Transient message, e.g. an action that was refused -->
<div id="toast"></div>

<!-- Loading overlay -->
<div id="loading" class="hidden">
<div class="spinner"></div>
Expand Down Expand Up @@ -154,6 +176,7 @@ <h5>Link Types <span id="edgeTypeCount" class="node-count-badge"></span></h5>
<label>Node spacing <input type ="range" id="spaceslider" min="100" max="500" value="200"></label>
<label>Level seperation <input type ="range" id="levelslider" min="100" max="500" value="150"></label>
<label>Node Size <input type ="range" id="nodeslider" min="10" max="100" value="30"></label>
<label class="checkbox-row" title="Straight edges, no edge labels, no shadows or hover effects. Turns on automatically for large graphs.">Simplified rendering <input type="checkbox" id="largeGraphToggle"></label>
</div>
</div>
</div>
Expand Down
81 changes: 67 additions & 14 deletions src/infragraph/visualizer/frontend/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -39,23 +44,68 @@ 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; }

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;

Expand All @@ -70,18 +120,21 @@ 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);
}
});

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);
}
});

Expand Down
65 changes: 53 additions & 12 deletions src/infragraph/visualizer/frontend/js/controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) }, {});
});
Loading
Loading