diff --git a/static/index.html b/static/index.html index 14e7c1a..fec15c3 100644 --- a/static/index.html +++ b/static/index.html @@ -4,1103 +4,7 @@ Tendrils Network - +
@@ -1143,2208 +47,6 @@
- + diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..4d3ccc3 --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,127 @@ +import { setConnectionStatus, setupErrorPanelListeners } from './ui.js'; +import { render } from './render.js'; +import { renderTable } from './table.js'; +import { showFlowView, closeFlowView } from './flow.js'; +import { + setCurrentConfig, setCurrentMode, setCurrentView, setTableSortKeys, + currentMode, currentView, flowViewData +} from './state.js'; + +let currentConfig = null; + +function connectSSE() { + const evtSource = new EventSource('/api/status/stream'); + let heartbeatTimeout = null; + + function resetHeartbeat() { + if (heartbeatTimeout) clearTimeout(heartbeatTimeout); + heartbeatTimeout = setTimeout(() => { + setConnectionStatus(false); + evtSource.close(); + setTimeout(connectSSE, 2000); + }, 10000); + } + + evtSource.addEventListener('status', (event) => { + resetHeartbeat(); + const data = JSON.parse(event.data); + currentConfig = data.config || {}; + setCurrentConfig(currentConfig); + render(data, currentConfig); + }); + + evtSource.onopen = () => { + setConnectionStatus(true); + resetHeartbeat(); + }; + + evtSource.onerror = () => { + if (heartbeatTimeout) clearTimeout(heartbeatTimeout); + setConnectionStatus(false); + evtSource.close(); + setTimeout(connectSSE, 2000); + }; +} + +function updateHash() { + let hash = ''; + if (currentMode !== 'network') hash = currentMode; + if (currentView === 'table') hash += (hash ? '-' : '') + 'table'; + window.location.hash = hash; +} + +function setMode(mode) { + setCurrentMode(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'); + document.getElementById('mode-dante').classList.add('active'); + } else if (mode === 'artnet') { + document.body.classList.add('artnet-mode'); + document.getElementById('mode-artnet').classList.add('active'); + } else if (mode === 'sacn') { + document.body.classList.add('sacn-mode'); + document.getElementById('mode-sacn').classList.add('active'); + } else { + document.getElementById('mode-network').classList.add('active'); + } + + updateHash(); + setTableSortKeys([]); + if (currentView === 'table') { + renderTable(); + } +} + +function setView(view) { + setCurrentView(view); + document.getElementById('view-map').classList.toggle('active', view === 'map'); + document.getElementById('view-table').classList.toggle('active', view === 'table'); + document.body.classList.toggle('table-view', view === 'table'); + updateHash(); + if (view === 'table') { + renderTable(); + } +} + +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); + } else if (currentMode !== 'network') { + setMode('network'); + } + if (hashView === 'table' && currentView !== 'table') { + setView('table'); + } else if (hashView !== 'table' && currentView === 'table') { + setView('map'); + } +} + +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('view-map').addEventListener('click', () => setView('map')); +document.getElementById('view-table').addEventListener('click', () => setView('table')); + +setupErrorPanelListeners(); + +window.addEventListener('hashchange', parseHash); +parseHash(); + +connectSSE(); diff --git a/static/js/components.js b/static/js/components.js new file mode 100644 index 0000000..9c94e0a --- /dev/null +++ b/static/js/components.js @@ -0,0 +1,430 @@ +import { getLabel, getShortLabel, isSwitch, getSpeedClass } from './nodes.js'; +import { addClickableValue, buildLinkStats, buildDanteDetail, buildClickableList } from './ui.js'; +import { nodeElements, locationElements, usedNodeIds, usedLocationIds } from './state.js'; + +export function createNodeElement(node, switchConnection, nodeLocation, uplinkInfo, danteInfo, artnetInfo, sacnInfo, hasError, isUnreachable) { + let div = nodeElements.get(node.id); + if (!div) { + div = document.createElement('div'); + div.dataset.id = node.id; + div.addEventListener('click', () => { + const nodeData = div._nodeData; + if (!nodeData) return; + let copyText = nodeData.names?.length > 0 ? nodeData.names.join('\n') : getLabel(nodeData); + navigator.clipboard.writeText(copyText).then(() => { + div.classList.add('copied'); + setTimeout(() => div.classList.remove('copied'), 300); + }); + }); + nodeElements.set(node.id, div); + } + div._nodeData = node; + + div.className = 'node' + (isSwitch(node) ? ' switch' : ''); + if (hasError) div.classList.add('has-error'); + if (isUnreachable) div.classList.add('unreachable'); + if (danteInfo?.isTx) div.classList.add('dante-tx'); + if (danteInfo?.isRx) div.classList.add('dante-rx'); + if (artnetInfo?.isOut) div.classList.add('artnet-out'); + if (artnetInfo?.isIn) div.classList.add('artnet-in'); + if (sacnInfo?.isOut) div.classList.add('sacn-out'); + if (sacnInfo?.isIn) div.classList.add('sacn-in'); + + if (!isSwitch(node) && switchConnection) { + let container = div.querySelector(':scope > .port-hover'); + if (!container) { + container = document.createElement('div'); + container.className = 'port-hover'; + container.innerHTML = '
'; + div.appendChild(container); + } + const portEl = container.querySelector('.switch-port'); + portEl.className = 'switch-port'; + if (switchConnection.external) portEl.classList.add('external'); + const speedClass = getSpeedClass(switchConnection.speed); + if (speedClass) portEl.classList.add(speedClass); + const portLabel = switchConnection.showSwitchName + ? switchConnection.switchName + ':' + switchConnection.port + : switchConnection.port; + portEl.textContent = portLabel; + + const statsEl = container.querySelector('.link-stats'); + statsEl.innerHTML = ''; + const errIn = switchConnection.errors?.in || 0; + const errOut = switchConnection.errors?.out || 0; + const r = switchConnection.rates; + buildLinkStats(statsEl, portLabel, switchConnection.speed, errIn, errOut, + r ? {rxBytes: r.outBytes, rxPkts: r.outPkts, txBytes: r.inBytes, txPkts: r.inPkts} : null); + } else { + const container = div.querySelector(':scope > .port-hover'); + if (container) container.remove(); + } + + let labelEl = div.querySelector(':scope > .node-label'); + if (!labelEl) { + labelEl = document.createElement('span'); + labelEl.className = 'node-label'; + div.appendChild(labelEl); + } + labelEl.innerHTML = ''; + if (node.names && node.names.length > 0) { + node.names.forEach((name, idx) => { + if (idx > 0) labelEl.appendChild(document.createTextNode('\n')); + const nameSpan = document.createElement('span'); + nameSpan.className = 'node-name'; + nameSpan.textContent = name; + nameSpan.addEventListener('click', (e) => { + e.stopPropagation(); + navigator.clipboard.writeText(name).then(() => { + div.classList.add('copied'); + setTimeout(() => div.classList.remove('copied'), 300); + }); + }); + labelEl.appendChild(nameSpan); + }); + } else { + labelEl.textContent = getLabel(node); + } + + const hasNodeInfo = node.interfaces && ( + node.interfaces.some(i => i.ips?.length > 0) || + node.interfaces.some(i => i.mac) + ); + if (hasNodeInfo) { + let wrapper = div.querySelector(':scope > .node-info-wrapper'); + if (!wrapper) { + wrapper = document.createElement('div'); + wrapper.className = 'node-info-wrapper'; + wrapper.innerHTML = '
'; + div.appendChild(wrapper); + } + const nodeInfo = wrapper.querySelector('.node-info'); + nodeInfo.innerHTML = ''; + const ips = []; + const macs = []; + node.interfaces.forEach(iface => { + if (iface.ips) iface.ips.forEach(ip => { if (!ips.includes(ip)) ips.push(ip); }); + if (iface.mac && !macs.includes(iface.mac)) macs.push(iface.mac); + }); + ips.sort(); + macs.sort(); + const plainLines = []; + ips.forEach((ip, idx) => { + if (idx > 0) nodeInfo.appendChild(document.createTextNode('\n')); + addClickableValue(nodeInfo, 'IP', ip, plainLines); + }); + macs.forEach((mac, idx) => { + if (ips.length > 0 || idx > 0) nodeInfo.appendChild(document.createTextNode('\n')); + addClickableValue(nodeInfo, 'MAC', mac, plainLines); + }); + if (plainLines.length > 0) { + nodeInfo.onclick = (e) => { + e.stopPropagation(); + navigator.clipboard.writeText(plainLines.join('\n')); + }; + } + } else { + const wrapper = div.querySelector(':scope > .node-info-wrapper'); + if (wrapper) wrapper.remove(); + } + + if (isSwitch(node) && uplinkInfo === 'ROOT') { + const container = div.querySelector(':scope > .uplink-hover'); + if (container) container.remove(); + + let rootEl = div.querySelector(':scope > .root-label'); + if (!rootEl) { + rootEl = document.createElement('div'); + rootEl.className = 'root-label'; + rootEl.textContent = 'ROOT'; + div.appendChild(rootEl); + } + } else if (isSwitch(node) && uplinkInfo) { + const rootEl = div.querySelector(':scope > .root-label'); + if (rootEl) rootEl.remove(); + + let container = div.querySelector(':scope > .uplink-hover'); + if (!container) { + container = document.createElement('div'); + container.className = 'uplink-hover'; + container.innerHTML = ''; + div.appendChild(container); + } + const uplinkEl = container.querySelector('.uplink'); + uplinkEl.className = 'uplink'; + const speedClass = getSpeedClass(uplinkInfo.speed); + if (speedClass) uplinkEl.classList.add(speedClass); + const uplinkLabel = uplinkInfo.localPort + ' → ' + uplinkInfo.parentName + ':' + uplinkInfo.remotePort; + uplinkEl.textContent = uplinkLabel; + + const statsEl = container.querySelector('.link-stats'); + statsEl.innerHTML = ''; + const errIn = uplinkInfo.errors?.in || 0; + const errOut = uplinkInfo.errors?.out || 0; + const r = uplinkInfo.rates; + buildLinkStats(statsEl, uplinkLabel, uplinkInfo.speed, errIn, errOut, + r ? {rxBytes: r.inBytes, rxPkts: r.inPkts, txBytes: r.outBytes, txPkts: r.outPkts} : null); + } else { + const rootEl = div.querySelector(':scope > .root-label'); + if (rootEl) rootEl.remove(); + const container = div.querySelector(':scope > .uplink-hover'); + if (container) container.remove(); + } + + if (danteInfo?.isTx) { + let container = div.querySelector(':scope > .dante-tx-hover'); + if (!container) { + container = document.createElement('div'); + container.className = 'dante-hover dante-tx-hover'; + container.innerHTML = '
'; + div.appendChild(container); + } + const textEl = container.querySelector('.dante-pill-text'); + const firstDest = danteInfo.txTo[0].split('\n')[0]; + const txMore = danteInfo.txTo.length > 1 ? ', ...' : ''; + textEl.textContent = firstDest + txMore; + + const detail = container.querySelector('.dante-detail'); + detail.innerHTML = ''; + buildDanteDetail(detail, danteInfo.txTo, '→', node.id, danteInfo.txToPeerIds); + } else { + const container = div.querySelector(':scope > .dante-tx-hover'); + if (container) container.remove(); + } + + if (danteInfo?.isRx) { + let container = div.querySelector(':scope > .dante-rx-hover'); + if (!container) { + container = document.createElement('div'); + container.className = 'dante-hover dante-rx-hover'; + container.innerHTML = '
'; + div.appendChild(container); + } + const textEl = container.querySelector('.dante-pill-text'); + const firstSource = danteInfo.rxFrom[0].split('\n')[0]; + const rxMore = danteInfo.rxFrom.length > 1 ? ', ...' : ''; + textEl.textContent = firstSource + rxMore; + + const detail = container.querySelector('.dante-detail'); + detail.innerHTML = ''; + buildDanteDetail(detail, danteInfo.rxFrom, '←', node.id, danteInfo.rxFromPeerIds); + } else { + const container = div.querySelector(':scope > .dante-rx-hover'); + if (container) container.remove(); + } + + if (artnetInfo?.isOut) { + let container = div.querySelector(':scope > .artnet-out-hover'); + if (!container) { + container = document.createElement('div'); + container.className = 'artnet-hover artnet-out-hover'; + container.innerHTML = '
'; + div.appendChild(container); + } + const textEl = container.querySelector('.artnet-pill-text'); + const firstOut = artnetInfo.outputs[0]; + const outLabel = firstOut.firstTarget || firstOut.display; + const outMore = artnetInfo.outputs.length > 1 ? ', ...' : ''; + textEl.textContent = outLabel + outMore; + + const detail = container.querySelector('.artnet-detail'); + detail.innerHTML = ''; + 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(); + } + + if (artnetInfo?.isIn) { + let container = div.querySelector(':scope > .artnet-in-hover'); + if (!container) { + container = document.createElement('div'); + container.className = 'artnet-hover artnet-in-hover'; + container.innerHTML = '
'; + div.appendChild(container); + } + const textEl = container.querySelector('.artnet-pill-text'); + const firstIn = artnetInfo.inputs[0]; + const inLabel = firstIn.firstTarget || firstIn.display; + const inMore = artnetInfo.inputs.length > 1 ? ', ...' : ''; + textEl.textContent = inLabel + inMore; + + const detail = container.querySelector('.artnet-detail'); + detail.innerHTML = ''; + 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(); + } + + if (sacnInfo?.isOut) { + let container = div.querySelector(':scope > .sacn-out-hover'); + if (!container) { + container = document.createElement('div'); + container.className = 'sacn-hover sacn-out-hover'; + container.innerHTML = '
'; + div.appendChild(container); + } + const textEl = container.querySelector('.sacn-pill-text'); + const firstOut = sacnInfo.outputs[0]; + const outLabel = firstOut.firstTarget || firstOut.display; + const outMore = sacnInfo.outputs.length > 1 ? ', ...' : ''; + textEl.textContent = outLabel + outMore; + + const detail = container.querySelector('.sacn-detail'); + detail.innerHTML = ''; + 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(); + } + + if (sacnInfo?.isIn) { + let container = div.querySelector(':scope > .sacn-in-hover'); + if (!container) { + container = document.createElement('div'); + container.className = 'sacn-hover sacn-in-hover'; + container.innerHTML = '
'; + div.appendChild(container); + } + const textEl = container.querySelector('.sacn-pill-text'); + const firstIn = sacnInfo.inputs[0]; + const inLabel = firstIn.firstTarget || firstIn.display; + const inMore = sacnInfo.inputs.length > 1 ? ', ...' : ''; + textEl.textContent = inLabel + inMore; + + const detail = container.querySelector('.sacn-detail'); + detail.innerHTML = ''; + 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(); + } + + return div; +} + +export function renderLocation(loc, assignedNodes, isTopLevel, switchConnections, switchUplinks, danteNodes, artnetNodes, sacnNodes, errorNodeIds, unreachableNodeIds, usedNodeIdsSet, usedLocationIdsSet) { + const nodes = assignedNodes.get(loc) || []; + const hasNodes = nodes.length > 0; + + const childElements = loc.children + .map(child => renderLocation(child, assignedNodes, false, switchConnections, switchUplinks, danteNodes, artnetNodes, sacnNodes, errorNodeIds, unreachableNodeIds, usedNodeIdsSet, usedLocationIdsSet)) + .filter(el => el !== null); + + if (!hasNodes && childElements.length === 0) { + return null; + } + + usedLocationIdsSet.add(loc.id); + let container = locationElements.get(loc.id); + if (!container) { + container = document.createElement('div'); + container.dataset.locid = loc.id; + locationElements.set(loc.id, container); + } + let classes = 'location'; + if (loc.anonymous) classes += ' anonymous'; + if (isTopLevel) classes += ' top-level'; + container.className = classes; + + let nameEl = container.querySelector(':scope > .location-name'); + if (!nameEl) { + nameEl = document.createElement('div'); + nameEl.className = 'location-name'; + container.insertBefore(nameEl, container.firstChild); + } + nameEl.textContent = loc.name; + + const switchRowId = loc.id + '_sw'; + const nodeRowId = loc.id + '_nd'; + + if (hasNodes) { + const switches = nodes.filter(n => isSwitch(n)); + const nonSwitches = nodes.filter(n => !isSwitch(n)); + + if (switches.length > 0) { + let switchRow = container.querySelector(':scope > .node-row[data-rowid="' + switchRowId + '"]'); + if (!switchRow) { + switchRow = document.createElement('div'); + switchRow.className = 'node-row'; + switchRow.dataset.rowid = switchRowId; + const insertPt = container.querySelector(':scope > .node-row, :scope > .children'); + container.insertBefore(switchRow, insertPt); + } + const currentIds = new Set(switches.map(n => n.id)); + Array.from(switchRow.children).forEach(ch => { + if (!currentIds.has(ch.dataset.id)) ch.remove(); + }); + switches.forEach(node => { + usedNodeIdsSet.add(node.id); + const uplink = switchUplinks.get(node.id); + const danteInfo = danteNodes.get(node.id); + const artnetInfo = artnetNodes.get(node.id); + const sacnInfo = sacnNodes.get(node.id); + const hasError = errorNodeIds.has(node.id); + const isUnreachable = unreachableNodeIds.has(node.id); + const el = createNodeElement(node, null, loc, uplink, danteInfo, artnetInfo, sacnInfo, hasError, isUnreachable); + if (el.parentNode !== switchRow) switchRow.appendChild(el); + }); + } else { + const switchRow = container.querySelector(':scope > .node-row[data-rowid="' + switchRowId + '"]'); + if (switchRow) switchRow.remove(); + } + + if (nonSwitches.length > 0) { + let nodeRow = container.querySelector(':scope > .node-row[data-rowid="' + nodeRowId + '"]'); + if (!nodeRow) { + nodeRow = document.createElement('div'); + nodeRow.className = 'node-row'; + nodeRow.dataset.rowid = nodeRowId; + const insertPt = container.querySelector(':scope > .children'); + container.insertBefore(nodeRow, insertPt); + } + const currentIds = new Set(nonSwitches.map(n => n.id)); + Array.from(nodeRow.children).forEach(ch => { + if (!currentIds.has(ch.dataset.id)) ch.remove(); + }); + nonSwitches.forEach(node => { + usedNodeIdsSet.add(node.id); + const conn = switchConnections.get(node.id); + const danteInfo = danteNodes.get(node.id); + const artnetInfo = artnetNodes.get(node.id); + const sacnInfo = sacnNodes.get(node.id); + const hasError = errorNodeIds.has(node.id); + const isUnreachable = unreachableNodeIds.has(node.id); + const el = createNodeElement(node, conn, loc, null, danteInfo, artnetInfo, sacnInfo, hasError, isUnreachable); + if (el.parentNode !== nodeRow) nodeRow.appendChild(el); + }); + } else { + const nodeRow = container.querySelector(':scope > .node-row[data-rowid="' + nodeRowId + '"]'); + if (nodeRow) nodeRow.remove(); + } + } else { + const switchRow = container.querySelector(':scope > .node-row[data-rowid="' + switchRowId + '"]'); + if (switchRow) switchRow.remove(); + const nodeRow = container.querySelector(':scope > .node-row[data-rowid="' + nodeRowId + '"]'); + if (nodeRow) nodeRow.remove(); + } + + if (childElements.length > 0) { + let childrenContainer = container.querySelector(':scope > .children'); + if (!childrenContainer) { + childrenContainer = document.createElement('div'); + container.appendChild(childrenContainer); + } + childrenContainer.className = 'children ' + loc.direction; + childElements.forEach(el => { + if (el.parentNode !== childrenContainer) childrenContainer.appendChild(el); + }); + } else { + const childrenContainer = container.querySelector(':scope > .children'); + if (childrenContainer) childrenContainer.remove(); + } + + return container; +} diff --git a/static/js/flow.js b/static/js/flow.js new file mode 100644 index 0000000..5a43e50 --- /dev/null +++ b/static/js/flow.js @@ -0,0 +1,352 @@ +import { getShortLabel, isSwitch, findInterface } from './nodes.js'; +import { flowViewData, currentMode, currentView } from './state.js'; + +function scrollToNode(typeid) { + const nodeEl = document.querySelector('.node[data-id="' + typeid + '"]'); + if (nodeEl) { + nodeEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); + nodeEl.classList.add('scroll-highlight'); + setTimeout(() => nodeEl.classList.remove('scroll-highlight'), 1000); + } +} + +export 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 }; +} + +export 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; +} + +export 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; +} + +export 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); +} + +export 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)'; + } + } +} + +export 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; +} + +export 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 : '')); + } +} + +export function openFlowHash(protocol, ...args) { + window.location.hash = 'flow/' + protocol + '/' + args.join('/'); +} diff --git a/static/js/format.js b/static/js/format.js new file mode 100644 index 0000000..6d008fd --- /dev/null +++ b/static/js/format.js @@ -0,0 +1,39 @@ +export function formatBytes(bytes) { + if (bytes < 1024) return bytes.toFixed(0) + ' B/s'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB/s'; + if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB/s'; + return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB/s'; +} + +export function formatPackets(pps) { + if (pps < 1000) return pps.toFixed(0) + ' pps'; + if (pps < 1000000) return (pps / 1000).toFixed(1) + 'K pps'; + return (pps / 1000000).toFixed(1) + 'M pps'; +} + +export function formatMbps(bytesPerSec) { + const mbps = (bytesPerSec * 8) / 1000000; + return Math.round(mbps).toLocaleString() + ' Mbit/s'; +} + +export function formatPps(pps) { + return Math.round(pps).toLocaleString() + ' pps'; +} + +export function formatLinkSpeed(bps) { + if (!bps) return '?'; + const mbps = bps / 1000000; + return mbps.toLocaleString() + ' Mbit/s'; +} + +export function formatUniverse(u) { + const net = (u >> 8) & 0x7f; + const subnet = (u >> 4) & 0x0f; + const universe = u & 0x0f; + return net + ':' + subnet + ':' + universe + ' (' + u + ')'; +} + +export function escapeHtml(str) { + if (!str) return ''; + return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} diff --git a/static/js/nodes.js b/static/js/nodes.js new file mode 100644 index 0000000..86d6964 --- /dev/null +++ b/static/js/nodes.js @@ -0,0 +1,88 @@ +export function getLabel(node) { + if (node.names && node.names.length > 0) return node.names.join('\n'); + if (node.interfaces && node.interfaces.length > 0) { + const ips = []; + node.interfaces.forEach(iface => { + if (iface.ips) iface.ips.forEach(ip => ips.push(ip)); + }); + if (ips.length > 0) return ips.join('\n'); + const macs = []; + node.interfaces.forEach(iface => { + if (iface.mac) macs.push(iface.mac); + }); + if (macs.length > 0) return macs.join('\n'); + } + return '??'; +} + +export function getShortLabel(node) { + if (node.names && node.names.length > 0) return node.names.join('\n'); + if (node.interfaces && node.interfaces.length > 0) { + const ips = []; + node.interfaces.forEach(iface => { + if (iface.ips) iface.ips.forEach(ip => ips.push(ip)); + }); + if (ips.length > 0) return ips.join('\n'); + const macs = []; + node.interfaces.forEach(iface => { + if (iface.mac) macs.push(iface.mac); + }); + if (macs.length > 0) return macs.join('\n'); + } + return '??'; +} + +export function getNodeIdentifiers(node) { + const ids = []; + if (node.names) { + node.names.forEach(n => ids.push(n.toLowerCase())); + } + if (node.interfaces) { + node.interfaces.forEach(iface => { + if (iface.mac) ids.push(iface.mac.toLowerCase()); + }); + } + return ids; +} + +export function isSwitch(node) { + return !!(node.poe_budget); +} + +export function getSpeedClass(speed) { + if (!speed || speed === 0) return ''; + if (speed >= 10000000000) return 'speed-10g'; + if (speed >= 1000000000) return 'speed-1g'; + if (speed >= 100000000) return 'speed-100m'; + return 'speed-slow'; +} + +export function findInterface(node, ifaceName) { + if (!node || !node.interfaces) return null; + return node.interfaces.find(i => i.name === ifaceName) || null; +} + +export function getInterfaceSpeed(node, ifaceName) { + const iface = findInterface(node, ifaceName); + return iface?.stats?.speed || 0; +} + +export function getInterfaceErrors(node, ifaceName) { + const iface = findInterface(node, ifaceName); + if (!iface?.stats) return null; + const inErr = iface.stats.in_errors || 0; + const outErr = iface.stats.out_errors || 0; + if (inErr === 0 && outErr === 0) return null; + return { in: inErr, out: outErr }; +} + +export function getInterfaceRates(node, ifaceName) { + const iface = findInterface(node, ifaceName); + if (!iface?.stats) return null; + return { + inPkts: iface.stats.in_pkts_rate || 0, + outPkts: iface.stats.out_pkts_rate || 0, + inBytes: iface.stats.in_bytes_rate || 0, + outBytes: iface.stats.out_bytes_rate || 0 + }; +} diff --git a/static/js/render.js b/static/js/render.js new file mode 100644 index 0000000..2dab224 --- /dev/null +++ b/static/js/render.js @@ -0,0 +1,381 @@ +import { getLabel, getShortLabel, isSwitch, getInterfaceSpeed, getInterfaceErrors, getInterfaceRates } from './nodes.js'; +import { buildSwitchUplinks, buildLocationTree, buildNodeIndex, findLocationForNode, findEffectiveSwitch } from './topology.js'; +import { formatUniverse } from './format.js'; +import { createNodeElement, renderLocation } from './components.js'; +import { updateErrorPanel, updateBroadcastStats } from './ui.js'; +import { renderTable } from './table.js'; +import { showFlowView } from './flow.js'; +import { + nodeElements, locationElements, + setUsedNodeIds, setUsedLocationIds, setPortErrors, + setTableData, setFlowViewData, currentView, + resetAnonCounter +} from './state.js'; + +export function render(data, config) { + resetAnonCounter(); + + const nodes = data.nodes || []; + const links = data.links || []; + + setPortErrors(data.errors || []); + const unreachableNodeIds = new Set(nodes.filter(n => n.unreachable).map(n => n.id)); + const errorNodeIds = new Set((data.errors || []).filter(e => e.type !== 'unreachable').map(e => e.node_id)); + + const locationTree = buildLocationTree(config.locations || [], null); + const nodeIndex = new Map(); + buildNodeIndex(locationTree, nodeIndex); + + const nodesByTypeId = new Map(); + nodes.forEach(node => { + nodesByTypeId.set(node.id, node); + }); + + const nodeLocations = new Map(); + const assignedNodes = new Map(); + const unassignedNodes = []; + + nodes.forEach(node => { + const loc = findLocationForNode(node, nodeIndex); + if (loc) { + nodeLocations.set(node.id, loc); + if (!assignedNodes.has(loc)) { + assignedNodes.set(loc, []); + } + assignedNodes.get(loc).push(node); + } else { + unassignedNodes.push(node); + } + }); + + const switchConnections = new Map(); + const switchLinks = []; + const allSwitches = nodes.filter(n => isSwitch(n)); + + links.forEach(link => { + const nodeA = nodesByTypeId.get(link.node_a_id); + const nodeB = nodesByTypeId.get(link.node_b_id); + if (!nodeA || !nodeB) return; + + const aIsSwitch = isSwitch(nodeA); + const bIsSwitch = isSwitch(nodeB); + + if (aIsSwitch && bIsSwitch) { + switchLinks.push({ + switchA: nodeA, + switchB: nodeB, + portA: link.interface_a || '?', + portB: link.interface_b || '?', + speedA: getInterfaceSpeed(nodeA, link.interface_a), + speedB: getInterfaceSpeed(nodeB, link.interface_b), + errorsA: getInterfaceErrors(nodeA, link.interface_a), + errorsB: getInterfaceErrors(nodeB, link.interface_b), + ratesA: getInterfaceRates(nodeA, link.interface_a), + ratesB: getInterfaceRates(nodeB, link.interface_b) + }); + } else if (aIsSwitch && !bIsSwitch) { + const nodeLoc = nodeLocations.get(nodeB.id); + const effectiveSwitch = findEffectiveSwitch(nodeLoc, assignedNodes); + const isLocalSwitch = effectiveSwitch && effectiveSwitch.id === nodeA.id; + switchConnections.set(nodeB.id, { + port: link.interface_a || '?', + switchName: getLabel(nodeA), + showSwitchName: !isLocalSwitch, + external: effectiveSwitch && !isLocalSwitch, + speed: getInterfaceSpeed(nodeA, link.interface_a), + errors: getInterfaceErrors(nodeA, link.interface_a), + rates: getInterfaceRates(nodeA, link.interface_a) + }); + } else if (bIsSwitch && !aIsSwitch) { + const nodeLoc = nodeLocations.get(nodeA.id); + const effectiveSwitch = findEffectiveSwitch(nodeLoc, assignedNodes); + const isLocalSwitch = effectiveSwitch && effectiveSwitch.id === nodeB.id; + switchConnections.set(nodeA.id, { + port: link.interface_b || '?', + switchName: getLabel(nodeB), + showSwitchName: !isLocalSwitch, + external: effectiveSwitch && !isLocalSwitch, + speed: getInterfaceSpeed(nodeB, link.interface_b), + errors: getInterfaceErrors(nodeB, link.interface_b), + rates: getInterfaceRates(nodeB, link.interface_b) + }); + } + }); + + const danteNodes = new Map(); + + const formatDanteChannel = (ch) => { + let str = ch.tx_channel + ' → ' + String(ch.rx_channel).padStart(2, '0'); + if (ch.type) str += ' [' + ch.type + ']'; + if (ch.status === 'no-source') str += ' ⚠'; + return str; + }; + + nodes.forEach(node => { + const nodeId = node.id; + const danteTx = node.dante_flows?.tx || []; + const danteRx = node.dante_flows?.rx || []; + + if (danteTx.length === 0 && danteRx.length === 0) return; + + 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 { text: peerName + channelSummary, peerId: peer.node_id }; + }); + + 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 { text: peerName + channelSummary, peerId: peer.node_id }; + }); + + 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: txEntries.map(e => e.text), + txToPeerIds: txEntries.map(e => e.peerId), + rxFrom: rxEntries.map(e => e.text), + rxFromPeerIds: rxEntries.map(e => e.peerId) + }); + }); + + const artnetNodes = new Map(); + + const universeInputs = new Map(); + const universeOutputs = new Map(); + + nodes.forEach(node => { + const name = getShortLabel(node); + (node.artnet_inputs || []).forEach(u => { + if (!universeInputs.has(u)) universeInputs.set(u, []); + universeInputs.get(u).push(name); + }); + (node.artnet_outputs || []).forEach(u => { + if (!universeOutputs.has(u)) universeOutputs.set(u, []); + universeOutputs.get(u).push(name); + }); + }); + + const collapseNames = (names) => { + const counts = {}; + names.forEach(n => counts[n] = (counts[n] || 0) + 1); + return Object.entries(counts).map(([name, count]) => count > 1 ? name + ' x' + count : name); + }; + + nodes.forEach(node => { + const nodeId = node.id; + const artnetInputs = node.artnet_inputs || []; + const artnetOutputs = node.artnet_outputs || []; + + if (artnetInputs.length === 0 && artnetOutputs.length === 0) return; + + 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], universe: u }; + } + return { display: uniStr, firstTarget: null, universe: 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], universe: u }; + } + return { display: uniStr, firstTarget: null, universe: u }; + }); + + artnetNodes.set(nodeId, { + isOut: outputs.length > 0, + isIn: inputs.length > 0, + outputs: outputs, + inputs: inputs + }); + }); + + const sacnNodes = new Map(); + + const sacnUniverseInputs = new Map(); + const sacnUniverseOutputs = new Map(); + + function getSacnInputsFromMulticast(node) { + const groups = node.multicast_groups || []; + const inputs = []; + groups.forEach(g => { + if (typeof g === 'string' && g.startsWith('sacn:')) { + const u = parseInt(g.substring(5), 10); + if (!isNaN(u)) inputs.push(u); + } + }); + return inputs; + } + + nodes.forEach(node => { + const name = getShortLabel(node); + getSacnInputsFromMulticast(node).forEach(u => { + if (!sacnUniverseInputs.has(u)) sacnUniverseInputs.set(u, []); + sacnUniverseInputs.get(u).push(name); + }); + (node.sacn_outputs || []).forEach(u => { + if (!sacnUniverseOutputs.has(u)) sacnUniverseOutputs.set(u, []); + sacnUniverseOutputs.get(u).push(name); + }); + }); + + const sacnCollapseNames = (names) => { + const counts = {}; + names.forEach(n => counts[n] = (counts[n] || 0) + 1); + return Object.entries(counts).map(([name, count]) => count > 1 ? name + ' x' + count : name); + }; + + nodes.forEach(node => { + const nodeId = node.id; + const sacnInputs = getSacnInputsFromMulticast(node); + const sacnOutputs = node.sacn_outputs || []; + + if (sacnInputs.length === 0 && sacnOutputs.length === 0) return; + + 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], universe: u }; + } + return { display: String(u), firstTarget: null, universe: u }; + }); + const outputs = sortedSacnOutputs.map(u => { + const dests = sacnCollapseNames(sacnUniverseInputs.get(u) || []); + if (dests.length > 0) { + return { display: dests[0] + ' [' + u + ']', firstTarget: dests[0], universe: u }; + } + return { display: String(u), firstTarget: null, universe: u }; + }); + + sacnNodes.set(nodeId, { + isOut: outputs.length > 0, + isIn: inputs.length > 0, + outputs: outputs, + inputs: inputs + }); + }); + + const switchUplinks = buildSwitchUplinks(allSwitches, switchLinks); + + const container = document.getElementById('container'); + const usedNodeIdsSet = new Set(); + const usedLocationIdsSet = new Set(); + + locationTree.forEach(loc => { + const el = renderLocation(loc, assignedNodes, true, switchConnections, switchUplinks, danteNodes, artnetNodes, sacnNodes, errorNodeIds, unreachableNodeIds, usedNodeIdsSet, usedLocationIdsSet); + if (el && el.parentNode !== container) container.appendChild(el); + }); + + let unassignedLoc = locationElements.get('__unassigned__'); + if (unassignedNodes.length > 0) { + if (!unassignedLoc) { + unassignedLoc = document.createElement('div'); + unassignedLoc.className = 'location top-level'; + const nameEl = document.createElement('div'); + nameEl.className = 'location-name'; + nameEl.textContent = 'Unassigned'; + unassignedLoc.appendChild(nameEl); + locationElements.set('__unassigned__', unassignedLoc); + } + + const switches = unassignedNodes.filter(n => isSwitch(n)); + const nonSwitches = unassignedNodes.filter(n => !isSwitch(n)); + + let switchRow = unassignedLoc.querySelector(':scope > .node-row.switch-row'); + if (switches.length > 0) { + if (!switchRow) { + switchRow = document.createElement('div'); + switchRow.className = 'node-row switch-row'; + unassignedLoc.appendChild(switchRow); + } + const currentIds = new Set(switches.map(n => n.id)); + Array.from(switchRow.children).forEach(ch => { + if (!currentIds.has(ch.dataset.id)) ch.remove(); + }); + switches.forEach(node => { + usedNodeIdsSet.add(node.id); + const uplink = switchUplinks.get(node.id); + const danteInfo = danteNodes.get(node.id); + const artnetInfo = artnetNodes.get(node.id); + const sacnInfo = sacnNodes.get(node.id); + const hasError = errorNodeIds.has(node.id); + const isUnreachable = unreachableNodeIds.has(node.id); + const el = createNodeElement(node, null, null, uplink, danteInfo, artnetInfo, sacnInfo, hasError, isUnreachable); + if (el.parentNode !== switchRow) switchRow.appendChild(el); + }); + } else if (switchRow) { + switchRow.remove(); + } + + let nodeRow = unassignedLoc.querySelector(':scope > .node-row:not(.switch-row)'); + if (nonSwitches.length > 0) { + if (!nodeRow) { + nodeRow = document.createElement('div'); + nodeRow.className = 'node-row'; + unassignedLoc.appendChild(nodeRow); + } + const currentIds = new Set(nonSwitches.map(n => n.id)); + Array.from(nodeRow.children).forEach(ch => { + if (!currentIds.has(ch.dataset.id)) ch.remove(); + }); + nonSwitches.forEach(node => { + usedNodeIdsSet.add(node.id); + const conn = switchConnections.get(node.id); + const danteInfo = danteNodes.get(node.id); + const artnetInfo = artnetNodes.get(node.id); + const sacnInfo = sacnNodes.get(node.id); + const hasError = errorNodeIds.has(node.id); + const isUnreachable = unreachableNodeIds.has(node.id); + const el = createNodeElement(node, conn, null, null, danteInfo, artnetInfo, sacnInfo, hasError, isUnreachable); + if (el.parentNode !== nodeRow) nodeRow.appendChild(el); + }); + } else if (nodeRow) { + nodeRow.remove(); + } + + if (unassignedLoc.parentNode !== container) container.appendChild(unassignedLoc); + usedLocationIdsSet.add('__unassigned__'); + } else if (unassignedLoc) { + unassignedLoc.remove(); + } + + setUsedNodeIds(usedNodeIdsSet); + setUsedLocationIds(usedLocationIdsSet); + + locationElements.forEach((el, id) => { + if (!usedLocationIdsSet.has(id) && el.parentNode) { + el.remove(); + } + }); + + updateErrorPanel(); + updateBroadcastStats(data.broadcast_stats); + + setTableData(data); + setFlowViewData(data); + if (currentView === 'table') { + renderTable(); + } + const hash = window.location.hash; + if (hash.startsWith('#flow/')) { + showFlowView(hash.slice(6)); + } +} diff --git a/static/js/state.js b/static/js/state.js new file mode 100644 index 0000000..8d15fe2 --- /dev/null +++ b/static/js/state.js @@ -0,0 +1,61 @@ +export const nodeElements = new Map(); +export const locationElements = new Map(); +export let usedNodeIds = new Set(); +export let usedLocationIds = new Set(); +export let anonCounter = 0; +export let portErrors = []; +export let errorPanelCollapsed = false; +export let currentConfig = null; +export let currentMode = 'network'; +export let currentView = 'map'; +export let tableData = null; +export let tableSortKeys = []; +export let flowViewData = null; + +export function resetAnonCounter() { + anonCounter = 0; +} + +export function incrementAnonCounter() { + return anonCounter++; +} + +export function setUsedNodeIds(ids) { + usedNodeIds = ids; +} + +export function setUsedLocationIds(ids) { + usedLocationIds = ids; +} + +export function setPortErrors(errors) { + portErrors = errors; +} + +export function setErrorPanelCollapsed(collapsed) { + errorPanelCollapsed = collapsed; +} + +export function setCurrentConfig(config) { + currentConfig = config; +} + +export function setCurrentMode(mode) { + currentMode = mode; +} + +export function setCurrentView(view) { + currentView = view; +} + +export function setTableData(data) { + tableData = data; +} + +export function setTableSortKeys(keys) { + tableSortKeys = keys; +} + +export function setFlowViewData(data) { + flowViewData = data; +} diff --git a/static/js/table.js b/static/js/table.js new file mode 100644 index 0000000..8e25804 --- /dev/null +++ b/static/js/table.js @@ -0,0 +1,376 @@ +import { getLabel, isSwitch, getInterfaceSpeed, getInterfaceErrors, getInterfaceRates } from './nodes.js'; +import { buildSwitchUplinks } from './topology.js'; +import { escapeHtml, formatUniverse } from './format.js'; +import { tableData, tableSortKeys, setTableSortKeys } from './state.js'; + +export function sortTable(column) { + const existingIdx = tableSortKeys.findIndex(k => k.column === column); + const newKeys = [...tableSortKeys]; + if (existingIdx === 0) { + newKeys[0] = { ...newKeys[0], asc: !newKeys[0].asc }; + } else { + if (existingIdx > 0) { + newKeys.splice(existingIdx, 1); + } + newKeys.unshift({ column, asc: true }); + } + setTableSortKeys(newKeys); +} + +export function sortRows(rows, sortKeys) { + if (!sortKeys || sortKeys.length === 0) return rows; + const indexed = rows.map((r, i) => ({ r, i })); + indexed.sort((a, b) => { + for (const { column, asc } of sortKeys) { + let va = a.r[column]; + let vb = b.r[column]; + if (va == null) va = ''; + if (vb == null) vb = ''; + let cmp; + if (typeof va === 'number' && typeof vb === 'number') { + cmp = va - vb; + } else { + va = String(va).toLowerCase(); + vb = String(vb).toLowerCase(); + cmp = va.localeCompare(vb, undefined, { numeric: true, sensitivity: 'base' }); + } + if (cmp !== 0) return asc ? cmp : -cmp; + } + return a.i - b.i; + }); + return indexed.map(x => x.r); +} + +export function renderTable() { + if (!tableData) return; + const container = document.getElementById('table-container'); + const mode = document.body.classList.contains('dante-mode') ? 'dante' : + document.body.classList.contains('artnet-mode') ? 'artnet' : + document.body.classList.contains('sacn-mode') ? 'sacn' : 'network'; + + let html = ''; + if (mode === 'network') { + html = renderNetworkTable(); + } else if (mode === 'dante') { + html = renderDanteTable(); + } else if (mode === 'artnet') { + html = renderArtnetTable(); + } else if (mode === 'sacn') { + html = renderSacnTable(); + } + container.innerHTML = html; + + container.querySelectorAll('th[data-sort]').forEach(th => { + th.addEventListener('click', () => { + sortTable(th.dataset.sort); + renderTable(); + }); + const sortKey = tableSortKeys.find(k => k.column === th.dataset.sort); + if (sortKey) { + th.classList.add(sortKey.asc ? 'sorted-asc' : 'sorted-desc'); + } + }); +} + +export function renderNetworkTable() { + const nodes = tableData.nodes || []; + const links = tableData.links || []; + + const nodesByTypeId = new Map(); + nodes.forEach(node => nodesByTypeId.set(node.id, node)); + + const upstreamConnections = new Map(); + const allSwitches = nodes.filter(n => isSwitch(n)); + const switchLinks = []; + + links.forEach(link => { + const nodeA = nodesByTypeId.get(link.node_a_id); + const nodeB = nodesByTypeId.get(link.node_b_id); + if (!nodeA || !nodeB) return; + + const aIsSwitch = isSwitch(nodeA); + const bIsSwitch = isSwitch(nodeB); + + if (aIsSwitch && !bIsSwitch) { + upstreamConnections.set(nodeB.id, { + switchName: getLabel(nodeA), + port: link.interface_a || '?', + speed: getInterfaceSpeed(nodeA, link.interface_a), + errors: getInterfaceErrors(nodeA, link.interface_a), + rates: getInterfaceRates(nodeA, link.interface_a), + isLocalPort: false + }); + } else if (bIsSwitch && !aIsSwitch) { + upstreamConnections.set(nodeA.id, { + switchName: getLabel(nodeB), + port: link.interface_b || '?', + speed: getInterfaceSpeed(nodeB, link.interface_b), + errors: getInterfaceErrors(nodeB, link.interface_b), + rates: getInterfaceRates(nodeB, link.interface_b), + isLocalPort: false + }); + } else if (aIsSwitch && bIsSwitch) { + switchLinks.push({ + switchA: nodeA, + switchB: nodeB, + portA: link.interface_a || '?', + portB: link.interface_b || '?', + speedA: getInterfaceSpeed(nodeA, link.interface_a), + speedB: getInterfaceSpeed(nodeB, link.interface_b), + errorsA: getInterfaceErrors(nodeA, link.interface_a), + errorsB: getInterfaceErrors(nodeB, link.interface_b), + ratesA: getInterfaceRates(nodeA, link.interface_a), + ratesB: getInterfaceRates(nodeB, link.interface_b) + }); + } + }); + + const switchUplinks = buildSwitchUplinks(allSwitches, switchLinks); + for (const [switchId, uplink] of switchUplinks) { + if (uplink === 'ROOT') { + upstreamConnections.set(switchId, 'ROOT'); + } else { + upstreamConnections.set(switchId, { + switchName: uplink.parentName, + port: uplink.localPort, + speed: uplink.speed, + errors: uplink.errors, + rates: uplink.rates, + isLocalPort: true + }); + } + } + + const formatMbpsLocal = (bytesPerSec) => { + const mbps = (bytesPerSec * 8) / 1000000; + return mbps.toFixed(1); + }; + + let rows = nodes.map(node => { + const name = getLabel(node); + const ips = []; + (node.interfaces || []).forEach(iface => { + if (iface.ips) iface.ips.forEach(ip => ips.push(ip)); + }); + + const conn = upstreamConnections.get(node.id); + const isRoot = conn === 'ROOT'; + const upstream = isRoot ? 'ROOT' : (conn ? conn.switchName + ':' + conn.port : ''); + const speed = isRoot ? null : (conn?.speed || 0); + const errors = isRoot ? null : (conn?.errors || { in: 0, out: 0 }); + const rates = isRoot ? null : (conn?.rates || { inBytes: 0, outBytes: 0 }); + const useLocalPerspective = isRoot || conn?.isLocalPort; + + const isUnreachable = node.unreachable; + const speedStr = speed == null ? '' : (speed >= 1e9 ? (speed/1e9)+'G' : speed >= 1e6 ? (speed/1e6)+'M' : speed > 0 ? speed : '0'); + + return { + name, + ip: ips[0] || '', + upstream, + speed, + speedStr, + inErrors: errors == null ? null : (useLocalPerspective ? errors.in : errors.out), + outErrors: errors == null ? null : (useLocalPerspective ? errors.out : errors.in), + inRate: rates == null ? null : (useLocalPerspective ? rates.inBytes : rates.outBytes), + outRate: rates == null ? null : (useLocalPerspective ? rates.outBytes : rates.inBytes), + status: isUnreachable ? 'unreachable' : (errors && (errors.in + errors.out) > 0 ? 'errors' : 'ok') + }; + }); + + rows = sortRows(rows, tableSortKeys); + + let html = ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + + rows.forEach(r => { + const statusClass = r.status === 'unreachable' ? 'status-error' : r.status === 'errors' ? 'status-warn' : 'status-ok'; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + }); + + html += '
NameIPUpstreamSpeedIn ErrOut ErrIn Mbit/sOut Mbit/sStatus
' + escapeHtml(r.name) + '' + escapeHtml(r.ip) + '' + escapeHtml(r.upstream) + '' + r.speedStr + '' + (r.inErrors == null ? '' : r.inErrors) + '' + (r.outErrors == null ? '' : r.outErrors) + '' + (r.inRate == null ? '' : formatMbpsLocal(r.inRate)) + '' + (r.outRate == null ? '' : formatMbpsLocal(r.outRate)) + '' + r.status + '
'; + return html; +} + +export function renderDanteTable() { + const nodes = tableData.nodes || []; + const nodesByTypeId = new Map(); + nodes.forEach(node => nodesByTypeId.set(node.id, node)); + let rows = []; + nodes.forEach(node => { + const name = getLabel(node); + const tx = node.dante_flows?.tx || []; + tx.forEach(peer => { + const peerNode = nodesByTypeId.get(peer.node_id); + const peerName = peerNode ? getLabel(peerNode) : '??'; + (peer.channels || []).forEach(ch => { + rows.push({ + source: name, + dest: peerName, + txChannel: ch.tx_channel, + rxChannel: ch.rx_channel, + type: ch.type || '', + status: ch.status || 'active' + }); + }); + if (!peer.channels || peer.channels.length === 0) { + rows.push({ source: name, dest: peerName, txChannel: '', rxChannel: 0, type: '', status: 'active' }); + } + }); + }); + + rows = sortRows(rows, tableSortKeys); + + let html = ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + + rows.forEach(r => { + const statusClass = r.status === 'no-source' ? 'status-warn' : 'status-ok'; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + }); + + html += '
SourceTX ChannelDestinationRX ChannelTypeStatus
' + escapeHtml(r.source) + '' + escapeHtml(r.txChannel) + '' + escapeHtml(r.dest) + '' + (r.rxChannel || '') + '' + escapeHtml(r.type) + '' + escapeHtml(r.status) + '
'; + return html; +} + +export function renderArtnetTable() { + const nodes = tableData.nodes || []; + const txByUniverse = new Map(); + const rxByUniverse = new Map(); + + nodes.forEach(node => { + const name = getLabel(node); + (node.artnet_inputs || []).forEach(u => { + if (!txByUniverse.has(u)) txByUniverse.set(u, []); + txByUniverse.get(u).push(name); + }); + (node.artnet_outputs || []).forEach(u => { + if (!rxByUniverse.has(u)) rxByUniverse.set(u, []); + rxByUniverse.get(u).push(name); + }); + }); + + const allUniverses = new Set([...txByUniverse.keys(), ...rxByUniverse.keys()]); + let rows = []; + allUniverses.forEach(u => { + const txNodes = txByUniverse.get(u) || []; + const rxNodes = rxByUniverse.get(u) || []; + const maxLen = Math.max(txNodes.length, rxNodes.length, 1); + for (let i = 0; i < maxLen; i++) { + rows.push({ + universe: u, + universeStr: formatUniverse(u), + tx: txNodes[i] || '', + rx: rxNodes[i] || '' + }); + } + }); + + rows = sortRows(rows, tableSortKeys); + + let html = ''; + html += ''; + html += ''; + html += ''; + html += ''; + + rows.forEach(r => { + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + }); + + html += '
TXUniverseRX
' + escapeHtml(r.tx) + '' + r.universeStr + '' + escapeHtml(r.rx) + '
'; + return html; +} + +export function renderSacnTable() { + const nodes = tableData.nodes || []; + const txByUniverse = new Map(); + const rxByUniverse = new Map(); + + nodes.forEach(node => { + const name = getLabel(node); + (node.sacn_outputs || []).forEach(u => { + if (!txByUniverse.has(u)) txByUniverse.set(u, []); + txByUniverse.get(u).push(name); + }); + (node.multicast_groups || []).forEach(g => { + if (typeof g === 'string' && g.startsWith('sacn:')) { + const u = parseInt(g.substring(5), 10); + if (!isNaN(u)) { + if (!rxByUniverse.has(u)) rxByUniverse.set(u, []); + rxByUniverse.get(u).push(name); + } + } + }); + }); + + const allUniverses = new Set([...txByUniverse.keys(), ...rxByUniverse.keys()]); + let rows = []; + allUniverses.forEach(u => { + const txNodes = txByUniverse.get(u) || []; + const rxNodes = rxByUniverse.get(u) || []; + const maxLen = Math.max(txNodes.length, rxNodes.length, 1); + for (let i = 0; i < maxLen; i++) { + rows.push({ + universe: u, + tx: txNodes[i] || '', + rx: rxNodes[i] || '' + }); + } + }); + + rows = sortRows(rows, tableSortKeys); + + let html = ''; + html += ''; + html += ''; + html += ''; + html += ''; + + rows.forEach(r => { + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + }); + + html += '
TXUniverseRX
' + escapeHtml(r.tx) + '' + r.universe + '' + escapeHtml(r.rx) + '
'; + return html; +} diff --git a/static/js/topology.js b/static/js/topology.js new file mode 100644 index 0000000..6e3a5ac --- /dev/null +++ b/static/js/topology.js @@ -0,0 +1,162 @@ +import { getLabel, getNodeIdentifiers, isSwitch } from './nodes.js'; +import { incrementAnonCounter } from './state.js'; + +export function buildSwitchUplinks(allSwitches, switchLinks) { + const uplinks = new Map(); + if (allSwitches.length === 0 || switchLinks.length === 0) return uplinks; + + const adjacency = new Map(); + allSwitches.forEach(sw => adjacency.set(sw.id, [])); + + switchLinks.forEach(link => { + adjacency.get(link.switchA.id).push({ + neighbor: link.switchB, + localPort: link.portA, + remotePort: link.portB, + localSpeed: link.speedA, + localErrors: link.errorsA, + localRates: link.ratesA + }); + adjacency.get(link.switchB.id).push({ + neighbor: link.switchA, + localPort: link.portB, + remotePort: link.portA, + localSpeed: link.speedB, + localErrors: link.errorsB, + localRates: link.ratesB + }); + }); + + for (const edges of adjacency.values()) { + edges.sort((a, b) => getLabel(a.neighbor).localeCompare(getLabel(b.neighbor))); + } + + const sortedSwitches = [...allSwitches].sort((a, b) => + getLabel(a).localeCompare(getLabel(b))); + + let bestRoot = sortedSwitches[0]; + let bestReachable = 0; + let bestMaxDepth = Infinity; + + for (const candidate of sortedSwitches) { + const visited = new Set([candidate.id]); + const queue = [{ sw: candidate, depth: 0 }]; + let maxDepth = 0; + + while (queue.length > 0) { + const { sw, depth } = queue.shift(); + maxDepth = Math.max(maxDepth, depth); + for (const edge of adjacency.get(sw.id) || []) { + if (!visited.has(edge.neighbor.id)) { + visited.add(edge.neighbor.id); + queue.push({ sw: edge.neighbor, depth: depth + 1 }); + } + } + } + + const reachable = visited.size; + if (reachable > bestReachable || + (reachable === bestReachable && maxDepth < bestMaxDepth)) { + bestReachable = reachable; + bestMaxDepth = maxDepth; + bestRoot = candidate; + } + } + + uplinks.set(bestRoot.id, 'ROOT'); + + const visited = new Set([bestRoot.id]); + const queue = [bestRoot]; + + while (queue.length > 0) { + const current = queue.shift(); + for (const edge of adjacency.get(current.id) || []) { + if (!visited.has(edge.neighbor.id)) { + visited.add(edge.neighbor.id); + const reverseEdge = adjacency.get(edge.neighbor.id).find(e => e.neighbor.id === current.id); + uplinks.set(edge.neighbor.id, { + localPort: reverseEdge?.localPort || '?', + remotePort: reverseEdge?.remotePort || '?', + parentNode: current, + parentName: getLabel(current), + speed: reverseEdge?.localSpeed || 0, + errors: reverseEdge?.localErrors || null, + rates: reverseEdge?.localRates || null + }); + queue.push(edge.neighbor); + } + } + } + + return uplinks; +} + +export function buildLocationTree(locations, parent) { + if (!locations) return []; + return locations.map((loc, idx) => { + let locId; + let anonymous = false; + if (loc.name) { + locId = 'loc_' + loc.name.replace(/[^a-zA-Z0-9]/g, '_'); + } else { + locId = 'loc_anon_' + incrementAnonCounter(); + anonymous = true; + } + const locObj = { + id: locId, + name: loc.name || '', + anonymous: anonymous, + direction: loc.direction || 'horizontal', + nodeRefs: (loc.nodes || []).map(n => n.toLowerCase()), + parent: parent, + children: [] + }; + locObj.children = buildLocationTree(loc.children, locObj); + return locObj; + }); +} + +export function getSwitchesInLocation(loc, assignedNodes) { + const switches = []; + const nodes = assignedNodes.get(loc) || []; + nodes.forEach(n => { + if (isSwitch(n)) switches.push(n); + }); + loc.children.forEach(child => { + if (child.anonymous) { + switches.push(...getSwitchesInLocation(child, assignedNodes)); + } + }); + return switches; +} + +export function findEffectiveSwitch(loc, assignedNodes) { + if (!loc) return null; + const switches = getSwitchesInLocation(loc, assignedNodes); + if (switches.length === 1) { + return switches[0]; + } + if (loc.parent) { + return findEffectiveSwitch(loc.parent, assignedNodes); + } + return null; +} + +export function buildNodeIndex(locations, index) { + locations.forEach(loc => { + loc.nodeRefs.forEach(ref => { + index.set(ref, loc); + }); + buildNodeIndex(loc.children, index); + }); +} + +export function findLocationForNode(node, nodeIndex) { + const identifiers = getNodeIdentifiers(node); + for (const ident of identifiers) { + if (nodeIndex.has(ident)) { + return nodeIndex.get(ident); + } + } + return null; +} diff --git a/static/js/ui.js b/static/js/ui.js new file mode 100644 index 0000000..34b5d9b --- /dev/null +++ b/static/js/ui.js @@ -0,0 +1,266 @@ +import { formatBytes, formatPackets, formatMbps, formatPps, formatLinkSpeed } from './format.js'; +import { openFlowHash } from './flow.js'; +import { portErrors, setErrorPanelCollapsed, errorPanelCollapsed } from './state.js'; + +export function addClickableValue(container, label, value, 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 = value; + val.addEventListener('click', (e) => { + e.stopPropagation(); + navigator.clipboard.writeText(value); + }); + container.appendChild(val); + plainLines.push(plainFormat ? plainFormat(label, value) : label + ': ' + value); +} + +export function buildClickableList(container, items, label, plainFormat, flowInfo) { + const plainLines = []; + items.forEach((item, idx) => { + if (idx > 0) container.appendChild(document.createTextNode('\n')); + 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.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(); + navigator.clipboard.writeText(plainLines.join('\n')); + }); +} + +export function buildLinkStats(container, portLabel, speed, errIn, errOut, rates) { + const plainLines = []; + if (portLabel) { + addClickableValue(container, 'PORT', portLabel, plainLines); + container.appendChild(document.createTextNode('\n')); + } + addClickableValue(container, 'LINK', formatLinkSpeed(speed), plainLines); + container.appendChild(document.createTextNode('\n')); + addClickableValue(container, 'ERR', 'RX ' + errIn + ' / TX ' + errOut, plainLines); + if (rates) { + container.appendChild(document.createTextNode('\n')); + addClickableValue(container, 'RX', formatMbps(rates.rxBytes) + ' (' + formatPps(rates.rxPkts) + ')', plainLines); + container.appendChild(document.createTextNode('\n')); + addClickableValue(container, 'TX', formatMbps(rates.txBytes) + ' (' + formatPps(rates.txPkts) + ')', plainLines); + } + container.addEventListener('click', (e) => { + e.stopPropagation(); + navigator.clipboard.writeText(plainLines.join('\n')); + }); +} + +export 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')); + plainLines.push(''); + } else if (container.childNodes.length > 0) { + container.appendChild(document.createTextNode('\n')); + } + if (line.startsWith(' ')) { + container.appendChild(document.createTextNode(' ' + line.trim())); + plainLines.push(' ' + line.trim()); + } else { + 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.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); + } + }); + }); + container.addEventListener('click', (e) => { + e.stopPropagation(); + navigator.clipboard.writeText(plainLines.join('\n')); + }); +} + +export function setConnectionStatus(connected) { + const el = document.getElementById('connection-status'); + const textEl = el.querySelector('.text'); + if (connected) { + el.className = 'connected'; + textEl.textContent = 'Connected'; + } else { + el.className = 'disconnected'; + textEl.textContent = 'Disconnected'; + } +} + +export function scrollToNode(typeid) { + const nodeEl = document.querySelector('.node[data-id="' + typeid + '"]'); + if (nodeEl) { + nodeEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); + nodeEl.classList.add('scroll-highlight'); + setTimeout(() => nodeEl.classList.remove('scroll-highlight'), 1000); + } +} + +export async function clearError(id) { + await fetch('/api/errors/clear?id=' + encodeURIComponent(id), { method: 'POST' }); +} + +export async function clearAllErrors() { + await fetch('/api/errors/clear?all=true', { method: 'POST' }); +} + +export function updateErrorPanel() { + const panel = document.getElementById('error-panel'); + const countEl = document.getElementById('error-count'); + const listEl = document.getElementById('error-list'); + + if (portErrors.length === 0) { + panel.classList.remove('has-errors'); + return; + } + + panel.classList.add('has-errors'); + countEl.textContent = portErrors.length + ' Error' + (portErrors.length !== 1 ? 's' : ''); + + listEl.innerHTML = ''; + portErrors.forEach(err => { + const item = document.createElement('div'); + item.className = 'error-item'; + + const nodeEl = document.createElement('div'); + nodeEl.className = 'error-node'; + nodeEl.textContent = err.node_name || err.node_id; + nodeEl.addEventListener('click', () => scrollToNode(err.node_id)); + item.appendChild(nodeEl); + + if (err.type === 'unreachable') { + const typeEl = document.createElement('div'); + typeEl.className = 'error-type'; + typeEl.textContent = 'Unreachable'; + item.appendChild(typeEl); + } else if (err.type === 'high_utilization') { + const portEl = document.createElement('div'); + portEl.className = 'error-port'; + portEl.textContent = 'Port: ' + err.port; + item.appendChild(portEl); + + const countsEl = document.createElement('div'); + countsEl.className = 'error-counts'; + countsEl.textContent = 'Utilization: ' + (err.utilization || 0).toFixed(0) + '%'; + item.appendChild(countsEl); + + const typeEl = document.createElement('div'); + typeEl.className = 'error-type'; + typeEl.textContent = 'High link utilization'; + item.appendChild(typeEl); + } else { + const portEl = document.createElement('div'); + portEl.className = 'error-port'; + portEl.textContent = 'Port: ' + err.port; + item.appendChild(portEl); + + const countsEl = document.createElement('div'); + countsEl.className = 'error-counts'; + countsEl.textContent = 'rx: ' + err.in_errors + ' (+' + (err.in_delta || 0) + ') / tx: ' + err.out_errors + ' (+' + (err.out_delta || 0) + ')'; + item.appendChild(countsEl); + + const typeEl = document.createElement('div'); + typeEl.className = 'error-type'; + typeEl.textContent = 'New errors detected'; + item.appendChild(typeEl); + } + + const dismissBtn = document.createElement('button'); + dismissBtn.textContent = 'Dismiss'; + dismissBtn.addEventListener('click', () => clearError(err.id)); + item.appendChild(dismissBtn); + + listEl.appendChild(item); + }); +} + +export function updateBroadcastStats(stats) { + const panel = document.getElementById('broadcast-stats'); + const ppsEl = document.getElementById('broadcast-pps'); + const bpsEl = document.getElementById('broadcast-bps'); + const bucketsEl = document.getElementById('broadcast-buckets'); + + if (!stats) { + ppsEl.textContent = '0 pps'; + bpsEl.textContent = '0 B/s'; + bucketsEl.innerHTML = ''; + panel.className = ''; + return; + } + + ppsEl.textContent = formatPackets(stats.packets_per_s); + bpsEl.textContent = formatBytes(stats.bytes_per_s); + + panel.classList.remove('warning', 'critical'); + if (stats.packets_per_s > 1000) { + panel.classList.add('critical'); + } else if (stats.packets_per_s > 100) { + panel.classList.add('warning'); + } + + bucketsEl.innerHTML = ''; + if (stats.buckets && stats.buckets.length > 0) { + stats.buckets.filter(b => b.packets_per_s >= 0.5).forEach(bucket => { + const div = document.createElement('div'); + div.className = 'bucket'; + div.innerHTML = '' + bucket.name + '' + + '' + formatPackets(bucket.packets_per_s) + ''; + bucketsEl.appendChild(div); + }); + } +} + +export function setupErrorPanelListeners() { + document.getElementById('clear-all-errors').addEventListener('click', clearAllErrors); + document.getElementById('toggle-errors').addEventListener('click', () => { + const panel = document.getElementById('error-panel'); + const btn = document.getElementById('toggle-errors'); + const newCollapsed = !errorPanelCollapsed; + setErrorPanelCollapsed(newCollapsed); + if (newCollapsed) { + panel.classList.add('collapsed'); + btn.textContent = 'Show'; + } else { + panel.classList.remove('collapsed'); + btn.textContent = 'Hide'; + } + }); +} diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..da5c48b --- /dev/null +++ b/static/style.css @@ -0,0 +1,1095 @@ +* { box-sizing: border-box; } +body { + font-family: ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; + margin: 0; + padding: 10px; + background: #111; + color: #eee; +} +#error { color: #f66; padding: 20px; } + +#connection-status { + position: fixed; + top: 10px; + left: 10px; + z-index: 1000; + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: #222; + border-radius: 6px; + border: 1px solid #444; + font-size: 11px; +} + +#connection-status .dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #666; +} + +#connection-status.connected .dot { + background: #4f4; +} + +#connection-status.disconnected .dot { + background: #f44; + animation: pulse-dot 1s infinite; +} + +@keyframes pulse-dot { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +#container { + display: flex; + flex-direction: column; + gap: 20px; + overflow: visible; +} + +.location { + background: #222; + border: 1px solid #444; + border-radius: 8px; + padding: 10px; + overflow: visible; +} + +.location.top-level { + width: 100%; +} + +.location-name { + font-weight: bold; + font-size: 14px; + margin-bottom: 10px; + text-align: center; +} + +.location.anonymous { + background: transparent; + border: none; + padding: 0; +} + +.location.anonymous > .location-name { + display: none; +} + +.node-row { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 8px; + justify-content: center; + overflow: visible; +} + +.node-row + .node-row { + margin-top: 8px; +} + +.node { + position: relative; + width: 120px; + min-height: 50px; + background: #a6d; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + font-size: 11px; + padding: 8px 4px 4px 4px; + cursor: pointer; + overflow: visible; + word-break: normal; + overflow-wrap: break-word; + white-space: pre-line; + margin-top: 8px; + z-index: 1; +} + +.node .switch-port { + font-size: 10px; + font-weight: normal; + background: #444; + color: #fff; + padding: 1px 6px; + border-radius: 8px; + white-space: nowrap; + max-width: 114px; + overflow: hidden; + text-overflow: ellipsis; +} + +.node .switch-port.external { + border: 1px dashed #c9f; +} + +.node .port-hover, +.node .uplink-hover { + position: absolute; + top: -8px; + left: 50%; + transform: translateX(-50%); +} + +.node .link-stats-wrapper { + display: none; + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + padding-bottom: 8px; +} + +.node .link-stats { + font-size: 10px; + font-weight: normal; + white-space: pre; + text-align: left; + background: #333; + border: 1px solid #555; + border-radius: 6px; + padding: 6px 8px; + line-height: 1.4; +} + +.link-stats .lbl, +.node-info .lbl, +.dante-info .lbl, +.dante-detail .lbl, +.artnet-info .lbl, +.artnet-detail .lbl { + color: #888; +} + +.node .port-hover::after, +.node .uplink-hover::after { + content: ''; + position: absolute; + top: 0; + bottom: -8px; + left: 50%; + transform: translateX(-50%); + width: 120px; +} + +.node .port-hover:hover .link-stats-wrapper, +.node .uplink-hover:hover .link-stats-wrapper { + display: block; + will-change: transform; +} + +.node .port-hover:hover, +.node .uplink-hover:hover { + z-index: 100; +} + +.node .uplink { + font-size: 10px; + font-weight: normal; + background: #444; + color: #fff; + padding: 1px 6px; + border-radius: 8px; + white-space: nowrap; + max-width: 114px; + overflow: hidden; + text-overflow: ellipsis; +} + +.node .root-label { + position: absolute; + top: -8px; + left: 50%; + transform: translateX(-50%); + font-size: 10px; + font-weight: normal; + background: #753; + color: #fff; + padding: 1px 6px; + border-radius: 8px; + white-space: nowrap; +} + +.node .switch-port.speed-10g, +.node .uplink.speed-10g { + background: #357; +} + +.node .switch-port.speed-1g, +.node .uplink.speed-1g { + background: #375; +} + +.node .switch-port.speed-100m, +.node .uplink.speed-100m { + background: #753; +} + +.node .switch-port.speed-slow, +.node .uplink.speed-slow { + background: #733; +} + +.node:hover { + filter: brightness(1.2); +} + +.node.switch { + background: #2a2; + font-weight: bold; +} + + +.node.copied { + outline: 2px solid #fff; +} + +.children { + display: flex; + gap: 15px; + margin-top: 10px; + overflow: visible; +} + +.children.horizontal { + flex-direction: row; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-evenly; + width: 100%; +} + +.children.vertical { + flex-direction: column; + align-items: center; +} + +#top-bar { + position: fixed; + top: 10px; + right: 10px; + z-index: 1000; + display: flex; + gap: 10px; +} + +.selector-group { + display: flex; + gap: 0; + background: #333; + border-radius: 6px; + overflow: hidden; + border: 1px solid #555; +} + +.selector-group button { + padding: 8px 16px; + border: none; + background: #333; + color: #aaa; + cursor: pointer; + font-size: 12px; + transition: all 0.2s; +} + +.selector-group button:hover { + background: #444; +} + +.selector-group button.active { + background: #555; + color: #fff; +} + +#table-container { + display: none; + padding: 10px; +} + +body.table-view #container { + display: none; +} + +body.table-view #table-container { + display: block; +} + +.data-table { + border-collapse: collapse; + background: #222; + border-radius: 8px; + overflow: hidden; + font-size: 11px; + margin: 0 auto; +} + +.data-table th, .data-table td { + padding: 6px 20px; + text-align: left; + border-bottom: 1px solid #333; +} + +.data-table th { + background: #333; + cursor: pointer; + user-select: none; + white-space: nowrap; +} + +.data-table th:hover { + background: #444; +} + +.data-table th.sorted-asc::after { + content: ' ▲'; + font-size: 10px; +} + +.data-table th.sorted-desc::after { + content: ' ▼'; + font-size: 10px; +} + +.data-table tr:hover { + background: #2a2a2a; +} + +.data-table td.numeric { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.data-table .status-ok { + color: #4f4; +} + +.data-table .status-warn { + color: #fa0; +} + +.data-table .status-error { + color: #f44; +} + +body.dante-mode .node { + opacity: 0.3; +} + +body.dante-mode .node.dante-tx { + opacity: 1; + background: #d62; +} + +body.dante-mode .node.dante-rx { + opacity: 1; + background: #26d; +} + +body.dante-mode .node.dante-tx.dante-rx { + background: linear-gradient(135deg, #d62 50%, #26d 50%); +} + +body.dante-mode .node .switch-port, +body.dante-mode .node .uplink, +body.dante-mode .node .root-label { + display: none; +} + +.node .dante-info { + display: none; + font-size: 10px; + font-weight: normal; + padding: 1px 6px; + border-radius: 8px; + white-space: nowrap; + color: #fff; + max-width: 114px; + overflow: hidden; + text-overflow: ellipsis; +} + +.node .dante-info.tx-info { + background: #853; +} + +.node .dante-info.rx-info { + background: #358; +} + +.node .dante-hover { + position: absolute; + top: -8px; + left: 50%; + transform: translateX(-50%); +} + +.node .dante-detail-wrapper { + display: none; + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + padding-bottom: 8px; +} + +.node .dante-detail { + font-size: 10px; + font-weight: normal; + white-space: pre; + text-align: left; + background: #333; + border: 1px solid #555; + border-radius: 6px; + padding: 6px 8px; + line-height: 1.4; +} + +.node .dante-hover::after { + content: ''; + position: absolute; + top: 0; + bottom: -8px; + left: 50%; + transform: translateX(-50%); + width: 120px; +} + +body.dante-mode .node .dante-hover:hover { + z-index: 100; +} + +body.dante-mode .node .dante-hover:hover .dante-detail-wrapper { + display: block; + will-change: transform; +} + +body.dante-mode .node.dante-tx .dante-info, +body.dante-mode .node.dante-rx .dante-info { + display: block; +} + +body.dante-mode .node.dante-tx.dante-rx .dante-rx-hover { + top: auto; + bottom: -8px; +} + +body.dante-mode .node.dante-tx.dante-rx .dante-rx-hover .dante-detail-wrapper { + bottom: auto; + top: 100%; + padding-bottom: 0; + padding-top: 8px; +} + + +body.artnet-mode .node { + opacity: 0.3; +} + +body.artnet-mode .node.artnet-out { + opacity: 1; + background: #287; +} + +body.artnet-mode .node.artnet-in { + opacity: 1; + background: #268; +} + +body.artnet-mode .node.artnet-out.artnet-in { + background: linear-gradient(135deg, #287 50%, #268 50%); +} + +body.artnet-mode .node .switch-port, +body.artnet-mode .node .uplink, +body.artnet-mode .node .root-label { + display: none; +} + +.node .artnet-info { + display: none; + font-size: 10px; + font-weight: normal; + padding: 1px 6px; + border-radius: 8px; + white-space: nowrap; + color: #fff; + max-width: 114px; + overflow: hidden; + text-overflow: ellipsis; +} + +.node .artnet-info.out-info { + background: #254; +} + +.node .artnet-info.in-info { + background: #245; +} + +.node .artnet-hover { + position: absolute; + top: -8px; + left: 50%; + transform: translateX(-50%); +} + +.node .artnet-detail-wrapper { + display: none; + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + padding-bottom: 8px; +} + +.node .artnet-detail { + font-size: 10px; + font-weight: normal; + white-space: pre; + text-align: left; + background: #333; + border: 1px solid #555; + border-radius: 6px; + padding: 6px 8px; + line-height: 1.4; +} + +.node .artnet-hover::after { + content: ''; + position: absolute; + top: 0; + bottom: -8px; + left: 50%; + transform: translateX(-50%); + width: 120px; +} + +body.artnet-mode .node .artnet-hover:hover { + z-index: 100; +} + +body.artnet-mode .node .artnet-hover:hover .artnet-detail-wrapper { + display: block; + will-change: transform; +} + +body.artnet-mode .node.artnet-out .artnet-info, +body.artnet-mode .node.artnet-in .artnet-info { + display: block; +} + +body.artnet-mode .node.artnet-out.artnet-in .artnet-in-hover { + top: auto; + bottom: -8px; +} + +body.artnet-mode .node.artnet-out.artnet-in .artnet-in-hover .artnet-detail-wrapper { + bottom: auto; + top: 100%; + padding-bottom: 0; + padding-top: 8px; +} + + +body.sacn-mode .node { + opacity: 0.3; +} + +body.sacn-mode .node.sacn-out { + opacity: 1; + background: #287; +} + +body.sacn-mode .node.sacn-in { + opacity: 1; + background: #268; +} + +body.sacn-mode .node.sacn-out.sacn-in { + background: linear-gradient(135deg, #287 50%, #268 50%); +} + +body.sacn-mode .node .switch-port, +body.sacn-mode .node .uplink, +body.sacn-mode .node .root-label { + display: none; +} + +.node .sacn-info { + display: none; + font-size: 10px; + font-weight: normal; + padding: 1px 6px; + border-radius: 8px; + white-space: nowrap; + color: #fff; + max-width: 114px; + overflow: hidden; + text-overflow: ellipsis; +} + +.node .sacn-info.out-info { + background: #254; +} + +.node .sacn-info.in-info { + background: #245; +} + +.node .sacn-hover { + position: absolute; + top: -8px; + left: 50%; + transform: translateX(-50%); +} + +.node .sacn-detail-wrapper { + display: none; + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + padding-bottom: 8px; +} + +.node .sacn-detail { + font-size: 10px; + font-weight: normal; + white-space: pre; + text-align: left; + background: #333; + border: 1px solid #555; + border-radius: 6px; + padding: 6px 8px; + line-height: 1.4; +} + +.node .sacn-hover::after { + content: ''; + position: absolute; + top: 0; + bottom: -8px; + left: 50%; + transform: translateX(-50%); + width: 120px; +} + +body.sacn-mode .node .sacn-hover:hover { + z-index: 100; +} + +body.sacn-mode .node .sacn-hover:hover .sacn-detail-wrapper { + display: block; + will-change: transform; +} + +body.sacn-mode .node.sacn-out .sacn-info, +body.sacn-mode .node.sacn-in .sacn-info { + display: block; +} + +body.sacn-mode .node.sacn-out.sacn-in .sacn-in-hover { + top: auto; + bottom: -8px; +} + +body.sacn-mode .node.sacn-out.sacn-in .sacn-in-hover .sacn-detail-wrapper { + bottom: auto; + top: 100%; + padding-bottom: 0; + padding-top: 8px; +} + +.sacn-info .lbl, +.sacn-detail .lbl { + 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; +} + +.node.unreachable { + box-shadow: 0 0 0 3px #f90; +} + +.node.has-error.unreachable { + box-shadow: 0 0 0 3px #f66, 0 0 0 6px #f90; +} + +.node:hover { + z-index: 100; +} + +.node .node-info-wrapper { + display: none; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + padding-top: 8px; + z-index: 1000; +} + +.node .node-info { + background: #333; + border: 1px solid #555; + border-radius: 6px; + padding: 6px 8px; + font-size: 10px; + font-weight: normal; + white-space: pre; + text-align: left; + line-height: 1.4; + cursor: pointer; +} + +.clickable-value, .node-name { + cursor: pointer; + display: inline-block; +} + + +.node:hover .node-info-wrapper { + display: block; + will-change: transform; +} + +body.dante-mode .node:not(.dante-tx):not(.dante-rx):hover .node-info-wrapper { + display: none; +} + +body.artnet-mode .node:not(.artnet-out):not(.artnet-in):hover .node-info-wrapper { + display: none; +} + +body.sacn-mode .node:not(.sacn-out):not(.sacn-in):hover .node-info-wrapper { + display: none; +} + +.node:has(.port-hover:hover) .node-info-wrapper, +.node:has(.uplink-hover:hover) .node-info-wrapper, +.node:has(.dante-hover:hover) .node-info-wrapper, +.node:has(.artnet-hover:hover) .node-info-wrapper, +.node:has(.sacn-hover:hover) .node-info-wrapper { + display: none; +} + +#error-panel { + position: fixed; + top: 50px; + right: 10px; + z-index: 1000; + background: #2a1a1a; + border: 1px solid #f66; + border-radius: 6px; + max-width: 500px; + max-height: 400px; + overflow: hidden; + display: none; +} + +#error-panel.has-errors { + display: block; +} + +#error-panel.collapsed #error-list { + display: none; +} + +#error-header { + padding: 8px 12px; + background: #3a2a2a; + display: flex; + gap: 8px; + align-items: center; +} + +#error-count { + flex: 1; + color: #f99; + font-weight: bold; +} + +#error-header button { + padding: 4px 8px; + border: none; + background: #444; + color: #ccc; + cursor: pointer; + border-radius: 4px; + font-size: 11px; +} + +#error-header button:hover { + background: #555; +} + +#error-list { + max-height: 300px; + overflow-y: auto; + padding: 8px; +} + +.error-item { + background: #3a2a2a; + border-radius: 4px; + padding: 8px; + margin-bottom: 6px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.error-item .error-node { + color: #faa; + font-weight: bold; + cursor: pointer; +} + +.error-item .error-node:hover { + text-decoration: underline; +} + +.error-item .error-port { + color: #ccc; + font-size: 11px; +} + +.error-item .error-counts { + color: #f66; + font-size: 11px; +} + +.error-item .error-type { + font-size: 9px; + color: #888; +} + +.error-item button { + align-self: flex-end; + padding: 2px 6px; + border: none; + background: #555; + color: #ccc; + cursor: pointer; + border-radius: 3px; + font-size: 10px; +} + +.error-item button:hover { + background: #666; +} + +.node.scroll-highlight { + outline: 3px solid white; +} + +#broadcast-stats { + position: fixed; + bottom: 10px; + left: 10px; + z-index: 1000; + padding: 8px 12px; + background: #222; + border-radius: 6px; + border: 1px solid #444; + font-size: 11px; +} + +#broadcast-stats.warning { + border-color: #f90; + background: #332a1a; +} + +#broadcast-stats.critical { + border-color: #f44; + background: #331a1a; +} + +#broadcast-stats .label { + color: #888; + margin-right: 4px; +} + +#broadcast-stats .value { + color: #eee; + font-weight: bold; +} + +#broadcast-stats .rate-row { + display: flex; + gap: 12px; +} + +#broadcast-stats .rate-item { + display: flex; + align-items: center; +} + +#broadcast-stats .buckets { + display: none; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid #444; +} + +#broadcast-stats:hover .buckets { + display: block; +} + +#broadcast-stats .bucket { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 2px 0; +} + +#broadcast-stats .bucket-name { + color: #aaa; +} + +#broadcast-stats .bucket-rate { + color: #eee; +}