From 0baa208b99cbddf376c0ca4e41926f4bf37ad37f Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Mon, 26 Jan 2026 14:45:31 -0800 Subject: [PATCH] Improve hover cards with wrapper pattern and consistent behavior Co-Authored-By: Claude Opus 4.5 --- dante.go | 4 +- static/index.html | 222 ++++++++++++++++++++++++++++++++-------------- 2 files changed, 156 insertions(+), 70 deletions(-) diff --git a/dante.go b/dante.go index 32c3dba..144891c 100644 --- a/dante.go +++ b/dante.go @@ -866,9 +866,9 @@ func (t *Tendrils) probeDanteDeviceWithPort(ip net.IP, port int) { if sub.TxChannelName != "" { typeStr := sub.ChannelType.String() if typeStr != "" { - channelInfo = fmt.Sprintf("%s->%02d:%s", sub.TxChannelName, sub.RxChannel, typeStr) + channelInfo = fmt.Sprintf("%s → %02d [%s]", sub.TxChannelName, sub.RxChannel, typeStr) } else { - channelInfo = fmt.Sprintf("%s->%02d", sub.TxChannelName, sub.RxChannel) + channelInfo = fmt.Sprintf("%s → %02d", sub.TxChannelName, sub.RxChannel) } } sourceNode := t.nodes.GetOrCreateByName(txDeviceName) diff --git a/static/index.html b/static/index.html index 05f4c81..f3e8a08 100644 --- a/static/index.html +++ b/static/index.html @@ -141,14 +141,18 @@ border: 1px dashed #c9f; } - .node .switch-port .error-info, - .node .uplink .error-info { + .node .switch-port .link-stats-wrapper, + .node .uplink .link-stats-wrapper { display: none; position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%); - margin-bottom: 4px; + padding-bottom: 8px; + } + + .node .switch-port .link-stats, + .node .uplink .link-stats { font-size: 10px; white-space: pre; text-align: left; @@ -159,8 +163,7 @@ line-height: 1.4; } - - .error-info .lbl, + .link-stats .lbl, .node-info .lbl, .dante-info .lbl, .dante-detail .lbl, @@ -180,8 +183,8 @@ width: 120px; } - .node .switch-port:hover .error-info, - .node .uplink:hover .error-info { + .node .switch-port:hover .link-stats-wrapper, + .node .uplink:hover .link-stats-wrapper { display: block; will-change: transform; } @@ -355,13 +358,16 @@ background: #358; } - .node .dante-info .dante-detail { + .node .dante-info .dante-detail-wrapper { display: none; position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%); - margin-bottom: 4px; + padding-bottom: 8px; + } + + .node .dante-info .dante-detail { font-size: 10px; white-space: pre; text-align: left; @@ -372,7 +378,6 @@ line-height: 1.4; } - .node .dante-info::after { content: ''; position: absolute; @@ -387,8 +392,9 @@ z-index: 100; } - .node .dante-info:hover .dante-detail { + .node .dante-info:hover .dante-detail-wrapper { display: block; + will-change: transform; } body.dante-mode .node.dante-tx .dante-info, @@ -405,11 +411,11 @@ bottom: -8px; } - body.dante-mode .node.dante-tx.dante-rx .dante-info.rx-info .dante-detail { + body.dante-mode .node.dante-tx.dante-rx .dante-info.rx-info .dante-detail-wrapper { bottom: auto; top: 100%; - margin-bottom: 0; - margin-top: 4px; + padding-bottom: 0; + padding-top: 8px; } @@ -460,13 +466,16 @@ background: #245; } - .node .artnet-info .artnet-detail { + .node .artnet-info .artnet-detail-wrapper { display: none; position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%); - margin-bottom: 4px; + padding-bottom: 8px; + } + + .node .artnet-info .artnet-detail { font-size: 10px; white-space: pre; text-align: left; @@ -477,7 +486,6 @@ line-height: 1.4; } - .node .artnet-info::after { content: ''; position: absolute; @@ -492,8 +500,9 @@ z-index: 100; } - .node .artnet-info:hover .artnet-detail { + .node .artnet-info:hover .artnet-detail-wrapper { display: block; + will-change: transform; } body.artnet-mode .node.artnet-out .artnet-info, @@ -510,11 +519,11 @@ bottom: -8px; } - body.artnet-mode .node.artnet-out.artnet-in .artnet-info.in-info .artnet-detail { + body.artnet-mode .node.artnet-out.artnet-in .artnet-info.in-info .artnet-detail-wrapper { bottom: auto; top: 100%; - margin-bottom: 0; - margin-top: 4px; + padding-bottom: 0; + padding-top: 8px; } @@ -549,13 +558,16 @@ z-index: 10; } - .node .sacn-info .sacn-detail { + .node .sacn-info .sacn-detail-wrapper { display: none; position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%); - margin-bottom: 4px; + padding-bottom: 8px; + } + + .node .sacn-info .sacn-detail { font-size: 10px; white-space: pre; text-align: left; @@ -566,7 +578,6 @@ line-height: 1.4; } - .node .sacn-info::after { content: ''; position: absolute; @@ -581,8 +592,9 @@ z-index: 100; } - .node .sacn-info:hover .sacn-detail { + .node .sacn-info:hover .sacn-detail-wrapper { display: block; + will-change: transform; } body.sacn-mode .node.sacn-consumer .sacn-info { @@ -610,56 +622,51 @@ z-index: 100; } - .node .node-info { + .node .node-info-wrapper { display: none; position: absolute; top: 100%; left: 50%; transform: translateX(-50%); - margin-top: 4px; + padding-top: 8px; + z-index: 1000; + } + + .node .node-info { background: #333; border: 1px solid #555; border-radius: 6px; padding: 6px 8px; font-size: 10px; white-space: pre; - z-index: 1000; text-align: left; line-height: 1.4; cursor: pointer; } - .node .node-info::before { - content: ''; - position: absolute; - bottom: 100%; - left: 0; - right: 0; - height: 8px; - } - .node:hover .node-info { + .node:hover .node-info-wrapper { display: block; will-change: transform; } - body.dante-mode .node:not(.dante-tx):not(.dante-rx):hover .node-info { + 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 { + body.artnet-mode .node:not(.artnet-out):not(.artnet-in):hover .node-info-wrapper { display: none; } - body.sacn-mode .node:not(.sacn-consumer):hover .node-info { + body.sacn-mode .node:not(.sacn-consumer):hover .node-info-wrapper { display: none; } - .node:has(.switch-port:hover) .node-info, - .node:has(.uplink:hover) .node-info, - .node:has(.dante-info:hover) .node-info, - .node:has(.artnet-info:hover) .node-info, - .node:has(.sacn-info:hover) .node-info { + .node:has(.switch-port:hover) .node-info-wrapper, + .node:has(.uplink:hover) .node-info-wrapper, + .node:has(.dante-info:hover) .node-info-wrapper, + .node:has(.artnet-info:hover) .node-info-wrapper, + .node:has(.sacn-info:hover) .node-info-wrapper { display: none; } @@ -1137,18 +1144,28 @@ if (speedClass) portEl.classList.add(speedClass); const errIn = switchConnection.errors?.in || 0; const errOut = switchConnection.errors?.out || 0; + const statsWrapper = document.createElement('div'); + statsWrapper.className = 'link-stats-wrapper'; const statsInfo = document.createElement('div'); - statsInfo.className = 'error-info'; + statsInfo.className = 'link-stats'; let statsHtml = 'LINK ' + formatLinkSpeed(switchConnection.speed); statsHtml += '\nERR RX ' + errIn + ' / TX ' + errOut; + let statsPlain = 'LINK: ' + formatLinkSpeed(switchConnection.speed); + statsPlain += '\nERR: RX ' + errIn + ' / TX ' + errOut; if (switchConnection.rates) { const r = switchConnection.rates; statsHtml += '\nRX ' + formatMbps(r.outBytes) + ' (' + formatPps(r.outPkts) + ')'; statsHtml += '\nTX ' + formatMbps(r.inBytes) + ' (' + formatPps(r.inPkts) + ')'; + statsPlain += '\nRX: ' + formatMbps(r.outBytes) + ' (' + formatPps(r.outPkts) + ')'; + statsPlain += '\nTX: ' + formatMbps(r.inBytes) + ' (' + formatPps(r.inPkts) + ')'; } statsInfo.innerHTML = statsHtml; - statsInfo.addEventListener('click', (e) => e.stopPropagation()); - portEl.appendChild(statsInfo); + statsInfo.addEventListener('click', (e) => { + e.stopPropagation(); + navigator.clipboard.writeText(statsPlain); + }); + statsWrapper.appendChild(statsInfo); + portEl.appendChild(statsWrapper); div.appendChild(portEl); } @@ -1156,6 +1173,8 @@ labelEl.textContent = getLabel(node); div.appendChild(labelEl); + const nodeInfoWrapper = document.createElement('div'); + nodeInfoWrapper.className = 'node-info-wrapper'; const nodeInfo = document.createElement('div'); nodeInfo.className = 'node-info'; if (node.interfaces) { @@ -1186,7 +1205,8 @@ } } if (nodeInfo.textContent) { - div.appendChild(nodeInfo); + nodeInfoWrapper.appendChild(nodeInfo); + div.appendChild(nodeInfoWrapper); } if (isSwitch(node) && uplinkInfo === 'ROOT') { @@ -1202,18 +1222,28 @@ if (speedClass) uplinkEl.classList.add(speedClass); const errIn = uplinkInfo.errors?.in || 0; const errOut = uplinkInfo.errors?.out || 0; + const statsWrapper = document.createElement('div'); + statsWrapper.className = 'link-stats-wrapper'; const statsInfo = document.createElement('div'); - statsInfo.className = 'error-info'; + statsInfo.className = 'link-stats'; let statsHtml = 'LINK ' + formatLinkSpeed(uplinkInfo.speed); statsHtml += '\nERR RX ' + errIn + ' / TX ' + errOut; + let statsPlain = 'LINK: ' + formatLinkSpeed(uplinkInfo.speed); + statsPlain += '\nERR: RX ' + errIn + ' / TX ' + errOut; if (uplinkInfo.rates) { const r = uplinkInfo.rates; statsHtml += '\nRX ' + formatMbps(r.inBytes) + ' (' + formatPps(r.inPkts) + ')'; statsHtml += '\nTX ' + formatMbps(r.outBytes) + ' (' + formatPps(r.outPkts) + ')'; + statsPlain += '\nRX: ' + formatMbps(r.inBytes) + ' (' + formatPps(r.inPkts) + ')'; + statsPlain += '\nTX: ' + formatMbps(r.outBytes) + ' (' + formatPps(r.outPkts) + ')'; } statsInfo.innerHTML = statsHtml; - statsInfo.addEventListener('click', (e) => e.stopPropagation()); - uplinkEl.appendChild(statsInfo); + statsInfo.addEventListener('click', (e) => { + e.stopPropagation(); + navigator.clipboard.writeText(statsPlain); + }); + statsWrapper.appendChild(statsInfo); + uplinkEl.appendChild(statsWrapper); div.appendChild(uplinkEl); } @@ -1221,7 +1251,10 @@ const txEl = document.createElement('div'); txEl.className = 'dante-info tx-info'; const firstDest = danteInfo.txTo[0].split('\n')[0]; - txEl.innerHTML = ' ' + firstDest; + const txMore = danteInfo.txTo.length > 1 ? ', ...' : ''; + txEl.innerHTML = ' ' + firstDest + txMore; + const detailWrapper = document.createElement('div'); + detailWrapper.className = 'dante-detail-wrapper'; const detail = document.createElement('div'); detail.className = 'dante-detail'; const txHtml = danteInfo.txTo.map(entry => { @@ -1233,9 +1266,22 @@ return ' ' + line; }).join('\n'); }).join('\n\n'); + const txPlain = danteInfo.txTo.map(entry => { + const lines = entry.split('\n'); + return lines.map(line => { + if (line.startsWith(' ')) { + return ' ' + line.trim(); + } + return '→ ' + line; + }).join('\n'); + }).join('\n\n'); detail.innerHTML = txHtml; - detail.addEventListener('click', (e) => e.stopPropagation()); - txEl.appendChild(detail); + detail.addEventListener('click', (e) => { + e.stopPropagation(); + navigator.clipboard.writeText(txPlain); + }); + detailWrapper.appendChild(detail); + txEl.appendChild(detailWrapper); div.appendChild(txEl); } @@ -1243,7 +1289,10 @@ const rxEl = document.createElement('div'); rxEl.className = 'dante-info rx-info'; const firstSource = danteInfo.rxFrom[0].split('\n')[0]; - rxEl.innerHTML = ' ' + firstSource; + const rxMore = danteInfo.rxFrom.length > 1 ? ', ...' : ''; + rxEl.innerHTML = ' ' + firstSource + rxMore; + const detailWrapper = document.createElement('div'); + detailWrapper.className = 'dante-detail-wrapper'; const detail = document.createElement('div'); detail.className = 'dante-detail'; const rxHtml = danteInfo.rxFrom.map(entry => { @@ -1255,45 +1304,82 @@ return ' ' + line; }).join('\n'); }).join('\n\n'); + const rxPlain = danteInfo.rxFrom.map(entry => { + const lines = entry.split('\n'); + return lines.map(line => { + if (line.startsWith(' ')) { + return ' ' + line.trim(); + } + return '← ' + line; + }).join('\n'); + }).join('\n\n'); detail.innerHTML = rxHtml; - detail.addEventListener('click', (e) => e.stopPropagation()); - rxEl.appendChild(detail); + detail.addEventListener('click', (e) => { + e.stopPropagation(); + navigator.clipboard.writeText(rxPlain); + }); + detailWrapper.appendChild(detail); + rxEl.appendChild(detailWrapper); div.appendChild(rxEl); } if (artnetInfo && artnetInfo.isOut) { const outEl = document.createElement('div'); outEl.className = 'artnet-info out-info'; - outEl.innerHTML = ' ' + artnetInfo.outputs[0]; + const outMore = artnetInfo.outputs.length > 1 ? ', ...' : ''; + outEl.innerHTML = ' ' + artnetInfo.outputs[0] + outMore; + const detailWrapper = document.createElement('div'); + detailWrapper.className = 'artnet-detail-wrapper'; const detail = document.createElement('div'); detail.className = 'artnet-detail'; detail.innerHTML = artnetInfo.outputs.map(u => ' ' + u).join('\n'); - detail.addEventListener('click', (e) => e.stopPropagation()); - outEl.appendChild(detail); + const outPlain = artnetInfo.outputs.map(u => '← ' + u).join('\n'); + detail.addEventListener('click', (e) => { + e.stopPropagation(); + navigator.clipboard.writeText(outPlain); + }); + detailWrapper.appendChild(detail); + outEl.appendChild(detailWrapper); div.appendChild(outEl); } if (artnetInfo && artnetInfo.isIn) { const inEl = document.createElement('div'); inEl.className = 'artnet-info in-info'; - inEl.innerHTML = ' ' + artnetInfo.inputs[0]; + const inMore = artnetInfo.inputs.length > 1 ? ', ...' : ''; + inEl.innerHTML = ' ' + artnetInfo.inputs[0] + inMore; + const detailWrapper = document.createElement('div'); + detailWrapper.className = 'artnet-detail-wrapper'; const detail = document.createElement('div'); detail.className = 'artnet-detail'; detail.innerHTML = artnetInfo.inputs.map(u => ' ' + u).join('\n'); - detail.addEventListener('click', (e) => e.stopPropagation()); - inEl.appendChild(detail); + const inPlain = artnetInfo.inputs.map(u => '→ ' + u).join('\n'); + detail.addEventListener('click', (e) => { + e.stopPropagation(); + navigator.clipboard.writeText(inPlain); + }); + detailWrapper.appendChild(detail); + inEl.appendChild(detailWrapper); div.appendChild(inEl); } if (sacnInfo && sacnInfo.isConsumer) { const sacnEl = document.createElement('div'); sacnEl.className = 'sacn-info'; - sacnEl.innerHTML = ' ' + sacnInfo.universes[0]; + const sacnMore = sacnInfo.universes.length > 1 ? ', ...' : ''; + sacnEl.innerHTML = ' ' + sacnInfo.universes[0] + sacnMore; + const detailWrapper = document.createElement('div'); + detailWrapper.className = 'sacn-detail-wrapper'; const detail = document.createElement('div'); detail.className = 'sacn-detail'; detail.innerHTML = sacnInfo.universes.map(u => ' ' + u).join('\n'); - detail.addEventListener('click', (e) => e.stopPropagation()); - sacnEl.appendChild(detail); + const sacnPlain = sacnInfo.universes.map(u => '← ' + u).join('\n'); + detail.addEventListener('click', (e) => { + e.stopPropagation(); + navigator.clipboard.writeText(sacnPlain); + }); + detailWrapper.appendChild(detail); + sacnEl.appendChild(detailWrapper); div.appendChild(sacnEl); } @@ -1644,7 +1730,7 @@ const net = (u >> 8) & 0x7f; const subnet = (u >> 4) & 0x0f; const universe = u & 0x0f; - return net + ':' + subnet + ':' + universe; + return net + ':' + subnet + ':' + universe + ' (' + u + ')'; }; const inputs = (an.inputs || []).map(formatUniverse);