Add sACN tab showing universe consumers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ian Gulliver
2026-01-26 13:37:21 -08:00
parent 4c6da837e9
commit 9bc44d4ee5
3 changed files with 208 additions and 10 deletions

View File

@@ -29,6 +29,7 @@ type StatusResponse struct {
Links []*Link `json:"links"` Links []*Link `json:"links"`
MulticastGroups []*MulticastGroupMembers `json:"multicast_groups"` MulticastGroups []*MulticastGroupMembers `json:"multicast_groups"`
ArtNetNodes []*ArtNetNode `json:"artnet_nodes"` ArtNetNodes []*ArtNetNode `json:"artnet_nodes"`
SACNNodes []*SACNNode `json:"sacn_nodes"`
DanteFlows []*DanteFlow `json:"dante_flows"` DanteFlows []*DanteFlow `json:"dante_flows"`
PortErrors []*PortError `json:"port_errors"` PortErrors []*PortError `json:"port_errors"`
UnreachableNodes []string `json:"unreachable_nodes"` UnreachableNodes []string `json:"unreachable_nodes"`
@@ -143,6 +144,7 @@ func (t *Tendrils) GetStatus() *StatusResponse {
Links: t.getLinks(), Links: t.getLinks(),
MulticastGroups: t.getMulticastGroups(), MulticastGroups: t.getMulticastGroups(),
ArtNetNodes: t.getArtNetNodes(), ArtNetNodes: t.getArtNetNodes(),
SACNNodes: t.getSACNNodes(),
DanteFlows: t.getDanteFlows(), DanteFlows: t.getDanteFlows(),
PortErrors: t.errors.GetErrors(), PortErrors: t.errors.GetErrors(),
UnreachableNodes: t.errors.GetUnreachableNodes(), UnreachableNodes: t.errors.GetUnreachableNodes(),

63
sacn.go Normal file
View File

@@ -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
}

View File

@@ -533,6 +533,90 @@
bottom: 100%; 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 { .node.has-error {
box-shadow: 0 0 0 3px #f66; box-shadow: 0 0 0 3px #f66;
} }
@@ -590,10 +674,15 @@
display: none; display: none;
} }
body.sacn-mode .node:not(.sacn-consumer):hover .node-info {
display: none;
}
.node:has(.switch-port:hover) .node-info, .node:has(.switch-port:hover) .node-info,
.node:has(.uplink:hover) .node-info, .node:has(.uplink:hover) .node-info,
.node:has(.dante-info: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; display: none;
} }
@@ -797,6 +886,7 @@
<button id="mode-network" class="active">Network</button> <button id="mode-network" class="active">Network</button>
<button id="mode-dante">Dante</button> <button id="mode-dante">Dante</button>
<button id="mode-artnet">Art-Net</button> <button id="mode-artnet">Art-Net</button>
<button id="mode-sacn">sACN</button>
</div> </div>
<div id="error-panel"> <div id="error-panel">
<div id="error-header"> <div id="error-header">
@@ -1034,7 +1124,7 @@
return null; 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'); const div = document.createElement('div');
div.className = 'node' + (isSwitch(node) ? ' switch' : ''); div.className = 'node' + (isSwitch(node) ? ' switch' : '');
div.dataset.typeid = node.typeid; div.dataset.typeid = node.typeid;
@@ -1051,6 +1141,10 @@
if (artnetInfo.isIn) div.classList.add('artnet-in'); if (artnetInfo.isIn) div.classList.add('artnet-in');
} }
if (sacnInfo && sacnInfo.isConsumer) {
div.classList.add('sacn-consumer');
}
if (!isSwitch(node) && switchConnection) { if (!isSwitch(node) && switchConnection) {
const portEl = document.createElement('div'); const portEl = document.createElement('div');
portEl.className = 'switch-port'; portEl.className = 'switch-port';
@@ -1214,6 +1308,18 @@
div.appendChild(inEl); div.appendChild(inEl);
} }
if (sacnInfo && sacnInfo.isConsumer) {
const sacnEl = document.createElement('div');
sacnEl.className = 'sacn-info';
sacnEl.innerHTML = '<span class="lbl">←</span> ' + sacnInfo.universes[0];
const detail = document.createElement('div');
detail.className = 'sacn-detail';
detail.innerHTML = sacnInfo.universes.map(u => '<span class="lbl">←</span> ' + u).join('\n');
detail.addEventListener('click', (e) => e.stopPropagation());
sacnEl.appendChild(detail);
div.appendChild(sacnEl);
}
div.addEventListener('click', () => { div.addEventListener('click', () => {
const json = JSON.stringify(node, null, 2); const json = JSON.stringify(node, null, 2);
navigator.clipboard.writeText(json).then(() => { navigator.clipboard.writeText(json).then(() => {
@@ -1224,12 +1330,12 @@
return div; 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 nodes = assignedNodes.get(loc) || [];
const hasNodes = nodes.length > 0; const hasNodes = nodes.length > 0;
const childElements = loc.children 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); .filter(el => el !== null);
if (!hasNodes && childElements.length === 0) { if (!hasNodes && childElements.length === 0) {
@@ -1258,9 +1364,10 @@
const uplink = switchUplinks.get(node.typeid); const uplink = switchUplinks.get(node.typeid);
const danteInfo = danteNodes.get(node.typeid); const danteInfo = danteNodes.get(node.typeid);
const artnetInfo = artnetNodes.get(node.typeid); const artnetInfo = artnetNodes.get(node.typeid);
const sacnInfo = sacnNodes.get(node.typeid);
const hasError = errorNodeIds.has(node.typeid); const hasError = errorNodeIds.has(node.typeid);
const isUnreachable = unreachableNodeIds.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); container.appendChild(switchRow);
} }
@@ -1272,9 +1379,10 @@
const conn = switchConnections.get(node.typeid); const conn = switchConnections.get(node.typeid);
const danteInfo = danteNodes.get(node.typeid); const danteInfo = danteNodes.get(node.typeid);
const artnetInfo = artnetNodes.get(node.typeid); const artnetInfo = artnetNodes.get(node.typeid);
const sacnInfo = sacnNodes.get(node.typeid);
const hasError = errorNodeIds.has(node.typeid); const hasError = errorNodeIds.has(node.typeid);
const isUnreachable = unreachableNodeIds.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); 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(); const switchUplinks = new Map();
if (allSwitches.length > 0 && switchLinks.length > 0) { if (allSwitches.length > 0 && switchLinks.length > 0) {
const adjacency = new Map(); const adjacency = new Map();
@@ -1663,7 +1786,7 @@
container.innerHTML = ''; container.innerHTML = '';
locationTree.forEach(loc => { 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); if (el) container.appendChild(el);
}); });
@@ -1686,9 +1809,10 @@
const uplink = switchUplinks.get(node.typeid); const uplink = switchUplinks.get(node.typeid);
const danteInfo = danteNodes.get(node.typeid); const danteInfo = danteNodes.get(node.typeid);
const artnetInfo = artnetNodes.get(node.typeid); const artnetInfo = artnetNodes.get(node.typeid);
const sacnInfo = sacnNodes.get(node.typeid);
const hasError = errorNodeIds.has(node.typeid); const hasError = errorNodeIds.has(node.typeid);
const isUnreachable = unreachableNodeIds.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); unassignedLoc.appendChild(switchRow);
} }
@@ -1700,9 +1824,10 @@
const conn = switchConnections.get(node.typeid); const conn = switchConnections.get(node.typeid);
const danteInfo = danteNodes.get(node.typeid); const danteInfo = danteNodes.get(node.typeid);
const artnetInfo = artnetNodes.get(node.typeid); const artnetInfo = artnetNodes.get(node.typeid);
const sacnInfo = sacnNodes.get(node.typeid);
const hasError = errorNodeIds.has(node.typeid); const hasError = errorNodeIds.has(node.typeid);
const isUnreachable = unreachableNodeIds.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); unassignedLoc.appendChild(nodeRow);
} }
@@ -1717,10 +1842,11 @@
connectSSE(); connectSSE();
function setMode(mode) { 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-network').classList.remove('active');
document.getElementById('mode-dante').classList.remove('active'); document.getElementById('mode-dante').classList.remove('active');
document.getElementById('mode-artnet').classList.remove('active'); document.getElementById('mode-artnet').classList.remove('active');
document.getElementById('mode-sacn').classList.remove('active');
if (mode === 'dante') { if (mode === 'dante') {
document.body.classList.add('dante-mode'); document.body.classList.add('dante-mode');
@@ -1730,6 +1856,10 @@
document.body.classList.add('artnet-mode'); document.body.classList.add('artnet-mode');
document.getElementById('mode-artnet').classList.add('active'); document.getElementById('mode-artnet').classList.add('active');
window.location.hash = 'artnet'; 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 { } else {
document.getElementById('mode-network').classList.add('active'); document.getElementById('mode-network').classList.add('active');
window.location.hash = ''; window.location.hash = '';
@@ -1739,6 +1869,7 @@
document.getElementById('mode-network').addEventListener('click', () => setMode('network')); document.getElementById('mode-network').addEventListener('click', () => setMode('network'));
document.getElementById('mode-dante').addEventListener('click', () => setMode('dante')); document.getElementById('mode-dante').addEventListener('click', () => setMode('dante'));
document.getElementById('mode-artnet').addEventListener('click', () => setMode('artnet')); 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('clear-all-errors').addEventListener('click', clearAllErrors);
document.getElementById('toggle-errors').addEventListener('click', () => { document.getElementById('toggle-errors').addEventListener('click', () => {
@@ -1758,6 +1889,8 @@
setMode('dante'); setMode('dante');
} else if (window.location.hash === '#artnet') { } else if (window.location.hash === '#artnet') {
setMode('artnet'); setMode('artnet');
} else if (window.location.hash === '#sacn') {
setMode('sacn');
} }
</script> </script>
</body> </body>