Improve hover cards with wrapper pattern and consistent behavior

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ian Gulliver
2026-01-26 14:45:31 -08:00
parent dbf449c447
commit 0baa208b99
2 changed files with 156 additions and 70 deletions

View File

@@ -866,9 +866,9 @@ func (t *Tendrils) probeDanteDeviceWithPort(ip net.IP, port int) {
if sub.TxChannelName != "" { if sub.TxChannelName != "" {
typeStr := sub.ChannelType.String() typeStr := sub.ChannelType.String()
if typeStr != "" { 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 { } else {
channelInfo = fmt.Sprintf("%s->%02d", sub.TxChannelName, sub.RxChannel) channelInfo = fmt.Sprintf("%s%02d", sub.TxChannelName, sub.RxChannel)
} }
} }
sourceNode := t.nodes.GetOrCreateByName(txDeviceName) sourceNode := t.nodes.GetOrCreateByName(txDeviceName)

View File

@@ -141,14 +141,18 @@
border: 1px dashed #c9f; border: 1px dashed #c9f;
} }
.node .switch-port .error-info, .node .switch-port .link-stats-wrapper,
.node .uplink .error-info { .node .uplink .link-stats-wrapper {
display: none; display: none;
position: absolute; position: absolute;
bottom: 100%; bottom: 100%;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
margin-bottom: 4px; padding-bottom: 8px;
}
.node .switch-port .link-stats,
.node .uplink .link-stats {
font-size: 10px; font-size: 10px;
white-space: pre; white-space: pre;
text-align: left; text-align: left;
@@ -159,8 +163,7 @@
line-height: 1.4; line-height: 1.4;
} }
.link-stats .lbl,
.error-info .lbl,
.node-info .lbl, .node-info .lbl,
.dante-info .lbl, .dante-info .lbl,
.dante-detail .lbl, .dante-detail .lbl,
@@ -180,8 +183,8 @@
width: 120px; width: 120px;
} }
.node .switch-port:hover .error-info, .node .switch-port:hover .link-stats-wrapper,
.node .uplink:hover .error-info { .node .uplink:hover .link-stats-wrapper {
display: block; display: block;
will-change: transform; will-change: transform;
} }
@@ -355,13 +358,16 @@
background: #358; background: #358;
} }
.node .dante-info .dante-detail { .node .dante-info .dante-detail-wrapper {
display: none; display: none;
position: absolute; position: absolute;
bottom: 100%; bottom: 100%;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
margin-bottom: 4px; padding-bottom: 8px;
}
.node .dante-info .dante-detail {
font-size: 10px; font-size: 10px;
white-space: pre; white-space: pre;
text-align: left; text-align: left;
@@ -372,7 +378,6 @@
line-height: 1.4; line-height: 1.4;
} }
.node .dante-info::after { .node .dante-info::after {
content: ''; content: '';
position: absolute; position: absolute;
@@ -387,8 +392,9 @@
z-index: 100; z-index: 100;
} }
.node .dante-info:hover .dante-detail { .node .dante-info:hover .dante-detail-wrapper {
display: block; display: block;
will-change: transform;
} }
body.dante-mode .node.dante-tx .dante-info, body.dante-mode .node.dante-tx .dante-info,
@@ -405,11 +411,11 @@
bottom: -8px; 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; bottom: auto;
top: 100%; top: 100%;
margin-bottom: 0; padding-bottom: 0;
margin-top: 4px; padding-top: 8px;
} }
@@ -460,13 +466,16 @@
background: #245; background: #245;
} }
.node .artnet-info .artnet-detail { .node .artnet-info .artnet-detail-wrapper {
display: none; display: none;
position: absolute; position: absolute;
bottom: 100%; bottom: 100%;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
margin-bottom: 4px; padding-bottom: 8px;
}
.node .artnet-info .artnet-detail {
font-size: 10px; font-size: 10px;
white-space: pre; white-space: pre;
text-align: left; text-align: left;
@@ -477,7 +486,6 @@
line-height: 1.4; line-height: 1.4;
} }
.node .artnet-info::after { .node .artnet-info::after {
content: ''; content: '';
position: absolute; position: absolute;
@@ -492,8 +500,9 @@
z-index: 100; z-index: 100;
} }
.node .artnet-info:hover .artnet-detail { .node .artnet-info:hover .artnet-detail-wrapper {
display: block; display: block;
will-change: transform;
} }
body.artnet-mode .node.artnet-out .artnet-info, body.artnet-mode .node.artnet-out .artnet-info,
@@ -510,11 +519,11 @@
bottom: -8px; 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; bottom: auto;
top: 100%; top: 100%;
margin-bottom: 0; padding-bottom: 0;
margin-top: 4px; padding-top: 8px;
} }
@@ -549,13 +558,16 @@
z-index: 10; z-index: 10;
} }
.node .sacn-info .sacn-detail { .node .sacn-info .sacn-detail-wrapper {
display: none; display: none;
position: absolute; position: absolute;
bottom: 100%; bottom: 100%;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
margin-bottom: 4px; padding-bottom: 8px;
}
.node .sacn-info .sacn-detail {
font-size: 10px; font-size: 10px;
white-space: pre; white-space: pre;
text-align: left; text-align: left;
@@ -566,7 +578,6 @@
line-height: 1.4; line-height: 1.4;
} }
.node .sacn-info::after { .node .sacn-info::after {
content: ''; content: '';
position: absolute; position: absolute;
@@ -581,8 +592,9 @@
z-index: 100; z-index: 100;
} }
.node .sacn-info:hover .sacn-detail { .node .sacn-info:hover .sacn-detail-wrapper {
display: block; display: block;
will-change: transform;
} }
body.sacn-mode .node.sacn-consumer .sacn-info { body.sacn-mode .node.sacn-consumer .sacn-info {
@@ -610,56 +622,51 @@
z-index: 100; z-index: 100;
} }
.node .node-info { .node .node-info-wrapper {
display: none; display: none;
position: absolute; position: absolute;
top: 100%; top: 100%;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
margin-top: 4px; padding-top: 8px;
z-index: 1000;
}
.node .node-info {
background: #333; background: #333;
border: 1px solid #555; border: 1px solid #555;
border-radius: 6px; border-radius: 6px;
padding: 6px 8px; padding: 6px 8px;
font-size: 10px; font-size: 10px;
white-space: pre; white-space: pre;
z-index: 1000;
text-align: left; text-align: left;
line-height: 1.4; line-height: 1.4;
cursor: pointer; 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; display: block;
will-change: transform; 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; 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; 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; display: none;
} }
.node:has(.switch-port:hover) .node-info, .node:has(.switch-port:hover) .node-info-wrapper,
.node:has(.uplink:hover) .node-info, .node:has(.uplink:hover) .node-info-wrapper,
.node:has(.dante-info:hover) .node-info, .node:has(.dante-info:hover) .node-info-wrapper,
.node:has(.artnet-info:hover) .node-info, .node:has(.artnet-info:hover) .node-info-wrapper,
.node:has(.sacn-info:hover) .node-info { .node:has(.sacn-info:hover) .node-info-wrapper {
display: none; display: none;
} }
@@ -1137,18 +1144,28 @@
if (speedClass) portEl.classList.add(speedClass); if (speedClass) portEl.classList.add(speedClass);
const errIn = switchConnection.errors?.in || 0; const errIn = switchConnection.errors?.in || 0;
const errOut = switchConnection.errors?.out || 0; const errOut = switchConnection.errors?.out || 0;
const statsWrapper = document.createElement('div');
statsWrapper.className = 'link-stats-wrapper';
const statsInfo = document.createElement('div'); const statsInfo = document.createElement('div');
statsInfo.className = 'error-info'; statsInfo.className = 'link-stats';
let statsHtml = '<span class="lbl">LINK</span> ' + formatLinkSpeed(switchConnection.speed); let statsHtml = '<span class="lbl">LINK</span> ' + formatLinkSpeed(switchConnection.speed);
statsHtml += '\n<span class="lbl">ERR</span> RX ' + errIn + ' / TX ' + errOut; statsHtml += '\n<span class="lbl">ERR</span> RX ' + errIn + ' / TX ' + errOut;
let statsPlain = 'LINK: ' + formatLinkSpeed(switchConnection.speed);
statsPlain += '\nERR: RX ' + errIn + ' / TX ' + errOut;
if (switchConnection.rates) { if (switchConnection.rates) {
const r = switchConnection.rates; const r = switchConnection.rates;
statsHtml += '\n<span class="lbl">RX</span> ' + formatMbps(r.outBytes) + ' (' + formatPps(r.outPkts) + ')'; statsHtml += '\n<span class="lbl">RX</span> ' + formatMbps(r.outBytes) + ' (' + formatPps(r.outPkts) + ')';
statsHtml += '\n<span class="lbl">TX</span> ' + formatMbps(r.inBytes) + ' (' + formatPps(r.inPkts) + ')'; statsHtml += '\n<span class="lbl">TX</span> ' + 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.innerHTML = statsHtml;
statsInfo.addEventListener('click', (e) => e.stopPropagation()); statsInfo.addEventListener('click', (e) => {
portEl.appendChild(statsInfo); e.stopPropagation();
navigator.clipboard.writeText(statsPlain);
});
statsWrapper.appendChild(statsInfo);
portEl.appendChild(statsWrapper);
div.appendChild(portEl); div.appendChild(portEl);
} }
@@ -1156,6 +1173,8 @@
labelEl.textContent = getLabel(node); labelEl.textContent = getLabel(node);
div.appendChild(labelEl); div.appendChild(labelEl);
const nodeInfoWrapper = document.createElement('div');
nodeInfoWrapper.className = 'node-info-wrapper';
const nodeInfo = document.createElement('div'); const nodeInfo = document.createElement('div');
nodeInfo.className = 'node-info'; nodeInfo.className = 'node-info';
if (node.interfaces) { if (node.interfaces) {
@@ -1186,7 +1205,8 @@
} }
} }
if (nodeInfo.textContent) { if (nodeInfo.textContent) {
div.appendChild(nodeInfo); nodeInfoWrapper.appendChild(nodeInfo);
div.appendChild(nodeInfoWrapper);
} }
if (isSwitch(node) && uplinkInfo === 'ROOT') { if (isSwitch(node) && uplinkInfo === 'ROOT') {
@@ -1202,18 +1222,28 @@
if (speedClass) uplinkEl.classList.add(speedClass); if (speedClass) uplinkEl.classList.add(speedClass);
const errIn = uplinkInfo.errors?.in || 0; const errIn = uplinkInfo.errors?.in || 0;
const errOut = uplinkInfo.errors?.out || 0; const errOut = uplinkInfo.errors?.out || 0;
const statsWrapper = document.createElement('div');
statsWrapper.className = 'link-stats-wrapper';
const statsInfo = document.createElement('div'); const statsInfo = document.createElement('div');
statsInfo.className = 'error-info'; statsInfo.className = 'link-stats';
let statsHtml = '<span class="lbl">LINK</span> ' + formatLinkSpeed(uplinkInfo.speed); let statsHtml = '<span class="lbl">LINK</span> ' + formatLinkSpeed(uplinkInfo.speed);
statsHtml += '\n<span class="lbl">ERR</span> RX ' + errIn + ' / TX ' + errOut; statsHtml += '\n<span class="lbl">ERR</span> RX ' + errIn + ' / TX ' + errOut;
let statsPlain = 'LINK: ' + formatLinkSpeed(uplinkInfo.speed);
statsPlain += '\nERR: RX ' + errIn + ' / TX ' + errOut;
if (uplinkInfo.rates) { if (uplinkInfo.rates) {
const r = uplinkInfo.rates; const r = uplinkInfo.rates;
statsHtml += '\n<span class="lbl">RX</span> ' + formatMbps(r.inBytes) + ' (' + formatPps(r.inPkts) + ')'; statsHtml += '\n<span class="lbl">RX</span> ' + formatMbps(r.inBytes) + ' (' + formatPps(r.inPkts) + ')';
statsHtml += '\n<span class="lbl">TX</span> ' + formatMbps(r.outBytes) + ' (' + formatPps(r.outPkts) + ')'; statsHtml += '\n<span class="lbl">TX</span> ' + 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.innerHTML = statsHtml;
statsInfo.addEventListener('click', (e) => e.stopPropagation()); statsInfo.addEventListener('click', (e) => {
uplinkEl.appendChild(statsInfo); e.stopPropagation();
navigator.clipboard.writeText(statsPlain);
});
statsWrapper.appendChild(statsInfo);
uplinkEl.appendChild(statsWrapper);
div.appendChild(uplinkEl); div.appendChild(uplinkEl);
} }
@@ -1221,7 +1251,10 @@
const txEl = document.createElement('div'); const txEl = document.createElement('div');
txEl.className = 'dante-info tx-info'; txEl.className = 'dante-info tx-info';
const firstDest = danteInfo.txTo[0].split('\n')[0]; const firstDest = danteInfo.txTo[0].split('\n')[0];
txEl.innerHTML = '<span class="lbl">→</span> ' + firstDest; const txMore = danteInfo.txTo.length > 1 ? ', ...' : '';
txEl.innerHTML = '<span class="lbl">→</span> ' + firstDest + txMore;
const detailWrapper = document.createElement('div');
detailWrapper.className = 'dante-detail-wrapper';
const detail = document.createElement('div'); const detail = document.createElement('div');
detail.className = 'dante-detail'; detail.className = 'dante-detail';
const txHtml = danteInfo.txTo.map(entry => { const txHtml = danteInfo.txTo.map(entry => {
@@ -1233,9 +1266,22 @@
return '<span class="lbl">→</span> ' + line; return '<span class="lbl">→</span> ' + line;
}).join('\n'); }).join('\n');
}).join('\n\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.innerHTML = txHtml;
detail.addEventListener('click', (e) => e.stopPropagation()); detail.addEventListener('click', (e) => {
txEl.appendChild(detail); e.stopPropagation();
navigator.clipboard.writeText(txPlain);
});
detailWrapper.appendChild(detail);
txEl.appendChild(detailWrapper);
div.appendChild(txEl); div.appendChild(txEl);
} }
@@ -1243,7 +1289,10 @@
const rxEl = document.createElement('div'); const rxEl = document.createElement('div');
rxEl.className = 'dante-info rx-info'; rxEl.className = 'dante-info rx-info';
const firstSource = danteInfo.rxFrom[0].split('\n')[0]; const firstSource = danteInfo.rxFrom[0].split('\n')[0];
rxEl.innerHTML = '<span class="lbl">←</span> ' + firstSource; const rxMore = danteInfo.rxFrom.length > 1 ? ', ...' : '';
rxEl.innerHTML = '<span class="lbl">←</span> ' + firstSource + rxMore;
const detailWrapper = document.createElement('div');
detailWrapper.className = 'dante-detail-wrapper';
const detail = document.createElement('div'); const detail = document.createElement('div');
detail.className = 'dante-detail'; detail.className = 'dante-detail';
const rxHtml = danteInfo.rxFrom.map(entry => { const rxHtml = danteInfo.rxFrom.map(entry => {
@@ -1255,45 +1304,82 @@
return '<span class="lbl">←</span> ' + line; return '<span class="lbl">←</span> ' + line;
}).join('\n'); }).join('\n');
}).join('\n\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.innerHTML = rxHtml;
detail.addEventListener('click', (e) => e.stopPropagation()); detail.addEventListener('click', (e) => {
rxEl.appendChild(detail); e.stopPropagation();
navigator.clipboard.writeText(rxPlain);
});
detailWrapper.appendChild(detail);
rxEl.appendChild(detailWrapper);
div.appendChild(rxEl); div.appendChild(rxEl);
} }
if (artnetInfo && artnetInfo.isOut) { if (artnetInfo && artnetInfo.isOut) {
const outEl = document.createElement('div'); const outEl = document.createElement('div');
outEl.className = 'artnet-info out-info'; outEl.className = 'artnet-info out-info';
outEl.innerHTML = '<span class="lbl">←</span> ' + artnetInfo.outputs[0]; const outMore = artnetInfo.outputs.length > 1 ? ', ...' : '';
outEl.innerHTML = '<span class="lbl">←</span> ' + artnetInfo.outputs[0] + outMore;
const detailWrapper = document.createElement('div');
detailWrapper.className = 'artnet-detail-wrapper';
const detail = document.createElement('div'); const detail = document.createElement('div');
detail.className = 'artnet-detail'; detail.className = 'artnet-detail';
detail.innerHTML = artnetInfo.outputs.map(u => '<span class="lbl">←</span> ' + u).join('\n'); detail.innerHTML = artnetInfo.outputs.map(u => '<span class="lbl">←</span> ' + u).join('\n');
detail.addEventListener('click', (e) => e.stopPropagation()); const outPlain = artnetInfo.outputs.map(u => '← ' + u).join('\n');
outEl.appendChild(detail); detail.addEventListener('click', (e) => {
e.stopPropagation();
navigator.clipboard.writeText(outPlain);
});
detailWrapper.appendChild(detail);
outEl.appendChild(detailWrapper);
div.appendChild(outEl); div.appendChild(outEl);
} }
if (artnetInfo && artnetInfo.isIn) { if (artnetInfo && artnetInfo.isIn) {
const inEl = document.createElement('div'); const inEl = document.createElement('div');
inEl.className = 'artnet-info in-info'; inEl.className = 'artnet-info in-info';
inEl.innerHTML = '<span class="lbl">→</span> ' + artnetInfo.inputs[0]; const inMore = artnetInfo.inputs.length > 1 ? ', ...' : '';
inEl.innerHTML = '<span class="lbl">→</span> ' + artnetInfo.inputs[0] + inMore;
const detailWrapper = document.createElement('div');
detailWrapper.className = 'artnet-detail-wrapper';
const detail = document.createElement('div'); const detail = document.createElement('div');
detail.className = 'artnet-detail'; detail.className = 'artnet-detail';
detail.innerHTML = artnetInfo.inputs.map(u => '<span class="lbl">→</span> ' + u).join('\n'); detail.innerHTML = artnetInfo.inputs.map(u => '<span class="lbl">→</span> ' + u).join('\n');
detail.addEventListener('click', (e) => e.stopPropagation()); const inPlain = artnetInfo.inputs.map(u => '→ ' + u).join('\n');
inEl.appendChild(detail); detail.addEventListener('click', (e) => {
e.stopPropagation();
navigator.clipboard.writeText(inPlain);
});
detailWrapper.appendChild(detail);
inEl.appendChild(detailWrapper);
div.appendChild(inEl); div.appendChild(inEl);
} }
if (sacnInfo && sacnInfo.isConsumer) { if (sacnInfo && sacnInfo.isConsumer) {
const sacnEl = document.createElement('div'); const sacnEl = document.createElement('div');
sacnEl.className = 'sacn-info'; sacnEl.className = 'sacn-info';
sacnEl.innerHTML = '<span class="lbl">←</span> ' + sacnInfo.universes[0]; const sacnMore = sacnInfo.universes.length > 1 ? ', ...' : '';
sacnEl.innerHTML = '<span class="lbl">←</span> ' + sacnInfo.universes[0] + sacnMore;
const detailWrapper = document.createElement('div');
detailWrapper.className = 'sacn-detail-wrapper';
const detail = document.createElement('div'); const detail = document.createElement('div');
detail.className = 'sacn-detail'; detail.className = 'sacn-detail';
detail.innerHTML = sacnInfo.universes.map(u => '<span class="lbl">←</span> ' + u).join('\n'); detail.innerHTML = sacnInfo.universes.map(u => '<span class="lbl">←</span> ' + u).join('\n');
detail.addEventListener('click', (e) => e.stopPropagation()); const sacnPlain = sacnInfo.universes.map(u => '← ' + u).join('\n');
sacnEl.appendChild(detail); detail.addEventListener('click', (e) => {
e.stopPropagation();
navigator.clipboard.writeText(sacnPlain);
});
detailWrapper.appendChild(detail);
sacnEl.appendChild(detailWrapper);
div.appendChild(sacnEl); div.appendChild(sacnEl);
} }
@@ -1644,7 +1730,7 @@
const net = (u >> 8) & 0x7f; const net = (u >> 8) & 0x7f;
const subnet = (u >> 4) & 0x0f; const subnet = (u >> 4) & 0x0f;
const universe = u & 0x0f; const universe = u & 0x0f;
return net + ':' + subnet + ':' + universe; return net + ':' + subnet + ':' + universe + ' (' + u + ')';
}; };
const inputs = (an.inputs || []).map(formatUniverse); const inputs = (an.inputs || []).map(formatUniverse);