Files
tendrils/static/index.html
Ian Gulliver a6ce2e4696 Convert config from maps to lists for ordering
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 16:00:26 -08:00

315 lines
11 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');
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);
}
function buildLocationIndex(locations, parentId, nodeToLocation, locationMeta, depth, orderBase) {
if (!locations) return;
locations.forEach((loc, idx) => {
const locId = 'loc_' + loc.name.replace(/[^a-zA-Z0-9]/g, '_');
locationMeta.set(locId, { name: loc.name, parentId, depth, order: orderBase + idx });
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;
}
function doLayout() {
cy.layout({
name: 'elk',
fit: true,
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'
}
}).run();
}
async function init() {
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();
const 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);
});
const 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
}
});
});
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
}
});
});
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 || ''
}
});
});
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: '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' }
});
doLayout();
}
init().catch(e => {
document.getElementById('error').textContent = e.message;
});
</script>
</body>
</html>