UI improvements: flow names, table tooltips, artmap mappings, sorting

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ian Gulliver
2026-01-30 23:27:45 -08:00
parent 587049616b
commit f97bf04eef
6 changed files with 144 additions and 62 deletions

View File

@@ -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();

View File

@@ -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,29 +334,51 @@ 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);
});
}
}
if (mappingsEl && isSourceNode) {
container.appendChild(mappingsEl);
}
container.appendChild(nodeEl);
if (mappingsEl && !isSourceNode) {
container.appendChild(mappingsEl);
}
});

View File

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

View File

@@ -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 += '<tr>';
html += '<td>' + escapeHtml(r.source) + '</td>';
html += '<td' + (r.sourceTitle !== r.source ? ' data-tooltip="' + escapeHtml(r.sourceTitle) + '"' : '') + '>' + escapeHtml(r.source) + '</td>';
html += '<td>' + escapeHtml(r.txChannel) + '</td>';
html += '<td>' + escapeHtml(r.dest) + '</td>';
html += '<td' + (r.destTitle !== r.dest ? ' data-tooltip="' + escapeHtml(r.destTitle) + '"' : '') + '>' + escapeHtml(r.dest) + '</td>';
html += '<td class="numeric">' + (r.rxChannel || '') + '</td>';
html += '<td>' + escapeHtml(r.type) + '</td>';
html += '<td class="' + statusClass + '">' + escapeHtml(r.status) + '</td>';
@@ -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 += '<tr>';
html += '<td>' + escapeHtml(r.tx) + '</td>';
html += '<td' + (r.txTitle && r.txTitle !== r.tx ? ' data-tooltip="' + escapeHtml(r.txTitle) + '"' : '') + '>' + escapeHtml(r.tx) + '</td>';
html += '<td>' + r.universeStr + '</td>';
html += '<td>' + escapeHtml(r.rx) + '</td>';
html += '<td' + (r.rxTitle && r.rxTitle !== r.rx ? ' data-tooltip="' + escapeHtml(r.rxTitle) + '"' : '') + '>' + escapeHtml(r.rx) + '</td>';
html += '</tr>';
});
@@ -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 += '<tr>';
html += '<td>' + escapeHtml(r.tx) + '</td>';
html += '<td' + (r.txTitle && r.txTitle !== r.tx ? ' data-tooltip="' + escapeHtml(r.txTitle) + '"' : '') + '>' + escapeHtml(r.tx) + '</td>';
html += '<td class="numeric">' + r.universe + '</td>';
html += '<td>' + escapeHtml(r.rx) + '</td>';
html += '<td' + (r.rxTitle && r.rxTitle !== r.rx ? ' data-tooltip="' + escapeHtml(r.rxTitle) + '"' : '') + '>' + escapeHtml(r.rx) + '</td>';
html += '</tr>';
});

View File

@@ -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'));
@@ -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);

View File

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