From 068d3be46f8368498b45e2f72890cd2f8ea7c344 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Fri, 30 Jan 2026 11:00:07 -0800 Subject: [PATCH] Add flow view for visualizing network paths between protocol endpoints Co-Authored-By: Claude Opus 4.5 --- static/index.html | 624 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 585 insertions(+), 39 deletions(-) diff --git a/static/index.html b/static/index.html index 1c4a76c..2621ef8 100644 --- a/static/index.html +++ b/static/index.html @@ -720,6 +720,139 @@ color: #888; } + .flow-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.85); + z-index: 2000; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + padding: 40px; + overflow-y: auto; + } + + .flow-title { + font-size: 16px; + margin-bottom: 30px; + color: #aaa; + } + + .flow-path { + display: flex; + flex-direction: column; + align-items: center; + gap: 0; + } + + .flow-node { + background: #a6d; + padding: 10px 16px; + border-radius: 8px; + font-size: 12px; + text-align: center; + min-width: 120px; + cursor: pointer; + } + + .flow-node:hover { + filter: brightness(1.2); + } + + .flow-node.switch { + background: #2a2; + } + + .flow-node.source { + background: #d62; + } + + .flow-node.dest { + background: #26d; + } + + .flow-link { + display: flex; + flex-direction: row; + align-items: center; + padding: 4px 0; + gap: 10px; + } + + .flow-link .port-labels { + display: flex; + flex-direction: column; + align-items: flex-end; + font-size: 10px; + color: #888; + gap: 4px; + min-width: 40px; + } + + .flow-link .line { + width: 2px; + background: #666; + height: 24px; + } + + .flow-link .line.unknown { + background: repeating-linear-gradient( + 180deg, + #666 0px, + #666 4px, + transparent 4px, + transparent 8px + ); + } + + .flow-link .line.has-errors { + background: #f90; + } + + .flow-link .stats { + font-size: 9px; + color: #888; + text-align: left; + white-space: pre; + min-width: 40px; + } + + .flow-error { + color: #f99; + font-size: 14px; + padding: 20px; + } + + .flow-receivers { + margin-top: 30px; + max-height: 300px; + overflow-y: auto; + } + + .flow-receivers-summary { + color: #aaa; + margin-bottom: 10px; + cursor: pointer; + } + + .flow-receivers-summary:hover { + color: #fff; + } + + .flow-receiver-list { + display: none; + flex-direction: column; + gap: 20px; + } + + .flow-receiver-list.expanded { + display: flex; + } + .node.has-error { box-shadow: 0 0 0 3px #f66; } @@ -1265,11 +1398,29 @@ plainLines.push(plainFormat ? plainFormat(label, value) : label + ': ' + value); } - function buildClickableList(container, items, label, plainFormat) { + function buildClickableList(container, items, label, plainFormat, flowInfo) { const plainLines = []; items.forEach((item, idx) => { if (idx > 0) container.appendChild(document.createTextNode('\n')); - addClickableValue(container, label, item, plainLines, plainFormat); + const lbl = document.createElement('span'); + lbl.className = 'lbl'; + lbl.textContent = label; + container.appendChild(lbl); + container.appendChild(document.createTextNode(' ')); + const val = document.createElement('span'); + val.className = 'clickable-value'; + val.textContent = item; + val.style.cursor = 'pointer'; + val.addEventListener('click', (e) => { + e.stopPropagation(); + if (flowInfo && flowInfo.universes && flowInfo.universes[idx] !== undefined) { + openFlowHash(flowInfo.protocol, flowInfo.universes[idx], flowInfo.nodeId); + } else { + navigator.clipboard.writeText(item); + } + }); + container.appendChild(val); + plainLines.push(plainFormat ? plainFormat(label, item) : label + ': ' + item); }); container.addEventListener('click', (e) => { e.stopPropagation(); @@ -1298,9 +1449,10 @@ }); } - function buildDanteDetail(container, entries, arrow) { + function buildDanteDetail(container, entries, arrow, sourceNodeId, peerNodeIds) { const plainLines = []; entries.forEach((entry, entryIdx) => { + const peerNodeId = peerNodeIds ? peerNodeIds[entryIdx] : null; entry.split('\n').forEach((line, lineIdx) => { if (entryIdx > 0 && lineIdx === 0) { container.appendChild(document.createTextNode('\n\n')); @@ -1312,7 +1464,27 @@ container.appendChild(document.createTextNode(' ' + line.trim())); plainLines.push(' ' + line.trim()); } else { - addClickableValue(container, arrow, line, plainLines, (l, v) => l + ' ' + v); + const lbl = document.createElement('span'); + lbl.className = 'lbl'; + lbl.textContent = arrow; + container.appendChild(lbl); + container.appendChild(document.createTextNode(' ')); + const val = document.createElement('span'); + val.className = 'clickable-value'; + val.textContent = line; + val.style.cursor = 'pointer'; + val.addEventListener('click', (e) => { + e.stopPropagation(); + if (sourceNodeId && peerNodeId) { + const src = arrow === '→' ? sourceNodeId : peerNodeId; + const dst = arrow === '→' ? peerNodeId : sourceNodeId; + openFlowHash('dante', src, 'to', dst); + } else { + navigator.clipboard.writeText(line); + } + }); + container.appendChild(val); + plainLines.push(arrow + ' ' + line); } }); }); @@ -1593,7 +1765,7 @@ const detail = container.querySelector('.dante-detail'); detail.innerHTML = ''; - buildDanteDetail(detail, danteInfo.txTo, '→'); + buildDanteDetail(detail, danteInfo.txTo, '→', node.id, danteInfo.txToPeerIds); } else { const container = div.querySelector(':scope > .dante-tx-hover'); if (container) container.remove(); @@ -1615,7 +1787,7 @@ const detail = container.querySelector('.dante-detail'); detail.innerHTML = ''; - buildDanteDetail(detail, danteInfo.rxFrom, '←'); + buildDanteDetail(detail, danteInfo.rxFrom, '←', node.id, danteInfo.rxFromPeerIds); } else { const container = div.querySelector(':scope > .dante-rx-hover'); if (container) container.remove(); @@ -1638,7 +1810,8 @@ const detail = container.querySelector('.artnet-detail'); detail.innerHTML = ''; - buildClickableList(detail, artnetInfo.outputs.map(o => o.display), '←', (l, v) => l + ' ' + v); + buildClickableList(detail, artnetInfo.outputs.map(o => o.display), '←', (l, v) => l + ' ' + v, + { protocol: 'artnet', nodeId: node.id, universes: artnetInfo.outputs.map(o => o.universe) }); } else { const container = div.querySelector(':scope > .artnet-out-hover'); if (container) container.remove(); @@ -1661,7 +1834,8 @@ const detail = container.querySelector('.artnet-detail'); detail.innerHTML = ''; - buildClickableList(detail, artnetInfo.inputs.map(i => i.display), '→', (l, v) => l + ' ' + v); + buildClickableList(detail, artnetInfo.inputs.map(i => i.display), '→', (l, v) => l + ' ' + v, + { protocol: 'artnet', nodeId: node.id, universes: artnetInfo.inputs.map(i => i.universe) }); } else { const container = div.querySelector(':scope > .artnet-in-hover'); if (container) container.remove(); @@ -1684,7 +1858,8 @@ const detail = container.querySelector('.sacn-detail'); detail.innerHTML = ''; - buildClickableList(detail, sacnInfo.outputs.map(o => o.display), '←', (l, v) => l + ' ' + v); + buildClickableList(detail, sacnInfo.outputs.map(o => o.display), '←', (l, v) => l + ' ' + v, + { protocol: 'sacn', nodeId: node.id, universes: sacnInfo.outputs.map(o => o.universe) }); } else { const container = div.querySelector(':scope > .sacn-out-hover'); if (container) container.remove(); @@ -1707,7 +1882,8 @@ const detail = container.querySelector('.sacn-detail'); detail.innerHTML = ''; - buildClickableList(detail, sacnInfo.inputs.map(i => i.display), '→', (l, v) => l + ' ' + v); + buildClickableList(detail, sacnInfo.inputs.map(i => i.display), '→', (l, v) => l + ' ' + v, + { protocol: 'sacn', nodeId: node.id, universes: sacnInfo.inputs.map(i => i.universe) }); } else { const container = div.querySelector(':scope > .sacn-in-hover'); if (container) container.remove(); @@ -2081,30 +2257,32 @@ if (danteTx.length === 0 && danteRx.length === 0) return; - const txTo = danteTx.map(peer => { + const txEntries = danteTx.map(peer => { const peerNode = nodesByTypeId.get(peer.node_id); const peerName = peerNode ? getShortLabel(peerNode) : '??'; const channels = (peer.channels || []).map(formatDanteChannel); const channelSummary = channels.length > 0 ? '\n ' + channels.join('\n ') : ''; - return peerName + channelSummary; + return { text: peerName + channelSummary, peerId: peer.node_id }; }); - const rxFrom = danteRx.map(peer => { + const rxEntries = danteRx.map(peer => { const peerNode = nodesByTypeId.get(peer.node_id); const peerName = peerNode ? getShortLabel(peerNode) : '??'; const channels = (peer.channels || []).map(formatDanteChannel); const channelSummary = channels.length > 0 ? '\n ' + channels.join('\n ') : ''; - return peerName + channelSummary; + return { text: peerName + channelSummary, peerId: peer.node_id }; }); - txTo.sort((a, b) => a.split('\n')[0].localeCompare(b.split('\n')[0])); - rxFrom.sort((a, b) => a.split('\n')[0].localeCompare(b.split('\n')[0])); + txEntries.sort((a, b) => a.text.split('\n')[0].localeCompare(b.text.split('\n')[0])); + rxEntries.sort((a, b) => a.text.split('\n')[0].localeCompare(b.text.split('\n')[0])); danteNodes.set(nodeId, { isTx: danteTx.length > 0, isRx: danteRx.length > 0, - txTo: txTo, - rxFrom: rxFrom + txTo: txEntries.map(e => e.text), + txToPeerIds: txEntries.map(e => e.peerId), + rxFrom: rxEntries.map(e => e.text), + rxFromPeerIds: rxEntries.map(e => e.peerId) }); }); @@ -2145,21 +2323,24 @@ if (artnetInputs.length === 0 && artnetOutputs.length === 0) return; - const inputs = artnetInputs.slice().sort((a, b) => a - b).map(u => { + const sortedInputs = artnetInputs.slice().sort((a, b) => a - b); + const sortedOutputs = artnetOutputs.slice().sort((a, b) => a - b); + + const inputs = sortedInputs.map(u => { const sources = collapseNames(universeOutputs.get(u) || []); const uniStr = formatUniverse(u); if (sources.length > 0) { - return { display: sources[0] + ' [' + uniStr + ']', firstTarget: sources[0] }; + return { display: sources[0] + ' [' + uniStr + ']', firstTarget: sources[0], universe: u }; } - return { display: uniStr, firstTarget: null }; + return { display: uniStr, firstTarget: null, universe: u }; }); - const outputs = artnetOutputs.slice().sort((a, b) => a - b).map(u => { + const outputs = sortedOutputs.map(u => { const dests = collapseNames(universeInputs.get(u) || []); const uniStr = formatUniverse(u); if (dests.length > 0) { - return { display: dests[0] + ' [' + uniStr + ']', firstTarget: dests[0] }; + return { display: dests[0] + ' [' + uniStr + ']', firstTarget: dests[0], universe: u }; } - return { display: uniStr, firstTarget: null }; + return { display: uniStr, firstTarget: null, universe: u }; }); artnetNodes.set(nodeId, { @@ -2212,19 +2393,22 @@ if (sacnInputs.length === 0 && sacnOutputs.length === 0) return; - const inputs = sacnInputs.slice().sort((a, b) => a - b).map(u => { + const sortedSacnInputs = sacnInputs.slice().sort((a, b) => a - b); + const sortedSacnOutputs = sacnOutputs.slice().sort((a, b) => a - b); + + const inputs = sortedSacnInputs.map(u => { const sources = sacnCollapseNames(sacnUniverseOutputs.get(u) || []); if (sources.length > 0) { - return { display: sources[0] + ' [' + u + ']', firstTarget: sources[0] }; + return { display: sources[0] + ' [' + u + ']', firstTarget: sources[0], universe: u }; } - return { display: String(u), firstTarget: null }; + return { display: String(u), firstTarget: null, universe: u }; }); - const outputs = sacnOutputs.slice().sort((a, b) => a - b).map(u => { + const outputs = sortedSacnOutputs.map(u => { const dests = sacnCollapseNames(sacnUniverseInputs.get(u) || []); if (dests.length > 0) { - return { display: dests[0] + ' [' + u + ']', firstTarget: dests[0] }; + return { display: dests[0] + ' [' + u + ']', firstTarget: dests[0], universe: u }; } - return { display: String(u), firstTarget: null }; + return { display: String(u), firstTarget: null, universe: u }; }); sacnNodes.set(nodeId, { @@ -2329,9 +2513,14 @@ updateBroadcastStats(data.broadcast_stats); tableData = data; + flowViewData = data; if (currentView === 'table') { renderTable(); } + const hash = window.location.hash; + if (hash.startsWith('#flow/')) { + showFlowView(hash.slice(6)); + } } connectSSE(); @@ -2775,6 +2964,349 @@ return net + ':' + subnet + ':' + universe + ' (' + u + ')'; } + let flowViewData = null; + + function buildNetworkGraph(nodes, links) { + const graph = new Map(); + const nodesByTypeId = new Map(); + nodes.forEach(n => { + nodesByTypeId.set(n.id, n); + graph.set(n.id, []); + }); + links.forEach(link => { + const nodeA = nodesByTypeId.get(link.node_a_id); + const nodeB = nodesByTypeId.get(link.node_b_id); + if (!nodeA || !nodeB) return; + graph.get(link.node_a_id).push({ + nodeId: link.node_b_id, + viaInterface: link.interface_a, + fromInterface: link.interface_b + }); + graph.get(link.node_b_id).push({ + nodeId: link.node_a_id, + viaInterface: link.interface_b, + fromInterface: link.interface_a + }); + }); + return { graph, nodesByTypeId }; + } + + function findPath(graph, sourceId, destId) { + if (sourceId === destId) return [{ nodeId: sourceId }]; + const visited = new Set([sourceId]); + const queue = [[{ nodeId: sourceId }]]; + while (queue.length > 0) { + const path = queue.shift(); + const current = path[path.length - 1]; + const edges = graph.get(current.nodeId) || []; + for (const edge of edges) { + if (visited.has(edge.nodeId)) continue; + const newPath = [...path, edge]; + if (edge.nodeId === destId) return newPath; + visited.add(edge.nodeId); + queue.push(newPath); + } + } + return null; + } + + function resolveNodeId(identifier, nodes) { + const lower = identifier.toLowerCase(); + for (const node of nodes) { + if (node.id === identifier) return node.id; + if (node.names) { + for (const name of node.names) { + if (name.toLowerCase() === lower) return node.id; + } + } + } + return null; + } + + function showFlowView(flowSpec) { + if (!flowViewData) return; + const { nodes, links } = flowViewData; + const { graph, nodesByTypeId } = buildNetworkGraph(nodes, links); + + const parts = flowSpec.split('/'); + const protocol = parts[0]; + let title = '', paths = [], error = ''; + + if (protocol === 'dante') { + if (parts.includes('to')) { + const toIdx = parts.indexOf('to'); + const sourceIdent = parts.slice(1, toIdx).join('/'); + const destIdent = parts.slice(toIdx + 1).join('/'); + const sourceId = resolveNodeId(sourceIdent, nodes); + const destId = resolveNodeId(destIdent, nodes); + if (!sourceId) { error = 'Source node not found: ' + sourceIdent; } + else if (!destId) { error = 'Destination node not found: ' + destIdent; } + else { + const sourceNode = nodesByTypeId.get(sourceId); + const destNode = nodesByTypeId.get(destId); + title = 'Dante: ' + getShortLabel(sourceNode) + ' → ' + getShortLabel(destNode); + const path = findPath(graph, sourceId, destId); + if (path) paths.push({ path, sourceId, destId }); + else error = 'No path found between nodes'; + } + } else { + const sourceIdent = parts[1]; + const txChannel = parts[2]; + const sourceId = resolveNodeId(sourceIdent, nodes); + if (!sourceId) { error = 'Source node not found: ' + sourceIdent; } + else { + const sourceNode = nodesByTypeId.get(sourceId); + const danteTx = sourceNode.dante_flows?.tx || []; + title = 'Dante TX: ' + getShortLabel(sourceNode) + (txChannel ? ' ch ' + txChannel : ''); + const destIds = new Set(); + danteTx.forEach(peer => { + if (txChannel) { + const hasChannel = (peer.channels || []).some(ch => ch.tx_channel === txChannel); + if (hasChannel) destIds.add(peer.node_id); + } else { + destIds.add(peer.node_id); + } + }); + destIds.forEach(destId => { + const path = findPath(graph, sourceId, destId); + if (path) paths.push({ path, sourceId, destId }); + }); + if (paths.length === 0 && destIds.size > 0) error = 'No paths found to destinations'; + else if (destIds.size === 0) error = 'No active flows' + (txChannel ? ' for channel ' + txChannel : ''); + } + } + } else if (protocol === 'sacn' || protocol === 'artnet') { + const universe = parseInt(parts[1], 10); + const sourceIdent = parts[2]; + const protoName = protocol === 'sacn' ? 'sACN' : 'Art-Net'; + if (isNaN(universe)) { error = 'Invalid universe'; } + else { + const sourceIds = []; + const destIds = []; + nodes.forEach(node => { + if (protocol === 'sacn') { + if ((node.sacn_outputs || []).includes(universe)) sourceIds.push(node.id); + const groups = node.multicast_groups || []; + if (groups.some(g => g === 'sacn:' + universe)) destIds.push(node.id); + } else { + if ((node.artnet_outputs || []).includes(universe)) sourceIds.push(node.id); + if ((node.artnet_inputs || []).includes(universe)) destIds.push(node.id); + } + }); + if (sourceIdent) { + const clickedNodeId = resolveNodeId(sourceIdent, nodes); + if (!clickedNodeId) { error = 'Node not found: ' + sourceIdent; } + else { + const clickedNode = nodesByTypeId.get(clickedNodeId); + const isSource = sourceIds.includes(clickedNodeId); + const isDest = destIds.includes(clickedNodeId); + if (isSource) { + const destNames = destIds.filter(id => id !== clickedNodeId).map(id => getShortLabel(nodesByTypeId.get(id))).join(', '); + title = protoName + ' ' + universe + ': ' + getShortLabel(clickedNode) + ' → ' + (destNames || '?'); + destIds.forEach(destId => { + if (destId !== clickedNodeId) { + const path = findPath(graph, clickedNodeId, destId); + if (path) paths.push({ path, sourceId: clickedNodeId, destId }); + } + }); + } else if (isDest) { + const sourceNames = sourceIds.map(id => getShortLabel(nodesByTypeId.get(id))).join(', '); + title = protoName + ' ' + universe + ': ' + (sourceNames || '?') + ' → ' + getShortLabel(clickedNode); + sourceIds.forEach(sourceId => { + const path = findPath(graph, sourceId, clickedNodeId); + if (path) paths.push({ path, sourceId, destId: clickedNodeId }); + }); + } else { + error = 'Node is not a source or destination for universe ' + universe; + } + } + } else { + title = protoName + ' Universe ' + universe; + sourceIds.forEach(sourceId => { + destIds.forEach(destId => { + if (sourceId !== destId) { + const path = findPath(graph, sourceId, destId); + if (path) paths.push({ path, sourceId, destId }); + } + }); + }); + } + if (!error && paths.length === 0) error = 'No active flows for universe ' + universe; + } + } else { + error = 'Unknown protocol: ' + protocol; + } + + renderFlowOverlay(title, paths, error, nodesByTypeId); + } + + function renderFlowOverlay(title, paths, error, nodesByTypeId) { + let overlay = document.getElementById('flow-overlay'); + if (!overlay) { + overlay = document.createElement('div'); + overlay.id = 'flow-overlay'; + overlay.className = 'flow-overlay'; + overlay.addEventListener('click', (e) => { + if (e.target === overlay) closeFlowView(); + }); + document.body.appendChild(overlay); + } + overlay.innerHTML = ''; + overlay.style.display = 'flex'; + + const titleEl = document.createElement('div'); + titleEl.className = 'flow-title'; + titleEl.textContent = title; + titleEl.addEventListener('click', (e) => e.stopPropagation()); + overlay.appendChild(titleEl); + + if (error) { + const errEl = document.createElement('div'); + errEl.className = 'flow-error'; + errEl.textContent = error; + errEl.addEventListener('click', (e) => e.stopPropagation()); + overlay.appendChild(errEl); + return; + } + + if (paths.length === 0) { + const errEl = document.createElement('div'); + errEl.className = 'flow-error'; + errEl.textContent = 'No paths to display'; + errEl.addEventListener('click', (e) => e.stopPropagation()); + overlay.appendChild(errEl); + return; + } + + if (paths.length === 1) { + const pathEl = renderFlowPath(paths[0], nodesByTypeId); + pathEl.addEventListener('click', (e) => e.stopPropagation()); + overlay.appendChild(pathEl); + } else { + const summary = document.createElement('div'); + summary.className = 'flow-receivers-summary'; + summary.textContent = paths.length + ' flow paths (click to expand)'; + const listEl = document.createElement('div'); + listEl.className = 'flow-receiver-list'; + summary.addEventListener('click', (e) => { + e.stopPropagation(); + listEl.classList.toggle('expanded'); + summary.textContent = listEl.classList.contains('expanded') + ? paths.length + ' flow paths (click to collapse)' + : paths.length + ' flow paths (click to expand)'; + }); + paths.forEach(p => { + const pathEl = renderFlowPath(p, nodesByTypeId); + pathEl.addEventListener('click', (e) => e.stopPropagation()); + listEl.appendChild(pathEl); + }); + overlay.appendChild(summary); + overlay.appendChild(listEl); + if (paths.length <= 5) { + listEl.classList.add('expanded'); + summary.textContent = paths.length + ' flow paths (click to collapse)'; + } + } + } + + function renderFlowPath(pathInfo, nodesByTypeId) { + const { path, sourceId, destId } = pathInfo; + const container = document.createElement('div'); + container.className = 'flow-path'; + + path.forEach((step, idx) => { + const node = nodesByTypeId.get(step.nodeId); + if (!node) return; + + if (idx > 0) { + const linkEl = document.createElement('div'); + linkEl.className = 'flow-link'; + + const prevNode = nodesByTypeId.get(path[idx - 1].nodeId); + + const portLabels = document.createElement('div'); + portLabels.className = 'port-labels'; + const leftPort = document.createElement('span'); + leftPort.textContent = path[idx].viaInterface || '?'; + const rightPort = document.createElement('span'); + rightPort.textContent = path[idx].fromInterface || '?'; + portLabels.appendChild(leftPort); + portLabels.appendChild(rightPort); + linkEl.appendChild(portLabels); + + let iface = findInterface(prevNode, path[idx].viaInterface); + let flipped = false; + if (!iface?.stats) { + iface = findInterface(node, path[idx].fromInterface); + flipped = true; + } + + const line = document.createElement('div'); + line.className = 'line'; + if (!path[idx].viaInterface && !path[idx].fromInterface) line.classList.add('unknown'); + if (iface?.stats && ((iface.stats.in_errors || 0) > 0 || (iface.stats.out_errors || 0) > 0)) { + line.classList.add('has-errors'); + } + linkEl.appendChild(line); + + const stats = document.createElement('div'); + stats.className = 'stats'; + const statLines = []; + if (iface?.stats) { + const speed = iface.stats.speed; + const speedStr = speed >= 1e9 ? (speed/1e9)+'G' : speed >= 1e6 ? (speed/1e6)+'M' : '?'; + statLines.push(speedStr); + const inBytes = iface.stats.in_bytes_rate || 0; + const outBytes = iface.stats.out_bytes_rate || 0; + if (speed > 0 && (inBytes > 0 || outBytes > 0)) { + const inPct = ((inBytes * 8) / speed * 100).toFixed(0); + const outPct = ((outBytes * 8) / speed * 100).toFixed(0); + if (flipped) { + statLines.push('↓' + inPct + '% ↑' + outPct + '%'); + } else { + statLines.push('↓' + outPct + '% ↑' + inPct + '%'); + } + } + } + stats.textContent = statLines.join('\n'); + linkEl.appendChild(stats); + + container.appendChild(linkEl); + } + + const nodeEl = document.createElement('div'); + nodeEl.className = 'flow-node'; + if (isSwitch(node)) nodeEl.classList.add('switch'); + if (step.nodeId === sourceId && sourceId !== destId) nodeEl.classList.add('source'); + else if (step.nodeId === destId) nodeEl.classList.add('dest'); + nodeEl.textContent = getShortLabel(node); + nodeEl.addEventListener('click', (e) => { + e.stopPropagation(); + closeFlowView(); + scrollToNode(step.nodeId); + }); + container.appendChild(nodeEl); + }); + + return container; + } + + function closeFlowView() { + const overlay = document.getElementById('flow-overlay'); + if (overlay) overlay.style.display = 'none'; + const hash = window.location.hash; + if (hash.startsWith('#flow/')) { + let newHash = ''; + if (currentMode !== 'network') newHash = currentMode; + if (currentView === 'table') newHash += (newHash ? '-' : '') + 'table'; + history.pushState(null, '', window.location.pathname + window.location.search + (newHash ? '#' + newHash : '')); + } + } + + function openFlowHash(protocol, ...args) { + window.location.hash = 'flow/' + protocol + '/' + args.join('/'); + } + document.getElementById('clear-all-errors').addEventListener('click', clearAllErrors); document.getElementById('toggle-errors').addEventListener('click', () => { const panel = document.getElementById('error-panel'); @@ -2789,17 +3321,31 @@ } }); - const hash = window.location.hash.slice(1); - const hashParts = hash.split('-'); - const hashMode = hashParts[0]; - const hashView = hashParts.includes('table') ? 'table' : 'map'; + function parseHash() { + const hash = window.location.hash.slice(1); + if (hash.startsWith('flow/')) { + if (flowViewData) showFlowView(hash.slice(5)); + return; + } + closeFlowView(); + const hashParts = hash.split('-'); + const hashMode = hashParts[0]; + const hashView = hashParts.includes('table') ? 'table' : 'map'; - if (hashMode === 'dante' || hashMode === 'artnet' || hashMode === 'sacn') { - setMode(hashMode); - } - if (hashView === 'table') { - setView('table'); + if (hashMode === 'dante' || hashMode === 'artnet' || hashMode === 'sacn') { + setMode(hashMode); + } else if (currentMode !== 'network') { + setMode('network'); + } + if (hashView === 'table' && currentView !== 'table') { + setView('table'); + } else if (hashView !== 'table' && currentView === 'table') { + setView('map'); + } } + + window.addEventListener('hashchange', parseHash); + parseHash();