diff --git a/http.go b/http.go index 1103899..ce2882d 100644 --- a/http.go +++ b/http.go @@ -29,6 +29,7 @@ type StatusResponse struct { Links []*Link `json:"links"` MulticastGroups []*MulticastGroupMembers `json:"multicast_groups"` ArtNetNodes []*ArtNetNode `json:"artnet_nodes"` + SACNNodes []*SACNNode `json:"sacn_nodes"` DanteFlows []*DanteFlow `json:"dante_flows"` PortErrors []*PortError `json:"port_errors"` UnreachableNodes []string `json:"unreachable_nodes"` @@ -143,6 +144,7 @@ func (t *Tendrils) GetStatus() *StatusResponse { Links: t.getLinks(), MulticastGroups: t.getMulticastGroups(), ArtNetNodes: t.getArtNetNodes(), + SACNNodes: t.getSACNNodes(), DanteFlows: t.getDanteFlows(), PortErrors: t.errors.GetErrors(), UnreachableNodes: t.errors.GetUnreachableNodes(), diff --git a/sacn.go b/sacn.go new file mode 100644 index 0000000..c155d4b --- /dev/null +++ b/sacn.go @@ -0,0 +1,63 @@ +package tendrils + +import ( + "fmt" + "sort" + "strings" + + "github.com/fvbommel/sortorder" +) + +type SACNNode struct { + TypeID string `json:"typeid"` + Node *Node `json:"node"` + Universes []int `json:"universes"` +} + +func (t *Tendrils) getSACNNodes() []*SACNNode { + t.nodes.mu.Lock() + t.nodes.expireMulticastMemberships() + t.nodes.mu.Unlock() + + t.nodes.mu.RLock() + defer t.nodes.mu.RUnlock() + + nodeUniverses := map[*Node][]int{} + + for _, gm := range t.nodes.multicastGroups { + if !strings.HasPrefix(gm.Group.Name, "sacn:") { + continue + } + + var universe int + if _, err := fmt.Sscanf(gm.Group.Name, "sacn:%d", &universe); err != nil { + continue + } + + for _, membership := range gm.Members { + if membership.Node == nil { + continue + } + universes := nodeUniverses[membership.Node] + if !containsInt(universes, universe) { + nodeUniverses[membership.Node] = append(universes, universe) + } + } + } + + result := make([]*SACNNode, 0, len(nodeUniverses)) + for node, universes := range nodeUniverses { + sort.Ints(universes) + result = append(result, &SACNNode{ + TypeID: newTypeID("sacnnode"), + Node: node, + Universes: universes, + }) + } + + sort.Slice(result, func(i, j int) bool { + return sortorder.NaturalLess(result[i].Node.DisplayName(), result[j].Node.DisplayName()) + }) + + return result +} diff --git a/static/index.html b/static/index.html index 02843a2..568734e 100644 --- a/static/index.html +++ b/static/index.html @@ -533,6 +533,90 @@ bottom: 100%; } + body.sacn-mode .node { + opacity: 0.3; + } + + body.sacn-mode .node.sacn-consumer { + opacity: 1; + background: #26d; + } + + body.sacn-mode .node .switch-port, + body.sacn-mode .node .uplink, + body.sacn-mode .node .root-label { + display: none; + } + + .node .sacn-info { + display: none; + position: absolute; + top: -8px; + left: 50%; + transform: translateX(-50%); + font-size: 10px; + font-weight: normal; + padding: 1px 6px; + border-radius: 8px; + white-space: nowrap; + background: #444; + color: #fff; + z-index: 10; + } + + .node .sacn-info .sacn-detail { + display: none; + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + margin-bottom: 4px; + font-size: 10px; + white-space: pre; + text-align: left; + background: #333; + border: 1px solid #555; + border-radius: 6px; + padding: 6px 8px; + line-height: 1.4; + } + + .node .sacn-info .sacn-detail::before { + content: ''; + position: absolute; + top: 100%; + left: 0; + right: 0; + height: 8px; + } + + .node .sacn-info::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 120px; + } + + .node .sacn-info:hover { + z-index: 100; + } + + .node .sacn-info:hover .sacn-detail { + display: block; + } + + body.sacn-mode .node.sacn-consumer .sacn-info { + display: block; + } + + .sacn-info .lbl, + .sacn-detail .lbl { + color: #888; + } + .node.has-error { box-shadow: 0 0 0 3px #f66; } @@ -590,10 +674,15 @@ display: none; } + body.sacn-mode .node:not(.sacn-consumer):hover .node-info { + display: none; + } + .node:has(.switch-port:hover) .node-info, .node:has(.uplink:hover) .node-info, .node:has(.dante-info:hover) .node-info, - .node:has(.artnet-info:hover) .node-info { + .node:has(.artnet-info:hover) .node-info, + .node:has(.sacn-info:hover) .node-info { display: none; } @@ -797,6 +886,7 @@ +
@@ -1034,7 +1124,7 @@ return null; } - function createNodeElement(node, switchConnection, nodeLocation, uplinkInfo, danteInfo, artnetInfo, hasError, isUnreachable) { + function createNodeElement(node, switchConnection, nodeLocation, uplinkInfo, danteInfo, artnetInfo, sacnInfo, hasError, isUnreachable) { const div = document.createElement('div'); div.className = 'node' + (isSwitch(node) ? ' switch' : ''); div.dataset.typeid = node.typeid; @@ -1051,6 +1141,10 @@ if (artnetInfo.isIn) div.classList.add('artnet-in'); } + if (sacnInfo && sacnInfo.isConsumer) { + div.classList.add('sacn-consumer'); + } + if (!isSwitch(node) && switchConnection) { const portEl = document.createElement('div'); portEl.className = 'switch-port'; @@ -1214,6 +1308,18 @@ div.appendChild(inEl); } + if (sacnInfo && sacnInfo.isConsumer) { + const sacnEl = document.createElement('div'); + sacnEl.className = 'sacn-info'; + sacnEl.innerHTML = ' ' + sacnInfo.universes[0]; + const detail = document.createElement('div'); + detail.className = 'sacn-detail'; + detail.innerHTML = sacnInfo.universes.map(u => ' ' + u).join('\n'); + detail.addEventListener('click', (e) => e.stopPropagation()); + sacnEl.appendChild(detail); + div.appendChild(sacnEl); + } + div.addEventListener('click', () => { const json = JSON.stringify(node, null, 2); navigator.clipboard.writeText(json).then(() => { @@ -1224,12 +1330,12 @@ return div; } - function renderLocation(loc, assignedNodes, isTopLevel, switchConnections, switchUplinks, danteNodes, artnetNodes, errorNodeIds, unreachableNodeIds) { + function renderLocation(loc, assignedNodes, isTopLevel, switchConnections, switchUplinks, danteNodes, artnetNodes, sacnNodes, errorNodeIds, unreachableNodeIds) { const nodes = assignedNodes.get(loc) || []; const hasNodes = nodes.length > 0; const childElements = loc.children - .map(child => renderLocation(child, assignedNodes, false, switchConnections, switchUplinks, danteNodes, artnetNodes, errorNodeIds, unreachableNodeIds)) + .map(child => renderLocation(child, assignedNodes, false, switchConnections, switchUplinks, danteNodes, artnetNodes, sacnNodes, errorNodeIds, unreachableNodeIds)) .filter(el => el !== null); if (!hasNodes && childElements.length === 0) { @@ -1258,9 +1364,10 @@ const uplink = switchUplinks.get(node.typeid); const danteInfo = danteNodes.get(node.typeid); const artnetInfo = artnetNodes.get(node.typeid); + const sacnInfo = sacnNodes.get(node.typeid); const hasError = errorNodeIds.has(node.typeid); const isUnreachable = unreachableNodeIds.has(node.typeid); - switchRow.appendChild(createNodeElement(node, null, loc, uplink, danteInfo, artnetInfo, hasError, isUnreachable)); + switchRow.appendChild(createNodeElement(node, null, loc, uplink, danteInfo, artnetInfo, sacnInfo, hasError, isUnreachable)); }); container.appendChild(switchRow); } @@ -1272,9 +1379,10 @@ const conn = switchConnections.get(node.typeid); const danteInfo = danteNodes.get(node.typeid); const artnetInfo = artnetNodes.get(node.typeid); + const sacnInfo = sacnNodes.get(node.typeid); const hasError = errorNodeIds.has(node.typeid); const isUnreachable = unreachableNodeIds.has(node.typeid); - nodeRow.appendChild(createNodeElement(node, conn, loc, null, danteInfo, artnetInfo, hasError, isUnreachable)); + nodeRow.appendChild(createNodeElement(node, conn, loc, null, danteInfo, artnetInfo, sacnInfo, hasError, isUnreachable)); }); container.appendChild(nodeRow); } @@ -1573,6 +1681,21 @@ }); }); + const sacnData = data.sacn_nodes || []; + const sacnNodes = new Map(); + + sacnData.forEach(sn => { + const nodeId = sn.node?.typeid; + if (!nodeId) return; + + const universes = (sn.universes || []).map(u => String(u)); + + sacnNodes.set(nodeId, { + isConsumer: universes.length > 0, + universes: universes + }); + }); + const switchUplinks = new Map(); if (allSwitches.length > 0 && switchLinks.length > 0) { const adjacency = new Map(); @@ -1663,7 +1786,7 @@ container.innerHTML = ''; locationTree.forEach(loc => { - const el = renderLocation(loc, assignedNodes, true, switchConnections, switchUplinks, danteNodes, artnetNodes, errorNodeIds, unreachableNodeIds); + const el = renderLocation(loc, assignedNodes, true, switchConnections, switchUplinks, danteNodes, artnetNodes, sacnNodes, errorNodeIds, unreachableNodeIds); if (el) container.appendChild(el); }); @@ -1686,9 +1809,10 @@ const uplink = switchUplinks.get(node.typeid); const danteInfo = danteNodes.get(node.typeid); const artnetInfo = artnetNodes.get(node.typeid); + const sacnInfo = sacnNodes.get(node.typeid); const hasError = errorNodeIds.has(node.typeid); const isUnreachable = unreachableNodeIds.has(node.typeid); - switchRow.appendChild(createNodeElement(node, null, null, uplink, danteInfo, artnetInfo, hasError, isUnreachable)); + switchRow.appendChild(createNodeElement(node, null, null, uplink, danteInfo, artnetInfo, sacnInfo, hasError, isUnreachable)); }); unassignedLoc.appendChild(switchRow); } @@ -1700,9 +1824,10 @@ const conn = switchConnections.get(node.typeid); const danteInfo = danteNodes.get(node.typeid); const artnetInfo = artnetNodes.get(node.typeid); + const sacnInfo = sacnNodes.get(node.typeid); const hasError = errorNodeIds.has(node.typeid); const isUnreachable = unreachableNodeIds.has(node.typeid); - nodeRow.appendChild(createNodeElement(node, conn, null, null, danteInfo, artnetInfo, hasError, isUnreachable)); + nodeRow.appendChild(createNodeElement(node, conn, null, null, danteInfo, artnetInfo, sacnInfo, hasError, isUnreachable)); }); unassignedLoc.appendChild(nodeRow); } @@ -1717,10 +1842,11 @@ connectSSE(); function setMode(mode) { - document.body.classList.remove('dante-mode', 'artnet-mode'); + document.body.classList.remove('dante-mode', 'artnet-mode', 'sacn-mode'); document.getElementById('mode-network').classList.remove('active'); document.getElementById('mode-dante').classList.remove('active'); document.getElementById('mode-artnet').classList.remove('active'); + document.getElementById('mode-sacn').classList.remove('active'); if (mode === 'dante') { document.body.classList.add('dante-mode'); @@ -1730,6 +1856,10 @@ document.body.classList.add('artnet-mode'); document.getElementById('mode-artnet').classList.add('active'); window.location.hash = 'artnet'; + } else if (mode === 'sacn') { + document.body.classList.add('sacn-mode'); + document.getElementById('mode-sacn').classList.add('active'); + window.location.hash = 'sacn'; } else { document.getElementById('mode-network').classList.add('active'); window.location.hash = ''; @@ -1739,6 +1869,7 @@ document.getElementById('mode-network').addEventListener('click', () => setMode('network')); document.getElementById('mode-dante').addEventListener('click', () => setMode('dante')); document.getElementById('mode-artnet').addEventListener('click', () => setMode('artnet')); + document.getElementById('mode-sacn').addEventListener('click', () => setMode('sacn')); document.getElementById('clear-all-errors').addEventListener('click', clearAllErrors); document.getElementById('toggle-errors').addEventListener('click', () => { @@ -1758,6 +1889,8 @@ setMode('dante'); } else if (window.location.hash === '#artnet') { setMode('artnet'); + } else if (window.location.hash === '#sacn') { + setMode('sacn'); }