2026-01-24 11:22:35 -08:00
|
|
|
<!DOCTYPE html>
|
|
|
|
|
<html lang="en">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
2026-01-24 14:26:38 -08:00
|
|
|
<title>Tendrils Network</title>
|
2026-01-24 11:22:35 -08:00
|
|
|
<style>
|
2026-01-24 14:26:38 -08:00
|
|
|
* { box-sizing: border-box; }
|
2026-01-24 11:22:35 -08:00
|
|
|
body {
|
2026-01-24 14:26:38 -08:00
|
|
|
font-family: system-ui, sans-serif;
|
2026-01-24 11:22:35 -08:00
|
|
|
margin: 0;
|
2026-01-24 14:26:38 -08:00
|
|
|
padding: 10px;
|
|
|
|
|
background: #111;
|
2026-01-24 11:22:35 -08:00
|
|
|
color: #eee;
|
|
|
|
|
}
|
2026-01-24 14:26:38 -08:00
|
|
|
#controls {
|
|
|
|
|
margin-bottom: 10px;
|
2026-01-24 11:22:35 -08:00
|
|
|
}
|
2026-01-25 17:16:21 -08:00
|
|
|
#stats { margin-left: 10px; }
|
|
|
|
|
#error { color: #f66; padding: 20px; }
|
|
|
|
|
|
|
|
|
|
#container {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: 20px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.location {
|
|
|
|
|
background: #222;
|
|
|
|
|
border: 1px solid #444;
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
padding: 10px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.location.top-level {
|
|
|
|
|
width: 100%;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.location-name {
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
margin-bottom: 10px;
|
|
|
|
|
text-align: center;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.location.anonymous {
|
|
|
|
|
background: transparent;
|
|
|
|
|
border: none;
|
|
|
|
|
padding: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.location.anonymous > .location-name {
|
|
|
|
|
display: none;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.node-row {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: row;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.node-row + .node-row {
|
|
|
|
|
margin-top: 8px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.node {
|
2026-01-25 17:24:37 -08:00
|
|
|
position: relative;
|
2026-01-25 17:16:21 -08:00
|
|
|
width: 120px;
|
2026-01-25 17:24:37 -08:00
|
|
|
min-height: 50px;
|
2026-01-25 17:16:21 -08:00
|
|
|
background: #a6d;
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
text-align: center;
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
padding: 4px;
|
2026-01-24 14:26:38 -08:00
|
|
|
cursor: pointer;
|
2026-01-25 17:24:37 -08:00
|
|
|
overflow: visible;
|
2026-01-25 17:16:21 -08:00
|
|
|
word-break: normal;
|
|
|
|
|
overflow-wrap: break-word;
|
|
|
|
|
white-space: pre-line;
|
2026-01-25 17:24:37 -08:00
|
|
|
margin-top: 8px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.node .switch-port {
|
|
|
|
|
position: absolute;
|
|
|
|
|
top: -8px;
|
|
|
|
|
left: 50%;
|
|
|
|
|
transform: translateX(-50%);
|
|
|
|
|
font-size: 9px;
|
|
|
|
|
background: #444;
|
|
|
|
|
color: #ccc;
|
|
|
|
|
padding: 1px 6px;
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.node .switch-port.external {
|
|
|
|
|
background: #633;
|
|
|
|
|
color: #f99;
|
2026-01-24 11:22:35 -08:00
|
|
|
}
|
2026-01-25 17:16:21 -08:00
|
|
|
|
|
|
|
|
.node:hover {
|
|
|
|
|
filter: brightness(1.2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.node.switch {
|
|
|
|
|
background: #2a2;
|
|
|
|
|
border: 2px solid #4f4;
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.node.copied {
|
|
|
|
|
outline: 2px solid #fff;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.children {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 15px;
|
|
|
|
|
margin-top: 10px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.children.horizontal {
|
|
|
|
|
flex-direction: row;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
justify-content: space-evenly;
|
2026-01-24 14:26:38 -08:00
|
|
|
width: 100%;
|
2026-01-24 11:22:35 -08:00
|
|
|
}
|
2026-01-25 17:16:21 -08:00
|
|
|
|
|
|
|
|
.children.vertical {
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
align-items: center;
|
|
|
|
|
}
|
2026-01-24 11:22:35 -08:00
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
2026-01-24 14:26:38 -08:00
|
|
|
<div id="controls">
|
|
|
|
|
<strong>Tendrils</strong>
|
|
|
|
|
<span id="stats"></span>
|
2026-01-24 11:22:35 -08:00
|
|
|
</div>
|
2026-01-24 14:26:38 -08:00
|
|
|
<div id="error"></div>
|
2026-01-25 17:16:21 -08:00
|
|
|
<div id="container"></div>
|
2026-01-24 11:22:35 -08:00
|
|
|
|
|
|
|
|
<script>
|
2026-01-24 14:26:38 -08:00
|
|
|
function getLabel(node) {
|
2026-01-24 16:00:26 -08:00
|
|
|
if (node.names && node.names.length > 0) return node.names.join('\n');
|
2026-01-25 11:28:56 -08:00
|
|
|
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');
|
|
|
|
|
}
|
2026-01-24 14:26:38 -08:00
|
|
|
return '??';
|
2026-01-24 11:22:35 -08:00
|
|
|
}
|
|
|
|
|
|
2026-01-24 15:04:42 -08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-24 14:26:38 -08:00
|
|
|
function isSwitch(node) {
|
|
|
|
|
return !!(node.poe_budget);
|
2026-01-24 11:22:35 -08:00
|
|
|
}
|
|
|
|
|
|
2026-01-24 21:24:05 -08:00
|
|
|
let anonCounter = 0;
|
|
|
|
|
|
2026-01-25 17:24:37 -08:00
|
|
|
function buildLocationTree(locations, parent) {
|
2026-01-25 17:16:21 -08:00
|
|
|
if (!locations) return [];
|
|
|
|
|
return locations.map((loc, idx) => {
|
2026-01-24 21:24:05 -08:00
|
|
|
let locId;
|
2026-01-25 17:16:21 -08:00
|
|
|
let anonymous = false;
|
2026-01-24 21:24:05 -08:00
|
|
|
if (loc.name) {
|
|
|
|
|
locId = 'loc_' + loc.name.replace(/[^a-zA-Z0-9]/g, '_');
|
|
|
|
|
} else {
|
|
|
|
|
locId = 'loc_anon_' + (anonCounter++);
|
2026-01-25 17:16:21 -08:00
|
|
|
anonymous = true;
|
2026-01-24 15:04:42 -08:00
|
|
|
}
|
2026-01-25 17:24:37 -08:00
|
|
|
const locObj = {
|
2026-01-25 17:16:21 -08:00
|
|
|
id: locId,
|
|
|
|
|
name: loc.name || '',
|
|
|
|
|
anonymous: anonymous,
|
|
|
|
|
direction: loc.direction || 'horizontal',
|
|
|
|
|
nodeRefs: (loc.nodes || []).map(n => n.toLowerCase()),
|
2026-01-25 17:24:37 -08:00
|
|
|
parent: parent,
|
|
|
|
|
children: []
|
2026-01-25 17:16:21 -08:00
|
|
|
};
|
2026-01-25 17:24:37 -08:00
|
|
|
locObj.children = buildLocationTree(loc.children, locObj);
|
|
|
|
|
return locObj;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getSwitchesInLocation(loc, assignedNodes) {
|
|
|
|
|
const switches = [];
|
|
|
|
|
const nodes = assignedNodes.get(loc) || [];
|
|
|
|
|
nodes.forEach(n => {
|
|
|
|
|
if (isSwitch(n)) switches.push(n);
|
|
|
|
|
});
|
|
|
|
|
loc.children.forEach(child => {
|
|
|
|
|
if (child.anonymous) {
|
|
|
|
|
switches.push(...getSwitchesInLocation(child, assignedNodes));
|
|
|
|
|
}
|
2026-01-24 16:00:26 -08:00
|
|
|
});
|
2026-01-25 17:24:37 -08:00
|
|
|
return switches;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function findEffectiveSwitch(loc, assignedNodes) {
|
|
|
|
|
if (!loc) return null;
|
|
|
|
|
const switches = getSwitchesInLocation(loc, assignedNodes);
|
|
|
|
|
if (switches.length === 1) {
|
|
|
|
|
return switches[0];
|
|
|
|
|
}
|
|
|
|
|
if (loc.parent) {
|
|
|
|
|
return findEffectiveSwitch(loc.parent, assignedNodes);
|
|
|
|
|
}
|
|
|
|
|
return null;
|
2026-01-24 15:04:42 -08:00
|
|
|
}
|
|
|
|
|
|
2026-01-25 17:16:21 -08:00
|
|
|
function buildNodeIndex(locations, index) {
|
|
|
|
|
locations.forEach(loc => {
|
|
|
|
|
loc.nodeRefs.forEach(ref => {
|
|
|
|
|
index.set(ref, loc);
|
|
|
|
|
});
|
|
|
|
|
buildNodeIndex(loc.children, index);
|
2026-01-24 16:33:56 -08:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-25 17:16:21 -08:00
|
|
|
function findLocationForNode(node, nodeIndex) {
|
|
|
|
|
const identifiers = getNodeIdentifiers(node);
|
|
|
|
|
for (const ident of identifiers) {
|
|
|
|
|
if (nodeIndex.has(ident)) {
|
|
|
|
|
return nodeIndex.get(ident);
|
2026-01-24 21:24:05 -08:00
|
|
|
}
|
2026-01-25 17:16:21 -08:00
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2026-01-24 21:24:05 -08:00
|
|
|
|
2026-01-25 17:24:37 -08:00
|
|
|
function createNodeElement(node, switchConnection, nodeLocation) {
|
2026-01-25 17:16:21 -08:00
|
|
|
const div = document.createElement('div');
|
|
|
|
|
div.className = 'node' + (isSwitch(node) ? ' switch' : '');
|
2026-01-25 17:24:37 -08:00
|
|
|
|
|
|
|
|
if (!isSwitch(node) && switchConnection) {
|
|
|
|
|
const portEl = document.createElement('div');
|
|
|
|
|
portEl.className = 'switch-port';
|
|
|
|
|
if (switchConnection.external) {
|
|
|
|
|
portEl.classList.add('external');
|
|
|
|
|
portEl.textContent = switchConnection.switchName + ':' + switchConnection.port;
|
|
|
|
|
} else {
|
|
|
|
|
portEl.textContent = switchConnection.port;
|
|
|
|
|
}
|
|
|
|
|
div.appendChild(portEl);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const labelEl = document.createElement('span');
|
|
|
|
|
labelEl.textContent = getLabel(node);
|
|
|
|
|
div.appendChild(labelEl);
|
|
|
|
|
|
2026-01-25 17:16:21 -08:00
|
|
|
div.addEventListener('click', () => {
|
|
|
|
|
const json = JSON.stringify(node, null, 2);
|
|
|
|
|
navigator.clipboard.writeText(json).then(() => {
|
|
|
|
|
div.classList.add('copied');
|
|
|
|
|
setTimeout(() => div.classList.remove('copied'), 300);
|
2026-01-24 21:24:05 -08:00
|
|
|
});
|
|
|
|
|
});
|
2026-01-25 17:16:21 -08:00
|
|
|
return div;
|
2026-01-24 21:24:05 -08:00
|
|
|
}
|
|
|
|
|
|
2026-01-25 17:24:37 -08:00
|
|
|
function renderLocation(loc, assignedNodes, isTopLevel, switchConnections) {
|
2026-01-25 17:16:21 -08:00
|
|
|
const nodes = assignedNodes.get(loc) || [];
|
|
|
|
|
const hasNodes = nodes.length > 0;
|
2026-01-24 16:33:56 -08:00
|
|
|
|
2026-01-25 17:16:21 -08:00
|
|
|
const childElements = loc.children
|
2026-01-25 17:24:37 -08:00
|
|
|
.map(child => renderLocation(child, assignedNodes, false, switchConnections))
|
2026-01-25 17:16:21 -08:00
|
|
|
.filter(el => el !== null);
|
2026-01-24 16:33:56 -08:00
|
|
|
|
2026-01-25 17:16:21 -08:00
|
|
|
if (!hasNodes && childElements.length === 0) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2026-01-24 16:33:56 -08:00
|
|
|
|
2026-01-25 17:16:21 -08:00
|
|
|
const container = document.createElement('div');
|
|
|
|
|
let classes = 'location';
|
|
|
|
|
if (loc.anonymous) classes += ' anonymous';
|
|
|
|
|
if (isTopLevel) classes += ' top-level';
|
|
|
|
|
container.className = classes;
|
|
|
|
|
|
|
|
|
|
const nameEl = document.createElement('div');
|
|
|
|
|
nameEl.className = 'location-name';
|
|
|
|
|
nameEl.textContent = loc.name;
|
|
|
|
|
container.appendChild(nameEl);
|
|
|
|
|
|
|
|
|
|
if (hasNodes) {
|
|
|
|
|
const switches = nodes.filter(n => isSwitch(n));
|
|
|
|
|
const nonSwitches = nodes.filter(n => !isSwitch(n));
|
|
|
|
|
|
|
|
|
|
if (switches.length > 0) {
|
|
|
|
|
const switchRow = document.createElement('div');
|
|
|
|
|
switchRow.className = 'node-row';
|
|
|
|
|
switches.forEach(node => {
|
2026-01-25 17:24:37 -08:00
|
|
|
switchRow.appendChild(createNodeElement(node, null, loc));
|
2026-01-25 17:16:21 -08:00
|
|
|
});
|
|
|
|
|
container.appendChild(switchRow);
|
|
|
|
|
}
|
2026-01-24 21:24:05 -08:00
|
|
|
|
2026-01-25 17:16:21 -08:00
|
|
|
if (nonSwitches.length > 0) {
|
|
|
|
|
const nodeRow = document.createElement('div');
|
|
|
|
|
nodeRow.className = 'node-row';
|
|
|
|
|
nonSwitches.forEach(node => {
|
2026-01-25 17:24:37 -08:00
|
|
|
const conn = switchConnections.get(node.typeid);
|
|
|
|
|
nodeRow.appendChild(createNodeElement(node, conn, loc));
|
2026-01-25 17:16:21 -08:00
|
|
|
});
|
|
|
|
|
container.appendChild(nodeRow);
|
|
|
|
|
}
|
2026-01-24 21:24:05 -08:00
|
|
|
}
|
|
|
|
|
|
2026-01-25 17:16:21 -08:00
|
|
|
if (childElements.length > 0) {
|
|
|
|
|
const childrenContainer = document.createElement('div');
|
|
|
|
|
childrenContainer.className = 'children ' + loc.direction;
|
|
|
|
|
childElements.forEach(el => childrenContainer.appendChild(el));
|
|
|
|
|
container.appendChild(childrenContainer);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return container;
|
2026-01-24 14:26:38 -08:00
|
|
|
}
|
2026-01-24 11:22:35 -08:00
|
|
|
|
2026-01-24 14:26:38 -08:00
|
|
|
async function init() {
|
2026-01-24 21:24:05 -08:00
|
|
|
anonCounter = 0;
|
2026-01-24 15:04:42 -08:00
|
|
|
const [statusResp, configResp] = await Promise.all([
|
|
|
|
|
fetch('/api/status'),
|
|
|
|
|
fetch('/api/config')
|
|
|
|
|
]);
|
|
|
|
|
const data = await statusResp.json();
|
|
|
|
|
const config = await configResp.json();
|
2026-01-24 14:26:38 -08:00
|
|
|
|
|
|
|
|
const nodes = data.nodes || [];
|
|
|
|
|
const links = data.links || [];
|
|
|
|
|
|
|
|
|
|
document.getElementById('stats').textContent =
|
|
|
|
|
`${nodes.length} nodes, ${links.length} links`;
|
|
|
|
|
|
2026-01-25 17:16:21 -08:00
|
|
|
const locationTree = buildLocationTree(config.locations || [], null);
|
|
|
|
|
const nodeIndex = new Map();
|
|
|
|
|
buildNodeIndex(locationTree, nodeIndex);
|
2026-01-24 15:04:42 -08:00
|
|
|
|
2026-01-25 17:24:37 -08:00
|
|
|
const nodesByTypeId = new Map();
|
|
|
|
|
nodes.forEach(node => {
|
|
|
|
|
nodesByTypeId.set(node.typeid, node);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const nodeLocations = new Map();
|
2026-01-25 17:16:21 -08:00
|
|
|
const assignedNodes = new Map();
|
|
|
|
|
const unassignedNodes = [];
|
2026-01-24 14:26:38 -08:00
|
|
|
|
2026-01-25 17:16:21 -08:00
|
|
|
nodes.forEach(node => {
|
|
|
|
|
const loc = findLocationForNode(node, nodeIndex);
|
|
|
|
|
if (loc) {
|
2026-01-25 17:24:37 -08:00
|
|
|
nodeLocations.set(node.typeid, loc);
|
2026-01-25 17:16:21 -08:00
|
|
|
if (!assignedNodes.has(loc)) {
|
|
|
|
|
assignedNodes.set(loc, []);
|
2026-01-24 15:04:42 -08:00
|
|
|
}
|
2026-01-25 17:16:21 -08:00
|
|
|
assignedNodes.get(loc).push(node);
|
|
|
|
|
} else {
|
|
|
|
|
unassignedNodes.push(node);
|
2026-01-24 15:04:42 -08:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-25 17:24:37 -08:00
|
|
|
const switchConnections = new Map();
|
|
|
|
|
links.forEach(link => {
|
|
|
|
|
const nodeA = nodesByTypeId.get(link.node_a?.typeid);
|
|
|
|
|
const nodeB = nodesByTypeId.get(link.node_b?.typeid);
|
|
|
|
|
if (!nodeA || !nodeB) return;
|
|
|
|
|
|
|
|
|
|
const aIsSwitch = isSwitch(nodeA);
|
|
|
|
|
const bIsSwitch = isSwitch(nodeB);
|
|
|
|
|
|
|
|
|
|
if (aIsSwitch && !bIsSwitch) {
|
|
|
|
|
const nodeLoc = nodeLocations.get(nodeB.typeid);
|
|
|
|
|
const effectiveSwitch = findEffectiveSwitch(nodeLoc, assignedNodes);
|
|
|
|
|
switchConnections.set(nodeB.typeid, {
|
|
|
|
|
port: link.interface_a || '?',
|
|
|
|
|
switchName: getLabel(nodeA),
|
|
|
|
|
external: !effectiveSwitch || effectiveSwitch.typeid !== nodeA.typeid
|
|
|
|
|
});
|
|
|
|
|
} else if (bIsSwitch && !aIsSwitch) {
|
|
|
|
|
const nodeLoc = nodeLocations.get(nodeA.typeid);
|
|
|
|
|
const effectiveSwitch = findEffectiveSwitch(nodeLoc, assignedNodes);
|
|
|
|
|
switchConnections.set(nodeA.typeid, {
|
|
|
|
|
port: link.interface_b || '?',
|
|
|
|
|
switchName: getLabel(nodeB),
|
|
|
|
|
external: !effectiveSwitch || effectiveSwitch.typeid !== nodeB.typeid
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-25 17:16:21 -08:00
|
|
|
const container = document.getElementById('container');
|
|
|
|
|
container.innerHTML = '';
|
2026-01-24 15:04:42 -08:00
|
|
|
|
2026-01-25 17:16:21 -08:00
|
|
|
locationTree.forEach(loc => {
|
2026-01-25 17:24:37 -08:00
|
|
|
const el = renderLocation(loc, assignedNodes, true, switchConnections);
|
2026-01-25 17:16:21 -08:00
|
|
|
if (el) container.appendChild(el);
|
2026-01-24 15:04:42 -08:00
|
|
|
});
|
|
|
|
|
|
2026-01-25 17:16:21 -08:00
|
|
|
if (unassignedNodes.length > 0) {
|
|
|
|
|
const unassignedLoc = document.createElement('div');
|
|
|
|
|
unassignedLoc.className = 'location top-level';
|
2026-01-24 14:26:38 -08:00
|
|
|
|
2026-01-25 17:16:21 -08:00
|
|
|
const nameEl = document.createElement('div');
|
|
|
|
|
nameEl.className = 'location-name';
|
|
|
|
|
nameEl.textContent = 'Unassigned';
|
|
|
|
|
unassignedLoc.appendChild(nameEl);
|
2026-01-24 14:26:38 -08:00
|
|
|
|
2026-01-25 17:16:21 -08:00
|
|
|
const switches = unassignedNodes.filter(n => isSwitch(n));
|
|
|
|
|
const nonSwitches = unassignedNodes.filter(n => !isSwitch(n));
|
2026-01-24 11:22:35 -08:00
|
|
|
|
2026-01-25 17:16:21 -08:00
|
|
|
if (switches.length > 0) {
|
|
|
|
|
const switchRow = document.createElement('div');
|
|
|
|
|
switchRow.className = 'node-row';
|
|
|
|
|
switches.forEach(node => {
|
2026-01-25 17:24:37 -08:00
|
|
|
switchRow.appendChild(createNodeElement(node, null, null));
|
2026-01-25 11:28:56 -08:00
|
|
|
});
|
2026-01-25 17:16:21 -08:00
|
|
|
unassignedLoc.appendChild(switchRow);
|
2026-01-25 11:28:56 -08:00
|
|
|
}
|
|
|
|
|
|
2026-01-25 17:16:21 -08:00
|
|
|
if (nonSwitches.length > 0) {
|
|
|
|
|
const nodeRow = document.createElement('div');
|
|
|
|
|
nodeRow.className = 'node-row';
|
|
|
|
|
nonSwitches.forEach(node => {
|
2026-01-25 17:24:37 -08:00
|
|
|
const conn = switchConnections.get(node.typeid);
|
|
|
|
|
nodeRow.appendChild(createNodeElement(node, conn, null));
|
2026-01-25 11:28:56 -08:00
|
|
|
});
|
2026-01-25 17:16:21 -08:00
|
|
|
unassignedLoc.appendChild(nodeRow);
|
2026-01-25 11:28:56 -08:00
|
|
|
}
|
|
|
|
|
|
2026-01-25 17:16:21 -08:00
|
|
|
container.appendChild(unassignedLoc);
|
|
|
|
|
}
|
2026-01-24 11:22:35 -08:00
|
|
|
}
|
|
|
|
|
|
2026-01-24 14:26:38 -08:00
|
|
|
init().catch(e => {
|
|
|
|
|
document.getElementById('error').textContent = e.message;
|
|
|
|
|
});
|
2026-01-24 11:22:35 -08:00
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|