From f97bf04eefeb71862996b5d66f2c7acbd864ef53 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Fri, 30 Jan 2026 23:27:45 -0800 Subject: [PATCH] UI improvements: flow names, table tooltips, artmap mappings, sorting Co-Authored-By: Claude Opus 4.5 --- static/js/components.js | 14 +++++----- static/js/flow.js | 55 ++++++++++++++++++++++++++----------- static/js/render.js | 9 +++--- static/js/table.js | 61 ++++++++++++++++++++++++----------------- static/js/ui.js | 16 +++++------ static/style.css | 51 ++++++++++++++++++++++++++++++++-- 6 files changed, 144 insertions(+), 62 deletions(-) diff --git a/static/js/components.js b/static/js/components.js index 9c94e0a..aa48a47 100644 --- a/static/js/components.js +++ b/static/js/components.js @@ -1,4 +1,4 @@ -import { getLabel, getShortLabel, isSwitch, getSpeedClass } from './nodes.js'; +import { getLabel, getShortLabel, getFirstName, isSwitch, getSpeedClass } from './nodes.js'; import { addClickableValue, buildLinkStats, buildDanteDetail, buildClickableList } from './ui.js'; import { nodeElements, locationElements, usedNodeIds, usedLocationIds } from './state.js'; @@ -186,7 +186,7 @@ export function createNodeElement(node, switchConnection, nodeLocation, uplinkIn const detail = container.querySelector('.dante-detail'); detail.innerHTML = ''; - buildDanteDetail(detail, danteInfo.txTo, '→', node.id, danteInfo.txToPeerIds); + buildDanteDetail(detail, danteInfo.txTo, '→', danteInfo.nodeName, danteInfo.txToPeerNames); } else { const container = div.querySelector(':scope > .dante-tx-hover'); if (container) container.remove(); @@ -207,7 +207,7 @@ export function createNodeElement(node, switchConnection, nodeLocation, uplinkIn const detail = container.querySelector('.dante-detail'); detail.innerHTML = ''; - buildDanteDetail(detail, danteInfo.rxFrom, '←', node.id, danteInfo.rxFromPeerIds); + buildDanteDetail(detail, danteInfo.rxFrom, '←', danteInfo.nodeName, danteInfo.rxFromPeerNames); } else { const container = div.querySelector(':scope > .dante-rx-hover'); if (container) container.remove(); @@ -230,7 +230,7 @@ export function createNodeElement(node, switchConnection, nodeLocation, uplinkIn 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) }); + { protocol: 'artnet', nodeName: getFirstName(node), universes: artnetInfo.outputs.map(o => o.universe) }); } else { const container = div.querySelector(':scope > .artnet-out-hover'); if (container) container.remove(); @@ -253,7 +253,7 @@ export function createNodeElement(node, switchConnection, nodeLocation, uplinkIn 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) }); + { protocol: 'artnet', nodeName: getFirstName(node), universes: artnetInfo.inputs.map(i => i.universe) }); } else { const container = div.querySelector(':scope > .artnet-in-hover'); if (container) container.remove(); @@ -276,7 +276,7 @@ export function createNodeElement(node, switchConnection, nodeLocation, uplinkIn 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) }); + { protocol: 'sacn', nodeName: getFirstName(node), universes: sacnInfo.outputs.map(o => o.universe) }); } else { const container = div.querySelector(':scope > .sacn-out-hover'); if (container) container.remove(); @@ -299,7 +299,7 @@ export function createNodeElement(node, switchConnection, nodeLocation, uplinkIn 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) }); + { protocol: 'sacn', nodeName: getFirstName(node), universes: sacnInfo.inputs.map(i => i.universe) }); } else { const container = div.querySelector(':scope > .sacn-in-hover'); if (container) container.remove(); diff --git a/static/js/flow.js b/static/js/flow.js index 93577ae..b7c50c7 100644 --- a/static/js/flow.js +++ b/static/js/flow.js @@ -1,4 +1,4 @@ -import { getShortLabel, isSwitch, findInterface } from './nodes.js'; +import { getShortLabel, getFirstName, isSwitch, findInterface } from './nodes.js'; import { flowViewData, currentMode, currentView } from './state.js'; function scrollToNode(typeid) { @@ -89,7 +89,7 @@ export function showFlowView(flowSpec) { else { const sourceNode = nodesByTypeId.get(sourceId); const destNode = nodesByTypeId.get(destId); - title = 'Dante: ' + getShortLabel(sourceNode) + ' → ' + getShortLabel(destNode); + title = 'Dante: ' + getFirstName(sourceNode) + ' → ' + getFirstName(destNode); const path = findPath(graph, sourceId, destId); if (path) paths.push({ path, sourceId, destId }); else error = 'No path found between nodes'; @@ -102,7 +102,7 @@ export function showFlowView(flowSpec) { else { const sourceNode = nodesByTypeId.get(sourceId); const danteTx = sourceNode.dante_flows?.tx || []; - title = 'Dante TX: ' + getShortLabel(sourceNode) + (txChannel ? ' ch ' + txChannel : ''); + title = 'Dante TX: ' + getFirstName(sourceNode) + (txChannel ? ' ch ' + txChannel : ''); const destIds = new Set(); danteTx.forEach(peer => { if (txChannel) { @@ -149,8 +149,8 @@ export function showFlowView(flowSpec) { 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 || '?'); + const destNames = destIds.filter(id => id !== clickedNodeId).map(id => getFirstName(nodesByTypeId.get(id))).join(', '); + title = protoName + ' ' + universe + ': ' + getFirstName(clickedNode) + ' → ' + (destNames || '?'); destIds.forEach(destId => { if (destId !== clickedNodeId) { const path = findPath(graph, clickedNodeId, destId); @@ -158,8 +158,8 @@ export function showFlowView(flowSpec) { } }); } else if (isDest) { - const sourceNames = sourceIds.map(id => getShortLabel(nodesByTypeId.get(id))).join(', '); - title = protoName + ' ' + universe + ': ' + (sourceNames || '?') + ' → ' + getShortLabel(clickedNode); + const sourceNames = sourceIds.map(id => getFirstName(nodesByTypeId.get(id))).join(', '); + title = protoName + ' ' + universe + ': ' + (sourceNames || '?') + ' → ' + getFirstName(clickedNode); sourceIds.forEach(sourceId => { const path = findPath(graph, sourceId, clickedNodeId); if (path) paths.push({ path, sourceId, destId: clickedNodeId }); @@ -324,8 +324,9 @@ export function renderFlowPath(pathInfo, nodesByTypeId, flowUniverse, flowProtoc const nodeEl = document.createElement('div'); nodeEl.className = 'flow-node'; + const isSourceNode = step.nodeId === sourceId && sourceId !== destId; if (isSwitch(node)) nodeEl.classList.add('switch'); - if (step.nodeId === sourceId && sourceId !== destId) nodeEl.classList.add('source'); + if (isSourceNode) nodeEl.classList.add('source'); else if (step.nodeId === destId) nodeEl.classList.add('dest'); nodeEl.textContent = getShortLabel(node); nodeEl.addEventListener('click', (e) => { @@ -333,30 +334,52 @@ export function renderFlowPath(pathInfo, nodesByTypeId, flowUniverse, flowProtoc closeFlowView(); scrollToNode(step.nodeId); }); - container.appendChild(nodeEl); + let mappingsEl = null; if (node.artmap_mappings && node.artmap_mappings.length > 0 && flowUniverse !== undefined) { const relevantMappings = getRelevantMappings(node.artmap_mappings, flowProtocol, flowUniverse); if (relevantMappings.length > 0) { - const mappingsEl = document.createElement('div'); + mappingsEl = document.createElement('div'); mappingsEl.className = 'flow-artmap-mappings'; + if (isSourceNode) mappingsEl.classList.add('before-node'); + const currentPrefix = flowProtocol + ':' + flowUniverse; + const nodeName = getFirstName(node); relevantMappings.forEach(m => { const mappingEl = document.createElement('div'); mappingEl.className = 'artmap-mapping'; - mappingEl.textContent = m.from + ' → ' + m.to; + const fromSpan = document.createElement('span'); + fromSpan.className = 'from'; + fromSpan.textContent = m.from; + const arrowSpan = document.createElement('span'); + arrowSpan.textContent = '→'; + const toSpan = document.createElement('span'); + toSpan.className = 'to'; + toSpan.textContent = m.to; + mappingEl.appendChild(fromSpan); + mappingEl.appendChild(arrowSpan); + mappingEl.appendChild(toSpan); mappingEl.addEventListener('click', (e) => { e.stopPropagation(); - const toProto = m.to.split(':')[0]; - const toUniverse = parseInt(m.to.split(':')[1], 10); - if (!isNaN(toUniverse)) { - openFlowHash(toProto, toUniverse); + const fromBase = m.from.split(':').slice(0, 2).join(':'); + const target = fromBase === currentPrefix ? m.to : m.from; + const targetProto = target.split(':')[0]; + const targetUniverse = parseInt(target.split(':')[1], 10); + if (!isNaN(targetUniverse)) { + openFlowHash(targetProto, targetUniverse, nodeName); } }); mappingsEl.appendChild(mappingEl); }); - container.appendChild(mappingsEl); } } + + if (mappingsEl && isSourceNode) { + container.appendChild(mappingsEl); + } + container.appendChild(nodeEl); + if (mappingsEl && !isSourceNode) { + container.appendChild(mappingsEl); + } }); return container; diff --git a/static/js/render.js b/static/js/render.js index b6ff871..d4a7f14 100644 --- a/static/js/render.js +++ b/static/js/render.js @@ -123,7 +123,7 @@ export function render(data, config) { const peerName = peerNode ? getFirstName(peerNode) : '??'; const channels = (peer.channels || []).map(formatDanteChannel); const channelSummary = channels.length > 0 ? '\n ' + channels.join('\n ') : ''; - return { text: peerName + channelSummary, peerId: peer.node_id }; + return { text: peerName + channelSummary, peerName }; }); const rxEntries = danteRx.map(peer => { @@ -131,7 +131,7 @@ export function render(data, config) { const peerName = peerNode ? getFirstName(peerNode) : '??'; const channels = (peer.channels || []).map(formatDanteChannel); const channelSummary = channels.length > 0 ? '\n ' + channels.join('\n ') : ''; - return { text: peerName + channelSummary, peerId: peer.node_id }; + return { text: peerName + channelSummary, peerName }; }); txEntries.sort((a, b) => a.text.split('\n')[0].localeCompare(b.text.split('\n')[0])); @@ -141,9 +141,10 @@ export function render(data, config) { isTx: danteTx.length > 0, isRx: danteRx.length > 0, txTo: txEntries.map(e => e.text), - txToPeerIds: txEntries.map(e => e.peerId), + txToPeerNames: txEntries.map(e => e.peerName), rxFrom: rxEntries.map(e => e.text), - rxFromPeerIds: rxEntries.map(e => e.peerId) + rxFromPeerNames: rxEntries.map(e => e.peerName), + nodeName: getFirstName(node) }); }); diff --git a/static/js/table.js b/static/js/table.js index 0974ac0..df01591 100644 --- a/static/js/table.js +++ b/static/js/table.js @@ -1,4 +1,4 @@ -import { getLabel, isSwitch, getInterfaceSpeed, getInterfaceErrors, getInterfaceRates } from './nodes.js'; +import { getLabel, getFirstName, 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'; @@ -65,9 +65,9 @@ export function renderTable() { 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'); + const primarySort = tableSortKeys[0]; + if (primarySort && primarySort.column === th.dataset.sort) { + th.classList.add(primarySort.asc ? 'sorted-asc' : 'sorted-desc'); } }); } @@ -217,15 +217,19 @@ export function renderDanteTable() { nodes.forEach(node => nodesByTypeId.set(node.id, node)); let rows = []; nodes.forEach(node => { - const name = getLabel(node); + const name = getFirstName(node); + const nameTitle = getLabel(node); const tx = node.dante_flows?.tx || []; tx.forEach(peer => { const peerNode = nodesByTypeId.get(peer.node_id); - const peerName = peerNode ? getLabel(peerNode) : '??'; + const peerName = peerNode ? getFirstName(peerNode) : '??'; + const peerTitle = peerNode ? getLabel(peerNode) : '??'; (peer.channels || []).forEach(ch => { rows.push({ source: name, + sourceTitle: nameTitle, dest: peerName, + destTitle: peerTitle, txChannel: ch.tx_channel, rxChannel: ch.rx_channel, type: ch.type || '', @@ -233,7 +237,7 @@ export function renderDanteTable() { }); }); if (!peer.channels || peer.channels.length === 0) { - rows.push({ source: name, dest: peerName, txChannel: '', rxChannel: 0, type: '', status: 'active' }); + rows.push({ source: name, sourceTitle: nameTitle, dest: peerName, destTitle: peerTitle, txChannel: '', rxChannel: 0, type: '', status: 'active' }); } }); }); @@ -252,9 +256,9 @@ export function renderDanteTable() { rows.forEach(r => { const statusClass = r.status === 'no-source' ? 'status-warn' : 'status-ok'; html += ''; - html += '' + escapeHtml(r.source) + ''; + html += '' + escapeHtml(r.source) + ''; html += '' + escapeHtml(r.txChannel) + ''; - html += '' + escapeHtml(r.dest) + ''; + html += '' + escapeHtml(r.dest) + ''; html += '' + (r.rxChannel || '') + ''; html += '' + escapeHtml(r.type) + ''; html += '' + escapeHtml(r.status) + ''; @@ -271,14 +275,15 @@ export function renderArtnetTable() { const rxByUniverse = new Map(); nodes.forEach(node => { - const name = getLabel(node); + const name = getFirstName(node); + const title = getLabel(node); (node.artnet_inputs || []).forEach(u => { if (!txByUniverse.has(u)) txByUniverse.set(u, []); - txByUniverse.get(u).push(name); + txByUniverse.get(u).push({ name, title }); }); (node.artnet_outputs || []).forEach(u => { if (!rxByUniverse.has(u)) rxByUniverse.set(u, []); - rxByUniverse.get(u).push(name); + rxByUniverse.get(u).push({ name, title }); }); }); @@ -292,8 +297,10 @@ export function renderArtnetTable() { rows.push({ universe: u, universeStr: formatUniverse(u), - tx: txNodes[i] || '', - rx: rxNodes[i] || '' + tx: txNodes[i]?.name || '', + txTitle: txNodes[i]?.title || '', + rx: rxNodes[i]?.name || '', + rxTitle: rxNodes[i]?.title || '' }); } }); @@ -308,9 +315,9 @@ export function renderArtnetTable() { rows.forEach(r => { html += ''; - html += '' + escapeHtml(r.tx) + ''; + html += '' + escapeHtml(r.tx) + ''; html += '' + r.universeStr + ''; - html += '' + escapeHtml(r.rx) + ''; + html += '' + escapeHtml(r.rx) + ''; html += ''; }); @@ -324,24 +331,26 @@ export function renderSacnTable() { const rxByUniverse = new Map(); nodes.forEach(node => { - const name = getLabel(node); + const name = getFirstName(node); + const title = getLabel(node); (node.sacn_outputs || []).forEach(u => { if (!txByUniverse.has(u)) txByUniverse.set(u, []); - txByUniverse.get(u).push(name); + txByUniverse.get(u).push({ name, title }); }); (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); + rxByUniverse.get(u).push({ name, title }); } } }); (node.sacn_unicast_inputs || []).forEach(u => { if (!rxByUniverse.has(u)) rxByUniverse.set(u, []); - if (!rxByUniverse.get(u).includes(name)) { - rxByUniverse.get(u).push(name); + const existing = rxByUniverse.get(u); + if (!existing.some(e => e.name === name)) { + existing.push({ name, title }); } }); }); @@ -355,8 +364,10 @@ export function renderSacnTable() { for (let i = 0; i < maxLen; i++) { rows.push({ universe: u, - tx: txNodes[i] || '', - rx: rxNodes[i] || '' + tx: txNodes[i]?.name || '', + txTitle: txNodes[i]?.title || '', + rx: rxNodes[i]?.name || '', + rxTitle: rxNodes[i]?.title || '' }); } }); @@ -371,9 +382,9 @@ export function renderSacnTable() { rows.forEach(r => { html += ''; - html += '' + escapeHtml(r.tx) + ''; + html += '' + escapeHtml(r.tx) + ''; html += '' + r.universe + ''; - html += '' + escapeHtml(r.rx) + ''; + html += '' + escapeHtml(r.rx) + ''; html += ''; }); diff --git a/static/js/ui.js b/static/js/ui.js index 31b026d..0a92ade 100644 --- a/static/js/ui.js +++ b/static/js/ui.js @@ -34,7 +34,7 @@ export function buildClickableList(container, items, label, plainFormat, flowInf val.addEventListener('click', (e) => { e.stopPropagation(); if (flowInfo && flowInfo.universes && flowInfo.universes[idx] !== undefined) { - openFlowHash(flowInfo.protocol, flowInfo.universes[idx], flowInfo.nodeId); + openFlowHash(flowInfo.protocol, flowInfo.universes[idx], flowInfo.nodeName); } else { navigator.clipboard.writeText(item); } @@ -69,10 +69,10 @@ export function buildLinkStats(container, portLabel, speed, errIn, errOut, rates }); } -export function buildDanteDetail(container, entries, arrow, sourceNodeId, peerNodeIds) { +export function buildDanteDetail(container, entries, arrow, sourceNodeName, peerNodeNames) { const plainLines = []; entries.forEach((entry, entryIdx) => { - const peerNodeId = peerNodeIds ? peerNodeIds[entryIdx] : null; + const peerNodeName = peerNodeNames ? peerNodeNames[entryIdx] : null; entry.split('\n').forEach((line, lineIdx) => { if (entryIdx > 0 && lineIdx === 0) { container.appendChild(document.createTextNode('\n\n')); @@ -81,8 +81,8 @@ export function buildDanteDetail(container, entries, arrow, sourceNodeId, peerNo container.appendChild(document.createTextNode('\n')); } if (line.startsWith(' ')) { - container.appendChild(document.createTextNode(' ' + line.trim())); - plainLines.push(' ' + line.trim()); + container.appendChild(document.createTextNode(' ' + line.trim())); + plainLines.push(' ' + line.trim()); } else { const lbl = document.createElement('span'); lbl.className = 'lbl'; @@ -94,9 +94,9 @@ export function buildDanteDetail(container, entries, arrow, sourceNodeId, peerNo val.textContent = line; val.addEventListener('click', (e) => { e.stopPropagation(); - if (sourceNodeId && peerNodeId) { - const src = arrow === '→' ? sourceNodeId : peerNodeId; - const dst = arrow === '→' ? peerNodeId : sourceNodeId; + if (sourceNodeName && peerNodeName) { + const src = arrow === '→' ? sourceNodeName : peerNodeName; + const dst = arrow === '→' ? peerNodeName : sourceNodeName; openFlowHash('dante', src, 'to', dst); } else { navigator.clipboard.writeText(line); diff --git a/static/style.css b/static/style.css index 25e0ad7..72b4639 100644 --- a/static/style.css +++ b/static/style.css @@ -379,6 +379,37 @@ body.table-view #table-container { color: #f44; } +.data-table td[data-tooltip] { + position: relative; + cursor: default; +} + +.data-table td[data-tooltip]::after { + content: attr(data-tooltip); + position: absolute; + left: 0; + top: 100%; + margin-top: 4px; + background: #000; + color: #fff; + padding: 6px 10px; + border-radius: 4px; + font-size: 12px; + white-space: pre; + pointer-events: none; + z-index: 1000; + box-shadow: 0 2px 8px rgba(0,0,0,0.5); + border: 1px solid #555; + opacity: 0; + visibility: hidden; + transition: opacity 0.15s; +} + +.data-table td[data-tooltip]:hover::after { + opacity: 1; + visibility: visible; +} + body.dante-mode .node { opacity: 0.3; } @@ -851,14 +882,22 @@ body.sacn-mode .node.sacn-out.sacn-in .sacn-in-hover .sacn-detail-wrapper { display: flex; flex-direction: column; gap: 4px; - margin-left: 20px; + margin-top: 8px; padding: 8px 12px; background: #1a1a2e; border-radius: 6px; - border-left: 3px solid #5a5aff; + box-shadow: 0 0 0 2px #5a5aff; +} + +.flow-artmap-mappings.before-node { + margin-top: 0; + margin-bottom: 8px; } .artmap-mapping { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 6px; font-size: 11px; color: #aaf; cursor: pointer; @@ -866,6 +905,14 @@ body.sacn-mode .node.sacn-out.sacn-in .sacn-in-hover .sacn-detail-wrapper { border-radius: 3px; } +.artmap-mapping .from { + text-align: right; +} + +.artmap-mapping .to { + text-align: left; +} + .artmap-mapping:hover { background: #2a2a4e; color: #ccf;