diff --git a/artmap.go b/artmap.go index f638424..665c2db 100644 --- a/artmap.go +++ b/artmap.go @@ -74,11 +74,25 @@ func (t *Tendrils) probeArtmap(ip net.IP) { log.Printf("[artmap] found ip=%s targets=%d mappings=%d", ip, len(cfg.Targets), len(cfg.Mappings)) } - t.processArtmapConfig(&cfg) + artmapNode := t.nodes.GetByIP(ip) + t.processArtmapConfig(&cfg, artmapNode) } -func (t *Tendrils) processArtmapConfig(cfg *artmapConfig) { +func (t *Tendrils) processArtmapConfig(cfg *artmapConfig, artmapNode *Node) { updated := false + + if artmapNode != nil && len(cfg.Mappings) > 0 { + mappings := make([]ArtmapMapping, len(cfg.Mappings)) + for i, m := range cfg.Mappings { + mappings[i] = ArtmapMapping{ + From: formatArtmapAddr(m.From), + To: formatArtmapToAddr(m.To), + } + } + t.nodes.UpdateArtmapMappings(artmapNode, mappings) + updated = true + } + for _, target := range cfg.Targets { ip := parseTargetIP(target.Address) if ip == nil { @@ -152,3 +166,32 @@ func parseTargetIP(addr string) net.IP { } return net.ParseIP(host) } + +func formatArtmapAddr(a artmapFromAddr) string { + u := formatArtmapUniverse(a.Universe) + if a.ChannelStart == 1 && a.ChannelEnd == 512 { + return u + } + if a.ChannelStart == a.ChannelEnd { + return fmt.Sprintf("%s:%d", u, a.ChannelStart) + } + return fmt.Sprintf("%s:%d-%d", u, a.ChannelStart, a.ChannelEnd) +} + +func formatArtmapToAddr(a artmapToAddr) string { + u := formatArtmapUniverse(a.Universe) + if a.ChannelStart == 1 { + return u + } + return fmt.Sprintf("%s:%d", u, a.ChannelStart) +} + +func formatArtmapUniverse(u artmapUniverse) string { + return fmt.Sprintf("%s:%d", u.Protocol, u.Number) +} + +func (n *Nodes) UpdateArtmapMappings(node *Node, mappings []ArtmapMapping) { + n.mu.Lock() + defer n.mu.Unlock() + node.ArtmapMappings = mappings +} diff --git a/static/js/flow.js b/static/js/flow.js index c81499e..93577ae 100644 --- a/static/js/flow.js +++ b/static/js/flow.js @@ -75,6 +75,7 @@ export function showFlowView(flowSpec) { const parts = flowSpec.split('/'); const protocol = parts[0]; let title = '', paths = [], error = ''; + let flowProtocol, flowUniverse; if (protocol === 'dante') { if (parts.includes('to')) { @@ -123,6 +124,8 @@ export function showFlowView(flowSpec) { const universe = parseInt(parts[1], 10); const sourceIdent = parts[2]; const protoName = protocol === 'sacn' ? 'sACN' : 'Art-Net'; + flowUniverse = universe; + flowProtocol = protocol; if (isNaN(universe)) { error = 'Invalid universe'; } else { const sourceIds = []; @@ -182,10 +185,10 @@ export function showFlowView(flowSpec) { error = 'Unknown protocol: ' + protocol; } - renderFlowOverlay(title, paths, error, nodesByTypeId); + renderFlowOverlay(title, paths, error, nodesByTypeId, flowProtocol, flowUniverse); } -export function renderFlowOverlay(title, paths, error, nodesByTypeId) { +export function renderFlowOverlay(title, paths, error, nodesByTypeId, flowProtocol, flowUniverse) { let overlay = document.getElementById('flow-overlay'); if (!overlay) { overlay = document.createElement('div'); @@ -224,7 +227,7 @@ export function renderFlowOverlay(title, paths, error, nodesByTypeId) { } if (paths.length === 1) { - const pathEl = renderFlowPath(paths[0], nodesByTypeId); + const pathEl = renderFlowPath(paths[0], nodesByTypeId, flowUniverse, flowProtocol); pathEl.addEventListener('click', (e) => e.stopPropagation()); overlay.appendChild(pathEl); } else { @@ -241,7 +244,7 @@ export function renderFlowOverlay(title, paths, error, nodesByTypeId) { : paths.length + ' flow paths (click to expand)'; }); paths.forEach(p => { - const pathEl = renderFlowPath(p, nodesByTypeId); + const pathEl = renderFlowPath(p, nodesByTypeId, flowUniverse, flowProtocol); pathEl.addEventListener('click', (e) => e.stopPropagation()); listEl.appendChild(pathEl); }); @@ -254,7 +257,7 @@ export function renderFlowOverlay(title, paths, error, nodesByTypeId) { } } -export function renderFlowPath(pathInfo, nodesByTypeId) { +export function renderFlowPath(pathInfo, nodesByTypeId, flowUniverse, flowProtocol) { const { path, sourceId, destId } = pathInfo; const container = document.createElement('div'); container.className = 'flow-path'; @@ -331,11 +334,43 @@ export function renderFlowPath(pathInfo, nodesByTypeId) { scrollToNode(step.nodeId); }); container.appendChild(nodeEl); + + if (node.artmap_mappings && node.artmap_mappings.length > 0 && flowUniverse !== undefined) { + const relevantMappings = getRelevantMappings(node.artmap_mappings, flowProtocol, flowUniverse); + if (relevantMappings.length > 0) { + const mappingsEl = document.createElement('div'); + mappingsEl.className = 'flow-artmap-mappings'; + relevantMappings.forEach(m => { + const mappingEl = document.createElement('div'); + mappingEl.className = 'artmap-mapping'; + mappingEl.textContent = m.from + ' → ' + m.to; + mappingEl.addEventListener('click', (e) => { + e.stopPropagation(); + const toProto = m.to.split(':')[0]; + const toUniverse = parseInt(m.to.split(':')[1], 10); + if (!isNaN(toUniverse)) { + openFlowHash(toProto, toUniverse); + } + }); + mappingsEl.appendChild(mappingEl); + }); + container.appendChild(mappingsEl); + } + } }); return container; } +function getRelevantMappings(mappings, protocol, universe) { + const prefix = protocol + ':' + universe; + return mappings.filter(m => { + const fromBase = m.from.split(':').slice(0, 2).join(':'); + const toBase = m.to.split(':').slice(0, 2).join(':'); + return fromBase === prefix || toBase === prefix; + }); +} + export function closeFlowView() { const overlay = document.getElementById('flow-overlay'); if (overlay) overlay.style.display = 'none'; diff --git a/static/style.css b/static/style.css index 9a92d7c..25e0ad7 100644 --- a/static/style.css +++ b/static/style.css @@ -847,6 +847,30 @@ body.sacn-mode .node.sacn-out.sacn-in .sacn-in-hover .sacn-detail-wrapper { display: flex; } +.flow-artmap-mappings { + display: flex; + flex-direction: column; + gap: 4px; + margin-left: 20px; + padding: 8px 12px; + background: #1a1a2e; + border-radius: 6px; + border-left: 3px solid #5a5aff; +} + +.artmap-mapping { + font-size: 11px; + color: #aaf; + cursor: pointer; + padding: 2px 4px; + border-radius: 3px; +} + +.artmap-mapping:hover { + background: #2a2a4e; + color: #ccf; +} + .node.has-error { box-shadow: 0 0 0 3px #f66; } diff --git a/types.go b/types.go index 703a7b3..7005d29 100644 --- a/types.go +++ b/types.go @@ -99,6 +99,11 @@ func (s SACNUniverseSet) MarshalJSON() ([]byte, error) { return json.Marshal(s.Universes()) } +type ArtmapMapping struct { + From string `json:"from"` + To string `json:"to"` +} + type MulticastGroupID int const ( @@ -452,6 +457,7 @@ type Node struct { ArtNetOutputs ArtNetUniverseSet `json:"artnet_outputs,omitempty"` SACNUnicastInputs SACNUniverseSet `json:"sacn_unicast_inputs,omitempty"` SACNOutputs SACNUniverseSet `json:"sacn_outputs,omitempty"` + ArtmapMappings []ArtmapMapping `json:"artmap_mappings,omitempty"` Unreachable bool `json:"unreachable,omitempty"` errors *ErrorTracker pollTrigger chan struct{}