466 lines
17 KiB
HTML
466 lines
17 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Tendrils Network</title>
|
|
<style>
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
font-family: system-ui, sans-serif;
|
|
margin: 0;
|
|
padding: 10px;
|
|
background: #111;
|
|
color: #eee;
|
|
height: 100vh;
|
|
}
|
|
#controls {
|
|
margin-bottom: 10px;
|
|
}
|
|
#controls button {
|
|
background: #333;
|
|
color: #fff;
|
|
border: 1px solid #555;
|
|
padding: 6px 12px;
|
|
margin-right: 5px;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
}
|
|
#controls button:hover { background: #444; }
|
|
#cy {
|
|
background: #1a1a1a;
|
|
border: 1px solid #333;
|
|
height: calc(100vh - 50px);
|
|
width: 100%;
|
|
}
|
|
#error { color: #f66; padding: 20px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="controls">
|
|
<strong>Tendrils</strong>
|
|
<button onclick="doLayout()">Layout</button>
|
|
<button onclick="cy.fit(50)">Fit</button>
|
|
<span id="stats"></span>
|
|
</div>
|
|
<div id="error"></div>
|
|
<div id="cy"></div>
|
|
|
|
<script src="cytoscape.min.js"></script>
|
|
<script src="elk.bundled.js"></script>
|
|
<script src="cytoscape-elk.min.js"></script>
|
|
<script>
|
|
cytoscape.use(cytoscapeElk);
|
|
let cy;
|
|
|
|
function getLabel(node) {
|
|
if (node.names && node.names.length > 0) return node.names.join('\n');
|
|
if (node.interfaces && node.interfaces.length > 0) {
|
|
const ips = [];
|
|
node.interfaces.forEach(iface => {
|
|
if (iface.ips) iface.ips.forEach(ip => ips.push(ip));
|
|
});
|
|
if (ips.length > 0) return ips.join('\n');
|
|
const macs = [];
|
|
node.interfaces.forEach(iface => {
|
|
if (iface.mac) macs.push(iface.mac);
|
|
});
|
|
if (macs.length > 0) return macs.join('\n');
|
|
}
|
|
return '??';
|
|
}
|
|
|
|
function getNodeIdentifiers(node) {
|
|
const ids = [];
|
|
if (node.names) {
|
|
node.names.forEach(n => ids.push(n.toLowerCase()));
|
|
}
|
|
if (node.interfaces) {
|
|
node.interfaces.forEach(iface => {
|
|
if (iface.mac) ids.push(iface.mac.toLowerCase());
|
|
});
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
function isSwitch(node) {
|
|
return !!(node.poe_budget);
|
|
}
|
|
|
|
let anonCounter = 0;
|
|
|
|
function buildLocationIndex(locations, parentId, nodeToLocation, locationMeta, depth, orderBase) {
|
|
if (!locations) return;
|
|
locations.forEach((loc, idx) => {
|
|
let locId;
|
|
if (loc.name) {
|
|
locId = 'loc_' + loc.name.replace(/[^a-zA-Z0-9]/g, '_');
|
|
locationMeta.set(locId, { name: loc.name, parentId, depth, order: orderBase + idx, anonymous: false });
|
|
} else {
|
|
locId = 'loc_anon_' + (anonCounter++);
|
|
locationMeta.set(locId, { name: '', parentId, depth, order: orderBase + idx, anonymous: true });
|
|
}
|
|
|
|
if (loc.nodes) {
|
|
loc.nodes.forEach(nodeRef => {
|
|
nodeToLocation.set(nodeRef.toLowerCase(), locId);
|
|
});
|
|
}
|
|
|
|
if (loc.children) {
|
|
buildLocationIndex(loc.children, locId, nodeToLocation, locationMeta, depth + 1, 0);
|
|
}
|
|
});
|
|
}
|
|
|
|
function getLocationChain(locId, locationMeta) {
|
|
const chain = [];
|
|
let current = locId;
|
|
while (current) {
|
|
chain.push(current);
|
|
const meta = locationMeta.get(current);
|
|
current = meta ? meta.parentId : null;
|
|
}
|
|
return chain;
|
|
}
|
|
|
|
let locationMeta = new Map();
|
|
let usedLocations = new Set();
|
|
|
|
function doLayout() {
|
|
const layout = cy.layout({
|
|
name: 'elk',
|
|
fit: false,
|
|
padding: 50,
|
|
nodeDimensionsIncludeLabels: true,
|
|
elk: {
|
|
algorithm: 'layered',
|
|
'elk.direction': 'DOWN',
|
|
'elk.spacing.nodeNode': 80,
|
|
'elk.spacing.edgeNode': 40,
|
|
'elk.spacing.edgeEdge': 30,
|
|
'elk.layered.spacing.nodeNodeBetweenLayers': 100,
|
|
'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP',
|
|
'elk.hierarchyHandling': 'INCLUDE_CHILDREN'
|
|
},
|
|
stop: function() {
|
|
reorderLocations();
|
|
cy.fit(50);
|
|
}
|
|
});
|
|
layout.run();
|
|
}
|
|
|
|
function reorderLocations() {
|
|
const locationsByParent = new Map();
|
|
locationMeta.forEach((meta, locId) => {
|
|
if (!usedLocations.has(locId)) return;
|
|
const parentKey = meta.parentId || '__root__';
|
|
if (!locationsByParent.has(parentKey)) {
|
|
locationsByParent.set(parentKey, []);
|
|
}
|
|
locationsByParent.get(parentKey).push({ id: locId, order: meta.order, depth: meta.depth });
|
|
});
|
|
|
|
const depths = new Set();
|
|
locationMeta.forEach((meta, locId) => {
|
|
if (usedLocations.has(locId)) depths.add(meta.depth);
|
|
});
|
|
const sortedDepths = Array.from(depths).sort((a, b) => b - a);
|
|
|
|
sortedDepths.forEach(depth => {
|
|
const isVertical = depth % 2 === 0;
|
|
|
|
locationsByParent.forEach((siblings, parentKey) => {
|
|
const locationsAtDepth = siblings.filter(s => s.depth === depth);
|
|
if (locationsAtDepth.length < 2) return;
|
|
|
|
locationsAtDepth.sort((a, b) => a.order - b.order);
|
|
reorderSiblings(locationsAtDepth.map(l => l.id), isVertical);
|
|
});
|
|
});
|
|
}
|
|
|
|
function reorderSiblings(locIds, isVertical) {
|
|
const boxes = locIds.map(locId => {
|
|
const node = cy.getElementById(locId);
|
|
if (node.empty()) return null;
|
|
const bb = node.boundingBox();
|
|
return {
|
|
id: locId,
|
|
bb: bb,
|
|
width: bb.x2 - bb.x1,
|
|
height: bb.y2 - bb.y1,
|
|
centerX: (bb.x1 + bb.x2) / 2,
|
|
centerY: (bb.y1 + bb.y2) / 2
|
|
};
|
|
}).filter(b => b !== null);
|
|
|
|
if (boxes.length < 2) return;
|
|
|
|
const gap = 50;
|
|
|
|
if (isVertical) {
|
|
const widest = boxes.reduce((a, b) => b.width > a.width ? b : a);
|
|
const targetCenterX = widest.centerX;
|
|
const minY = Math.min(...boxes.map(b => b.bb.y1));
|
|
|
|
let targetY = minY;
|
|
boxes.forEach(box => {
|
|
const deltaX = targetCenterX - box.centerX;
|
|
const deltaY = targetY - box.bb.y1;
|
|
moveLocationAndDescendants(box.id, deltaX, deltaY);
|
|
targetY += box.height + gap;
|
|
});
|
|
} else {
|
|
const tallest = boxes.reduce((a, b) => b.height > a.height ? b : a);
|
|
const targetCenterY = tallest.centerY;
|
|
const minX = Math.min(...boxes.map(b => b.bb.x1));
|
|
|
|
let targetX = minX;
|
|
boxes.forEach(box => {
|
|
const deltaX = targetX - box.bb.x1;
|
|
const deltaY = targetCenterY - box.centerY;
|
|
moveLocationAndDescendants(box.id, deltaX, deltaY);
|
|
targetX += box.width + gap;
|
|
});
|
|
}
|
|
}
|
|
|
|
function moveLocationAndDescendants(locId, deltaX, deltaY) {
|
|
if (deltaX === 0 && deltaY === 0) return;
|
|
const node = cy.getElementById(locId);
|
|
node.descendants().filter(n => !n.isParent()).forEach(n => {
|
|
const pos = n.position();
|
|
n.position({ x: pos.x + deltaX, y: pos.y + deltaY });
|
|
});
|
|
}
|
|
|
|
async function init() {
|
|
anonCounter = 0;
|
|
const [statusResp, configResp] = await Promise.all([
|
|
fetch('/api/status'),
|
|
fetch('/api/config')
|
|
]);
|
|
const data = await statusResp.json();
|
|
const config = await configResp.json();
|
|
|
|
const nodes = data.nodes || [];
|
|
const links = data.links || [];
|
|
|
|
document.getElementById('stats').textContent =
|
|
`${nodes.length} nodes, ${links.length} links`;
|
|
|
|
const elements = [];
|
|
const idMap = new Map();
|
|
const switchIds = new Set();
|
|
|
|
const nodeToLocation = new Map();
|
|
locationMeta = new Map();
|
|
buildLocationIndex(config.locations || [], null, nodeToLocation, locationMeta, 0, 0);
|
|
|
|
nodes.forEach((n, i) => {
|
|
const id = 'n' + i;
|
|
idMap.set(n.typeid, id);
|
|
if (isSwitch(n)) switchIds.add(id);
|
|
});
|
|
|
|
usedLocations = new Set();
|
|
const nodeParents = new Map();
|
|
|
|
nodes.forEach((n, i) => {
|
|
const id = 'n' + i;
|
|
const identifiers = getNodeIdentifiers(n);
|
|
for (const ident of identifiers) {
|
|
if (nodeToLocation.has(ident)) {
|
|
const locId = nodeToLocation.get(ident);
|
|
nodeParents.set(id, locId);
|
|
getLocationChain(locId, locationMeta).forEach(l => usedLocations.add(l));
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
|
|
const sortedLocations = Array.from(usedLocations).sort((a, b) => {
|
|
const chainA = getLocationChain(a, locationMeta).length;
|
|
const chainB = getLocationChain(b, locationMeta).length;
|
|
return chainA - chainB;
|
|
});
|
|
|
|
sortedLocations.forEach(locId => {
|
|
const meta = locationMeta.get(locId);
|
|
elements.push({
|
|
data: {
|
|
id: locId,
|
|
label: meta.name,
|
|
parent: meta.parentId && usedLocations.has(meta.parentId) ? meta.parentId : null,
|
|
isLocation: true,
|
|
isAnonymous: meta.anonymous
|
|
}
|
|
});
|
|
});
|
|
|
|
nodes.forEach((n, i) => {
|
|
const id = 'n' + i;
|
|
const sw = switchIds.has(id);
|
|
const parent = nodeParents.get(id) || null;
|
|
|
|
elements.push({
|
|
data: {
|
|
id: id,
|
|
label: getLabel(n),
|
|
isSwitch: sw,
|
|
parent: parent,
|
|
rawData: n
|
|
}
|
|
});
|
|
});
|
|
|
|
links.forEach((link, i) => {
|
|
const idA = idMap.get(link.node_a?.typeid);
|
|
const idB = idMap.get(link.node_b?.typeid);
|
|
if (!idA || !idB) return;
|
|
|
|
elements.push({
|
|
data: {
|
|
id: 'e' + i,
|
|
source: idA,
|
|
target: idB,
|
|
sourceLabel: link.interface_a || '',
|
|
targetLabel: link.interface_b || '',
|
|
rawData: link
|
|
}
|
|
});
|
|
});
|
|
|
|
cy = cytoscape({
|
|
container: document.getElementById('cy'),
|
|
elements: elements,
|
|
autoungrabify: true,
|
|
autounselectify: true,
|
|
style: [
|
|
{
|
|
selector: 'node',
|
|
style: {
|
|
'label': 'data(label)',
|
|
'text-valign': 'center',
|
|
'text-halign': 'center',
|
|
'background-color': '#a6d',
|
|
'color': '#fff',
|
|
'font-size': 12,
|
|
'width': 120,
|
|
'height': 40,
|
|
'padding': 8,
|
|
'shape': 'round-rectangle',
|
|
'text-wrap': 'wrap',
|
|
'text-max-width': 110
|
|
}
|
|
},
|
|
{
|
|
selector: 'node[?isSwitch]',
|
|
style: {
|
|
'background-color': '#2a2',
|
|
'border-width': 3,
|
|
'border-color': '#4f4',
|
|
'font-size': 14,
|
|
'font-weight': 'bold',
|
|
'width': 100,
|
|
'height': 50
|
|
}
|
|
},
|
|
{
|
|
selector: 'node[?isLocation]',
|
|
style: {
|
|
'background-color': '#333',
|
|
'background-opacity': 0.8,
|
|
'border-width': 2,
|
|
'border-color': '#666',
|
|
'text-valign': 'top',
|
|
'text-halign': 'center',
|
|
'text-margin-y': 10,
|
|
'font-size': 16,
|
|
'font-weight': 'bold',
|
|
'color': '#fff',
|
|
'padding': 30,
|
|
'shape': 'round-rectangle'
|
|
}
|
|
},
|
|
{
|
|
selector: ':parent',
|
|
style: {
|
|
'background-opacity': 0.5,
|
|
'border-width': 2,
|
|
'border-color': '#666',
|
|
'text-valign': 'top',
|
|
'text-halign': 'center',
|
|
'text-margin-y': 10,
|
|
'padding': 30
|
|
}
|
|
},
|
|
{
|
|
selector: 'node[?isAnonymous]',
|
|
style: {
|
|
'background-opacity': 0,
|
|
'border-width': 0,
|
|
'padding': 10
|
|
}
|
|
},
|
|
{
|
|
selector: 'edge',
|
|
style: {
|
|
'width': 2,
|
|
'line-color': '#666',
|
|
'curve-style': 'bezier',
|
|
'source-label': 'data(sourceLabel)',
|
|
'target-label': 'data(targetLabel)',
|
|
'source-text-offset': 40,
|
|
'target-text-offset': 40,
|
|
'source-text-rotation': 'autorotate',
|
|
'target-text-rotation': 'autorotate',
|
|
'font-size': 9,
|
|
'color': '#aaa',
|
|
'text-background-color': '#1a1a1a',
|
|
'text-background-opacity': 1,
|
|
'text-background-padding': 2
|
|
}
|
|
}
|
|
],
|
|
layout: { name: 'preset' }
|
|
});
|
|
|
|
cy.on('click', 'node', function(evt) {
|
|
const node = evt.target;
|
|
const rawData = node.data('rawData');
|
|
if (rawData && !node.data('isLocation')) {
|
|
const json = JSON.stringify(rawData, null, 2);
|
|
navigator.clipboard.writeText(json).then(() => {
|
|
console.log('Copied node data');
|
|
}).catch(err => {
|
|
console.error('Copy failed:', err);
|
|
});
|
|
}
|
|
});
|
|
|
|
cy.on('click', 'edge', function(evt) {
|
|
const edge = evt.target;
|
|
const rawData = edge.data('rawData');
|
|
if (rawData) {
|
|
const json = JSON.stringify(rawData, null, 2);
|
|
navigator.clipboard.writeText(json).then(() => {
|
|
console.log('Copied link data');
|
|
}).catch(err => {
|
|
console.error('Copy failed:', err);
|
|
});
|
|
}
|
|
});
|
|
|
|
doLayout();
|
|
}
|
|
|
|
init().catch(e => {
|
|
document.getElementById('error').textContent = e.message;
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|