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:
@@ -194,7 +194,7 @@ locations:
|
|||||||
- names: ["showpi1"]
|
- names: ["showpi1"]
|
||||||
- names: ["showpi2"]
|
- names: ["showpi2"]
|
||||||
macs: ["d8:3a:dd:e3:5b:db"]
|
macs: ["d8:3a:dd:e3:5b:db"]
|
||||||
- names: ["AP"]
|
- names: ["EAP770"]
|
||||||
macs: ["a8:29:48:ca:11:40"]
|
macs: ["a8:29:48:ca:11:40"]
|
||||||
|
|
||||||
- name: Sound Control
|
- name: Sound Control
|
||||||
|
|||||||
7
link.go
7
link.go
@@ -82,6 +82,13 @@ func (n *Nodes) getDirectLinks() []*Link {
|
|||||||
key := makeLinkKey(lh.node, lh.port, target, targetIface)
|
key := makeLinkKey(lh.node, lh.port, target, targetIface)
|
||||||
if !seen[key] {
|
if !seen[key] {
|
||||||
seen[key] = true
|
seen[key] = true
|
||||||
|
if target.Type == "" {
|
||||||
|
if lh.node.Type == NodeTypeSwitch {
|
||||||
|
target.Type = NodeTypeWiredClient
|
||||||
|
} else if lh.node.Type == NodeTypeAP {
|
||||||
|
target.Type = NodeTypeWirelessClient
|
||||||
|
}
|
||||||
|
}
|
||||||
links = append(links, &Link{
|
links = append(links, &Link{
|
||||||
ID: newID("link"),
|
ID: newID("link"),
|
||||||
NodeA: lh.node,
|
NodeA: lh.node,
|
||||||
|
|||||||
23
nodes.go
23
nodes.go
@@ -388,6 +388,9 @@ func (n *Nodes) mergeNodes(keep, merge *Node) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
keepWasUnreachable := keep.Unreachable
|
||||||
|
mergeWasUnreachable := merge.Unreachable
|
||||||
|
|
||||||
for name := range merge.Names {
|
for name := range merge.Names {
|
||||||
if keep.Names == nil {
|
if keep.Names == nil {
|
||||||
keep.Names = NameSet{}
|
keep.Names = NameSet{}
|
||||||
@@ -429,6 +432,26 @@ func (n *Nodes) mergeNodes(keep, merge *Node) {
|
|||||||
merge.cancelFunc()
|
merge.cancelFunc()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasIPs := false
|
||||||
|
for _, iface := range keep.Interfaces {
|
||||||
|
if len(iface.IPs) > 0 {
|
||||||
|
hasIPs = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasIPs && (keepWasUnreachable || mergeWasUnreachable) {
|
||||||
|
keep.Unreachable = false
|
||||||
|
if n.t != nil && n.t.errors != nil {
|
||||||
|
n.t.errors.RemoveUnreachable(keep)
|
||||||
|
}
|
||||||
|
if keepWasUnreachable {
|
||||||
|
log.Printf("[merge] %s is now reachable (merged with node that has IPs)", keep.DisplayName())
|
||||||
|
}
|
||||||
|
if mergeWasUnreachable {
|
||||||
|
log.Printf("[merge] %s is now reachable (merged into %s)", merge.DisplayName(), keep.DisplayName())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
n.removeNode(merge)
|
n.removeNode(merge)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
120
snmp.go
120
snmp.go
@@ -6,6 +6,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gosnmp/gosnmp"
|
"github.com/gosnmp/gosnmp"
|
||||||
@@ -98,6 +99,23 @@ func (t *Tendrils) connectSNMP(ip net.IP) (*gosnmp.GoSNMP, error) {
|
|||||||
return snmp, nil
|
return snmp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *Tendrils) pollSNMP(node *Node, ip net.IP) {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
t.queryWirelessAP(node, ip)
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
t.querySNMPDevice(node, ip)
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
func (t *Tendrils) querySNMPDevice(node *Node, ip net.IP) {
|
func (t *Tendrils) querySNMPDevice(node *Node, ip net.IP) {
|
||||||
snmp, err := t.connectSNMP(ip)
|
snmp, err := t.connectSNMP(ip)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -339,6 +357,7 @@ func (t *Tendrils) queryPoEBudget(snmp *gosnmp.GoSNMP, node *Node) {
|
|||||||
if maxPower > 0 {
|
if maxPower > 0 {
|
||||||
t.nodes.mu.Lock()
|
t.nodes.mu.Lock()
|
||||||
node.PoEBudget = &PoEBudget{Power: power, MaxPower: maxPower}
|
node.PoEBudget = &PoEBudget{Power: power, MaxPower: maxPower}
|
||||||
|
node.Type = NodeTypeSwitch
|
||||||
t.nodes.mu.Unlock()
|
t.nodes.mu.Unlock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -650,3 +669,104 @@ func (t *Tendrils) queryDHCPBindings(snmp *gosnmp.GoSNMP) {
|
|||||||
t.nodes.Update(nil, mac, []net.IP{ip}, "", "", "dhcp")
|
t.nodes.Update(nil, mac, []net.IP{ip}, "", "", "dhcp")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *Tendrils) connectSNMPv2c(ip net.IP, community string) (*gosnmp.GoSNMP, error) {
|
||||||
|
snmp := &gosnmp.GoSNMP{
|
||||||
|
Target: ip.String(),
|
||||||
|
Port: 161,
|
||||||
|
Version: gosnmp.Version2c,
|
||||||
|
Community: community,
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
Retries: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := snmp.Connect()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return snmp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tendrils) queryWirelessAP(node *Node, ip net.IP) bool {
|
||||||
|
snmp, err := t.connectSNMPv2c(ip, "tendrils12!")
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer snmp.Conn.Close()
|
||||||
|
|
||||||
|
sysObjID := t.getSysObjectID(snmp)
|
||||||
|
if !strings.HasPrefix(sysObjID, "1.3.6.1.4.1.11863.") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
t.nodes.mu.Lock()
|
||||||
|
node.Type = NodeTypeAP
|
||||||
|
t.nodes.mu.Unlock()
|
||||||
|
|
||||||
|
ifNames := t.getInterfaceNames(snmp)
|
||||||
|
t.querySysName(snmp, node)
|
||||||
|
t.queryInterfaceMACs(snmp, node, ifNames)
|
||||||
|
t.queryTPLinkWirelessClients(snmp, node)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tendrils) getSysObjectID(snmp *gosnmp.GoSNMP) string {
|
||||||
|
oid := "1.3.6.1.2.1.1.2.0"
|
||||||
|
|
||||||
|
result, err := snmp.Get([]string{oid})
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Variables) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
variable := result.Variables[0]
|
||||||
|
if variable.Type != gosnmp.ObjectIdentifier {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimPrefix(variable.Value.(string), ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tendrils) queryTPLinkWirelessClients(snmp *gosnmp.GoSNMP, node *Node) {
|
||||||
|
clientMACOID := "1.3.6.1.4.1.11863.10.1.1.2.1.2"
|
||||||
|
|
||||||
|
results, err := snmp.BulkWalkAll(clientMACOID)
|
||||||
|
if err != nil {
|
||||||
|
if t.DebugSNMP {
|
||||||
|
log.Printf("[snmp] %s: tp-link client walk failed: %v", snmp.Target, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.nodes.ClearMACTable(node)
|
||||||
|
|
||||||
|
for _, result := range results {
|
||||||
|
if result.Type != gosnmp.OctetString {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
macText := string(result.Value.([]byte))
|
||||||
|
macText = strings.TrimRight(macText, "\x00")
|
||||||
|
macText = strings.ReplaceAll(macText, "-", ":")
|
||||||
|
mac, err := net.ParseMAC(macText)
|
||||||
|
if err != nil {
|
||||||
|
if t.DebugSNMP {
|
||||||
|
log.Printf("[snmp] %s: invalid wireless client mac %q: %v", snmp.Target, macText, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
t.nodes.UpdateMACTable(node, mac, "wifi")
|
||||||
|
|
||||||
|
if t.DebugSNMP {
|
||||||
|
log.Printf("[snmp] %s: wireless client mac=%s", snmp.Target, mac.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.NotifyUpdate()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 { addClickableValue, buildLinkStats, buildDanteDetail, buildClickableList, removeNode } from './ui.js';
|
||||||
import { nodeElements, locationElements, usedNodeIds, usedLocationIds } from './state.js';
|
import { nodeElements, locationElements, usedNodeIds, usedLocationIds } from './state.js';
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ export function createNodeElement(node, switchConnection, nodeLocation, uplinkIn
|
|||||||
}
|
}
|
||||||
div._nodeData = node;
|
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 (hasError) div.classList.add('has-error');
|
||||||
if (isUnreachable) div.classList.add('unreachable');
|
if (isUnreachable) div.classList.add('unreachable');
|
||||||
if (danteInfo?.isTx) div.classList.add('dante-tx');
|
if (danteInfo?.isTx) div.classList.add('dante-tx');
|
||||||
@@ -129,7 +129,7 @@ export function createNodeElement(node, switchConnection, nodeLocation, uplinkIn
|
|||||||
if (wrapper) wrapper.remove();
|
if (wrapper) wrapper.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSwitch(node) && uplinkInfo === 'ROOT') {
|
if ((isSwitch(node) || isAP(node)) && uplinkInfo === 'ROOT') {
|
||||||
const container = div.querySelector(':scope > .uplink-hover');
|
const container = div.querySelector(':scope > .uplink-hover');
|
||||||
if (container) container.remove();
|
if (container) container.remove();
|
||||||
|
|
||||||
@@ -140,7 +140,7 @@ export function createNodeElement(node, switchConnection, nodeLocation, uplinkIn
|
|||||||
rootEl.textContent = 'ROOT';
|
rootEl.textContent = 'ROOT';
|
||||||
div.appendChild(rootEl);
|
div.appendChild(rootEl);
|
||||||
}
|
}
|
||||||
} else if (isSwitch(node) && uplinkInfo) {
|
} else if ((isSwitch(node) || isAP(node)) && uplinkInfo) {
|
||||||
const rootEl = div.querySelector(':scope > .root-label');
|
const rootEl = div.querySelector(':scope > .root-label');
|
||||||
if (rootEl) rootEl.remove();
|
if (rootEl) rootEl.remove();
|
||||||
|
|
||||||
@@ -155,7 +155,9 @@ export function createNodeElement(node, switchConnection, nodeLocation, uplinkIn
|
|||||||
uplinkEl.className = 'uplink';
|
uplinkEl.className = 'uplink';
|
||||||
const speedClass = getSpeedClass(uplinkInfo.speed);
|
const speedClass = getSpeedClass(uplinkInfo.speed);
|
||||||
if (speedClass) uplinkEl.classList.add(speedClass);
|
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;
|
uplinkEl.textContent = uplinkLabel;
|
||||||
|
|
||||||
const statsEl = container.querySelector('.link-stats');
|
const statsEl = container.querySelector('.link-stats');
|
||||||
@@ -348,7 +350,8 @@ export function renderLocation(loc, assignedNodes, isTopLevel, switchConnections
|
|||||||
locationElements.set(loc.id, container);
|
locationElements.set(loc.id, container);
|
||||||
}
|
}
|
||||||
let classes = 'location';
|
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';
|
if (isTopLevel) classes += ' top-level';
|
||||||
container.className = classes;
|
container.className = classes;
|
||||||
|
|
||||||
@@ -364,8 +367,8 @@ export function renderLocation(loc, assignedNodes, isTopLevel, switchConnections
|
|||||||
const nodeRowId = loc.id + '_nd';
|
const nodeRowId = loc.id + '_nd';
|
||||||
|
|
||||||
if (hasNodes) {
|
if (hasNodes) {
|
||||||
const switches = nodes.filter(n => isSwitch(n));
|
const switches = nodes.filter(n => isSwitch(n) || isAP(n));
|
||||||
const nonSwitches = nodes.filter(n => !isSwitch(n));
|
const nonSwitches = nodes.filter(n => !isSwitch(n) && !isAP(n));
|
||||||
|
|
||||||
if (switches.length > 0) {
|
if (switches.length > 0) {
|
||||||
let switchRow = container.querySelector(':scope > .node-row[data-rowid="' + switchRowId + '"]');
|
let switchRow = container.querySelector(':scope > .node-row[data-rowid="' + switchRowId + '"]');
|
||||||
|
|||||||
@@ -59,7 +59,11 @@ export function getNodeIdentifiers(node) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isSwitch(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) {
|
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 { buildSwitchUplinks, buildLocationTree, buildNodeIndex, findLocationForNode, findEffectiveSwitch } from './topology.js';
|
||||||
import { formatUniverse } from './format.js';
|
import { formatUniverse } from './format.js';
|
||||||
import { createNodeElement, renderLocation } from './components.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 switchConnections = new Map();
|
||||||
const switchLinks = [];
|
const switchLinks = [];
|
||||||
const allSwitches = nodes.filter(n => isSwitch(n));
|
const allSwitches = nodes.filter(n => isSwitch(n) || isAP(n));
|
||||||
|
|
||||||
links.forEach(link => {
|
links.forEach(link => {
|
||||||
const nodeA = nodesByTypeId.get(link.node_a_id);
|
const nodeA = nodesByTypeId.get(link.node_a_id);
|
||||||
const nodeB = nodesByTypeId.get(link.node_b_id);
|
const nodeB = nodesByTypeId.get(link.node_b_id);
|
||||||
if (!nodeA || !nodeB) return;
|
if (!nodeA || !nodeB) return;
|
||||||
|
|
||||||
const aIsSwitch = isSwitch(nodeA);
|
const aIsSwitch = isSwitch(nodeA) || isAP(nodeA);
|
||||||
const bIsSwitch = isSwitch(nodeB);
|
const bIsSwitch = isSwitch(nodeB) || isAP(nodeB);
|
||||||
|
|
||||||
if (aIsSwitch && bIsSwitch) {
|
if (aIsSwitch && bIsSwitch) {
|
||||||
switchLinks.push({
|
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';
|
import { incrementAnonCounter } from './state.js';
|
||||||
|
|
||||||
export function buildSwitchUplinks(allSwitches, switchLinks) {
|
export function buildSwitchUplinks(allSwitches, switchLinks) {
|
||||||
@@ -83,11 +83,11 @@ export function buildSwitchUplinks(allSwitches, switchLinks) {
|
|||||||
remotePort: reverseEdge?.remotePort || '?',
|
remotePort: reverseEdge?.remotePort || '?',
|
||||||
parentNode: current,
|
parentNode: current,
|
||||||
parentName: getLabel(current),
|
parentName: getLabel(current),
|
||||||
speed: reverseEdge?.localSpeed || 0,
|
speed: reverseEdge?.localSpeed || edge.localSpeed || 0,
|
||||||
errors: reverseEdge?.localErrors || null,
|
errors: reverseEdge?.localErrors || edge.localErrors || null,
|
||||||
rates: reverseEdge?.localRates || null,
|
rates: reverseEdge?.localRates || edge.localRates || null,
|
||||||
uptime: reverseEdge?.localUptime || 0,
|
uptime: reverseEdge?.localUptime || edge.localUptime || 0,
|
||||||
lastError: reverseEdge?.localLastError || null
|
lastError: reverseEdge?.localLastError || edge.localLastError || null
|
||||||
});
|
});
|
||||||
queue.push(edge.neighbor);
|
queue.push(edge.neighbor);
|
||||||
}
|
}
|
||||||
@@ -130,10 +130,10 @@ export function getSwitchesInLocation(loc, assignedNodes) {
|
|||||||
const switches = [];
|
const switches = [];
|
||||||
const nodes = assignedNodes.get(loc) || [];
|
const nodes = assignedNodes.get(loc) || [];
|
||||||
nodes.forEach(n => {
|
nodes.forEach(n => {
|
||||||
if (isSwitch(n)) switches.push(n);
|
if (isSwitch(n) || isAP(n)) switches.push(n);
|
||||||
});
|
});
|
||||||
loc.children.forEach(child => {
|
loc.children.forEach(child => {
|
||||||
if (child.anonymous) {
|
if (child.anonymous || child.isAPLocation) {
|
||||||
switches.push(...getSwitchesInLocation(child, assignedNodes));
|
switches.push(...getSwitchesInLocation(child, assignedNodes));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -80,6 +80,15 @@ body {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.location.ap-location {
|
||||||
|
background: #1a2a2a;
|
||||||
|
border: 1px solid #28a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location.ap-location > .location-name {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.node-row {
|
.node-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -274,6 +283,11 @@ body.sacn-mode .node .sacn-hover {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.node.ap {
|
||||||
|
background: #28a;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.node.copied {
|
.node.copied {
|
||||||
outline: 2px solid #fff;
|
outline: 2px solid #fff;
|
||||||
|
|||||||
@@ -333,7 +333,7 @@ func (t *Tendrils) pollNode(node *Node) {
|
|||||||
|
|
||||||
if !t.DisableSNMP {
|
if !t.DisableSNMP {
|
||||||
for _, ip := range ips {
|
for _, ip := range ips {
|
||||||
t.querySNMPDevice(node, ip)
|
t.pollSNMP(node, ip)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
10
types.go
10
types.go
@@ -19,6 +19,15 @@ func newID(prefix string) string {
|
|||||||
return tid.String()
|
return tid.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NodeType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
NodeTypeSwitch NodeType = "switch"
|
||||||
|
NodeTypeAP NodeType = "ap"
|
||||||
|
NodeTypeWirelessClient NodeType = "wireless_client"
|
||||||
|
NodeTypeWiredClient NodeType = "wired_client"
|
||||||
|
)
|
||||||
|
|
||||||
type ArtNetUniverse int
|
type ArtNetUniverse int
|
||||||
|
|
||||||
func (u ArtNetUniverse) Net() int {
|
func (u ArtNetUniverse) Net() int {
|
||||||
@@ -464,6 +473,7 @@ type Node struct {
|
|||||||
Names NameSet `json:"names"`
|
Names NameSet `json:"names"`
|
||||||
Interfaces InterfaceMap `json:"interfaces"`
|
Interfaces InterfaceMap `json:"interfaces"`
|
||||||
MACTable map[string]string `json:"mac_table,omitempty"`
|
MACTable map[string]string `json:"mac_table,omitempty"`
|
||||||
|
Type NodeType `json:"type,omitempty"`
|
||||||
PoEBudget *PoEBudget `json:"poe_budget,omitempty"`
|
PoEBudget *PoEBudget `json:"poe_budget,omitempty"`
|
||||||
DanteTxChannels int `json:"dante_tx_channels,omitempty"`
|
DanteTxChannels int `json:"dante_tx_channels,omitempty"`
|
||||||
DanteClockMasterSeen time.Time `json:"-"`
|
DanteClockMasterSeen time.Time `json:"-"`
|
||||||
|
|||||||
Reference in New Issue
Block a user