Add TP-Link AP support with wireless client sub-locations
- Add NodeType enum (switch, ap, wireless_client, wired_client) - Poll SNMPv2c and SNMPv3 in parallel to win race with ping - Render APs with bordered sub-locations containing wireless clients - Fall back to parent interface stats when child lacks them - Log when unreachable nodes become reachable via merge Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { getLabel, getShortLabel, getFirstName, isSwitch, getSpeedClass } from './nodes.js';
|
||||
import { getLabel, getShortLabel, getFirstName, isSwitch, isAP, getSpeedClass } from './nodes.js';
|
||||
import { addClickableValue, buildLinkStats, buildDanteDetail, buildClickableList, removeNode } from './ui.js';
|
||||
import { nodeElements, locationElements, usedNodeIds, usedLocationIds } from './state.js';
|
||||
|
||||
@@ -20,7 +20,7 @@ export function createNodeElement(node, switchConnection, nodeLocation, uplinkIn
|
||||
}
|
||||
div._nodeData = node;
|
||||
|
||||
div.className = 'node' + (isSwitch(node) ? ' switch' : '');
|
||||
div.className = 'node' + (isSwitch(node) ? ' switch' : '') + (isAP(node) ? ' ap' : '');
|
||||
if (hasError) div.classList.add('has-error');
|
||||
if (isUnreachable) div.classList.add('unreachable');
|
||||
if (danteInfo?.isTx) div.classList.add('dante-tx');
|
||||
@@ -129,7 +129,7 @@ export function createNodeElement(node, switchConnection, nodeLocation, uplinkIn
|
||||
if (wrapper) wrapper.remove();
|
||||
}
|
||||
|
||||
if (isSwitch(node) && uplinkInfo === 'ROOT') {
|
||||
if ((isSwitch(node) || isAP(node)) && uplinkInfo === 'ROOT') {
|
||||
const container = div.querySelector(':scope > .uplink-hover');
|
||||
if (container) container.remove();
|
||||
|
||||
@@ -140,7 +140,7 @@ export function createNodeElement(node, switchConnection, nodeLocation, uplinkIn
|
||||
rootEl.textContent = 'ROOT';
|
||||
div.appendChild(rootEl);
|
||||
}
|
||||
} else if (isSwitch(node) && uplinkInfo) {
|
||||
} else if ((isSwitch(node) || isAP(node)) && uplinkInfo) {
|
||||
const rootEl = div.querySelector(':scope > .root-label');
|
||||
if (rootEl) rootEl.remove();
|
||||
|
||||
@@ -155,7 +155,9 @@ export function createNodeElement(node, switchConnection, nodeLocation, uplinkIn
|
||||
uplinkEl.className = 'uplink';
|
||||
const speedClass = getSpeedClass(uplinkInfo.speed);
|
||||
if (speedClass) uplinkEl.classList.add(speedClass);
|
||||
const uplinkLabel = uplinkInfo.localPort + ' → ' + uplinkInfo.parentName + ':' + uplinkInfo.remotePort;
|
||||
const uplinkLabel = isAP(node)
|
||||
? uplinkInfo.parentName + ':' + uplinkInfo.remotePort
|
||||
: uplinkInfo.localPort + ' → ' + uplinkInfo.parentName + ':' + uplinkInfo.remotePort;
|
||||
uplinkEl.textContent = uplinkLabel;
|
||||
|
||||
const statsEl = container.querySelector('.link-stats');
|
||||
@@ -348,7 +350,8 @@ export function renderLocation(loc, assignedNodes, isTopLevel, switchConnections
|
||||
locationElements.set(loc.id, container);
|
||||
}
|
||||
let classes = 'location';
|
||||
if (loc.anonymous) classes += ' anonymous';
|
||||
if (loc.anonymous && !loc.isAPLocation) classes += ' anonymous';
|
||||
if (loc.isAPLocation) classes += ' ap-location';
|
||||
if (isTopLevel) classes += ' top-level';
|
||||
container.className = classes;
|
||||
|
||||
@@ -364,8 +367,8 @@ export function renderLocation(loc, assignedNodes, isTopLevel, switchConnections
|
||||
const nodeRowId = loc.id + '_nd';
|
||||
|
||||
if (hasNodes) {
|
||||
const switches = nodes.filter(n => isSwitch(n));
|
||||
const nonSwitches = nodes.filter(n => !isSwitch(n));
|
||||
const switches = nodes.filter(n => isSwitch(n) || isAP(n));
|
||||
const nonSwitches = nodes.filter(n => !isSwitch(n) && !isAP(n));
|
||||
|
||||
if (switches.length > 0) {
|
||||
let switchRow = container.querySelector(':scope > .node-row[data-rowid="' + switchRowId + '"]');
|
||||
|
||||
@@ -59,7 +59,11 @@ export function getNodeIdentifiers(node) {
|
||||
}
|
||||
|
||||
export function isSwitch(node) {
|
||||
return !!(node.poe_budget);
|
||||
return node.type === 'switch';
|
||||
}
|
||||
|
||||
export function isAP(node) {
|
||||
return node.type === 'ap';
|
||||
}
|
||||
|
||||
export function getSpeedClass(speed) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getLabel, getShortLabel, isSwitch, getInterfaceSpeed, getInterfaceErrors, getInterfaceRates, getInterfaceUptime, getInterfaceLastError, getFirstName } from './nodes.js';
|
||||
import { getLabel, getShortLabel, isSwitch, isAP, getInterfaceSpeed, getInterfaceErrors, getInterfaceRates, getInterfaceUptime, getInterfaceLastError, getFirstName } from './nodes.js';
|
||||
import { buildSwitchUplinks, buildLocationTree, buildNodeIndex, findLocationForNode, findEffectiveSwitch } from './topology.js';
|
||||
import { formatUniverse } from './format.js';
|
||||
import { createNodeElement, renderLocation } from './components.js';
|
||||
@@ -48,17 +48,74 @@ export function render(data, config) {
|
||||
}
|
||||
});
|
||||
|
||||
const apClients = new Map();
|
||||
links.forEach(link => {
|
||||
const nodeA = nodesByTypeId.get(link.node_a_id);
|
||||
const nodeB = nodesByTypeId.get(link.node_b_id);
|
||||
if (!nodeA || !nodeB) return;
|
||||
|
||||
if (isAP(nodeA) && link.interface_a === 'wifi' && !isSwitch(nodeB) && !isAP(nodeB)) {
|
||||
if (!apClients.has(nodeA)) apClients.set(nodeA, []);
|
||||
apClients.get(nodeA).push(nodeB);
|
||||
} else if (isAP(nodeB) && link.interface_b === 'wifi' && !isSwitch(nodeA) && !isAP(nodeA)) {
|
||||
if (!apClients.has(nodeB)) apClients.set(nodeB, []);
|
||||
apClients.get(nodeB).push(nodeA);
|
||||
}
|
||||
});
|
||||
|
||||
apClients.forEach((clients, ap) => {
|
||||
const apLoc = nodeLocations.get(ap.id);
|
||||
const apSubLoc = {
|
||||
id: 'ap_' + ap.id,
|
||||
name: '',
|
||||
anonymous: true,
|
||||
isAPLocation: true,
|
||||
direction: 'horizontal',
|
||||
nodeRefs: [],
|
||||
parent: apLoc || null,
|
||||
children: []
|
||||
};
|
||||
|
||||
if (apLoc) {
|
||||
const apLocNodes = assignedNodes.get(apLoc) || [];
|
||||
const idx = apLocNodes.indexOf(ap);
|
||||
if (idx !== -1) apLocNodes.splice(idx, 1);
|
||||
apLoc.children.push(apSubLoc);
|
||||
} else {
|
||||
const idx = unassignedNodes.indexOf(ap);
|
||||
if (idx !== -1) unassignedNodes.splice(idx, 1);
|
||||
locationTree.push(apSubLoc);
|
||||
}
|
||||
|
||||
assignedNodes.set(apSubLoc, [ap]);
|
||||
nodeLocations.set(ap.id, apSubLoc);
|
||||
|
||||
clients.forEach(client => {
|
||||
const clientLoc = nodeLocations.get(client.id);
|
||||
if (clientLoc && clientLoc !== apSubLoc) {
|
||||
const clientLocNodes = assignedNodes.get(clientLoc) || [];
|
||||
const idx = clientLocNodes.indexOf(client);
|
||||
if (idx !== -1) clientLocNodes.splice(idx, 1);
|
||||
} else {
|
||||
const idx = unassignedNodes.indexOf(client);
|
||||
if (idx !== -1) unassignedNodes.splice(idx, 1);
|
||||
}
|
||||
assignedNodes.get(apSubLoc).push(client);
|
||||
nodeLocations.set(client.id, apSubLoc);
|
||||
});
|
||||
});
|
||||
|
||||
const switchConnections = new Map();
|
||||
const switchLinks = [];
|
||||
const allSwitches = nodes.filter(n => isSwitch(n));
|
||||
const allSwitches = nodes.filter(n => isSwitch(n) || isAP(n));
|
||||
|
||||
links.forEach(link => {
|
||||
const nodeA = nodesByTypeId.get(link.node_a_id);
|
||||
const nodeB = nodesByTypeId.get(link.node_b_id);
|
||||
if (!nodeA || !nodeB) return;
|
||||
|
||||
const aIsSwitch = isSwitch(nodeA);
|
||||
const bIsSwitch = isSwitch(nodeB);
|
||||
const aIsSwitch = isSwitch(nodeA) || isAP(nodeA);
|
||||
const bIsSwitch = isSwitch(nodeB) || isAP(nodeB);
|
||||
|
||||
if (aIsSwitch && bIsSwitch) {
|
||||
switchLinks.push({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getLabel, getNodeIdentifiers, isSwitch } from './nodes.js';
|
||||
import { getLabel, getNodeIdentifiers, isSwitch, isAP } from './nodes.js';
|
||||
import { incrementAnonCounter } from './state.js';
|
||||
|
||||
export function buildSwitchUplinks(allSwitches, switchLinks) {
|
||||
@@ -83,11 +83,11 @@ export function buildSwitchUplinks(allSwitches, switchLinks) {
|
||||
remotePort: reverseEdge?.remotePort || '?',
|
||||
parentNode: current,
|
||||
parentName: getLabel(current),
|
||||
speed: reverseEdge?.localSpeed || 0,
|
||||
errors: reverseEdge?.localErrors || null,
|
||||
rates: reverseEdge?.localRates || null,
|
||||
uptime: reverseEdge?.localUptime || 0,
|
||||
lastError: reverseEdge?.localLastError || null
|
||||
speed: reverseEdge?.localSpeed || edge.localSpeed || 0,
|
||||
errors: reverseEdge?.localErrors || edge.localErrors || null,
|
||||
rates: reverseEdge?.localRates || edge.localRates || null,
|
||||
uptime: reverseEdge?.localUptime || edge.localUptime || 0,
|
||||
lastError: reverseEdge?.localLastError || edge.localLastError || null
|
||||
});
|
||||
queue.push(edge.neighbor);
|
||||
}
|
||||
@@ -130,10 +130,10 @@ export function getSwitchesInLocation(loc, assignedNodes) {
|
||||
const switches = [];
|
||||
const nodes = assignedNodes.get(loc) || [];
|
||||
nodes.forEach(n => {
|
||||
if (isSwitch(n)) switches.push(n);
|
||||
if (isSwitch(n) || isAP(n)) switches.push(n);
|
||||
});
|
||||
loc.children.forEach(child => {
|
||||
if (child.anonymous) {
|
||||
if (child.anonymous || child.isAPLocation) {
|
||||
switches.push(...getSwitchesInLocation(child, assignedNodes));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -80,6 +80,15 @@ body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.location.ap-location {
|
||||
background: #1a2a2a;
|
||||
border: 1px solid #28a;
|
||||
}
|
||||
|
||||
.location.ap-location > .location-name {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.node-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -274,6 +283,11 @@ body.sacn-mode .node .sacn-hover {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.node.ap {
|
||||
background: #28a;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
|
||||
.node.copied {
|
||||
outline: 2px solid #fff;
|
||||
|
||||
Reference in New Issue
Block a user