diff --git a/public/nodes.js b/public/nodes.js index b6084a6c..7be18556 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -491,9 +491,11 @@ let directNode = null; // set when navigating directly to #/nodes/:pubkey let regionChangeHandler = null; + let packetCountHelpCleanup = null; function init(app, routeParam) { directNode = routeParam || null; + packetCountHelpCleanup = setupPacketCountHelp(app); if (directNode) { // Full-screen single node view (desktop + mobile). @@ -634,6 +636,75 @@ return nodeData; } + function renderPacketCountLabel(id) { + return ``; + } + + function setupPacketCountHelp(app) { + let activeHelp = null; + let frame = 0; + + function position() { + if (!activeHelp || !activeHelp.isConnected) { activeHelp = null; return; } + const tip = activeHelp.querySelector('.sort-help-tip'); + if (!tip.offsetHeight) return; + const trigger = activeHelp.getBoundingClientRect(); + const bounds = activeHelp.closest('#nodesRight, #nodeFullBody').getBoundingClientRect(); + const top = Math.max(0, bounds.top); + const bottom = Math.min(window.innerHeight, bounds.bottom); + const left = Math.max(0, bounds.left); + const right = Math.min(window.innerWidth, bounds.right); + if (trigger.bottom <= top || trigger.top >= bottom) { + activeHelp.classList.add('help-dismissed'); + return; + } + tip.style.maxWidth = (right - left) + 'px'; + tip.style.left = Math.max(left - trigger.left, Math.min(0, right - trigger.left - tip.offsetWidth)) + 'px'; + const height = tip.offsetHeight; + const preferredTop = trigger.top - height < top ? trigger.bottom : trigger.top - height; + tip.style.top = (Math.max(top, Math.min(preferredTop, bottom - height)) - trigger.top) + 'px'; + tip.style.bottom = 'auto'; + } + + function open(event) { + const help = event.target.closest('.node-packet-count-help'); + if (!help || help.contains(event.relatedTarget)) return; + activeHelp = help; + help.classList.remove('help-dismissed'); + if (event.type === 'pointerover') help.classList.toggle('help-touch', event.pointerType === 'touch'); + position(); + } + + function schedule() { + if (!activeHelp || frame) return; + frame = requestAnimationFrame(() => { frame = 0; position(); }); + } + + function dismiss(event) { + if (event.key !== 'Escape' || !activeHelp || !activeHelp.isConnected || + !activeHelp.querySelector('.sort-help-tip').offsetHeight) return; + activeHelp.classList.add('help-dismissed'); + event.preventDefault(); + event.stopImmediatePropagation(); + } + + app.addEventListener('pointerover', open); + app.addEventListener('focusin', open); + app.addEventListener('scroll', schedule, true); + window.addEventListener('resize', schedule); + // Capture also dismisses hover-only help before the page's Escape shortcuts. + document.addEventListener('keydown', dismiss, true); + return () => { + app.removeEventListener('pointerover', open); + app.removeEventListener('focusin', open); + app.removeEventListener('scroll', schedule, true); + window.removeEventListener('resize', schedule); + document.removeEventListener('keydown', dismiss, true); + cancelAnimationFrame(frame); + activeHelp = null; + }; + } + async function loadFullNode(pubkey) { const body = document.getElementById('nodeFullBody'); try { @@ -739,7 +810,7 @@ ${(n.role === 'repeater' || n.role === 'room') && Array.isArray(n.transported_scopes) && n.transported_scopes.length ? `Transported scopes${n.transported_scopes.map(sc => '' + escapeHtml(String(sc)) + '').join('')}` : ''} ${'configured_scope' in n && n.configured_scope !== null ? `Configured scope ${n.configured_scope === '' ? 'none configured' : `${escapeHtml(n.configured_scope)}`}` : ''} First Seen${renderNodeTimestampHtml(n.first_seen)} - Total Packets${stats.totalTransmissions || stats.totalPackets || n.advert_count || 0}${stats.totalObservations && stats.totalObservations !== (stats.totalTransmissions || stats.totalPackets) ? ' (seen ' + stats.totalObservations + '×)' : ''} + ${renderPacketCountLabel('fullNodePacketCountHelp')}${stats.totalTransmissions || stats.totalPackets || n.advert_count || 0}${stats.totalObservations && stats.totalObservations !== (stats.totalTransmissions || stats.totalPackets) ? ' (seen ' + stats.totalObservations + '×)' : ''} Packets Today${stats.packetsToday || 0} ${stats.avgHops ? `Avg Hops${stats.avgHops}` : ''} ${hasLoc ? `Location${Number(n.lat).toFixed(5)}, ${Number(n.lon).toFixed(5)}` : ''} @@ -1075,6 +1146,7 @@ } function destroy() { + if (packetCountHelpCleanup) { packetCountHelpCleanup(); packetCountHelpCleanup = null; } if (wsHandler) offWS(wsHandler); wsHandler = null; removeDetailMap(); @@ -1732,7 +1804,7 @@
Last Heard
${renderNodeTimestampHtml(lastHeard || n.last_seen)}
First Seen
${renderNodeTimestampHtml(n.first_seen)}
-
Total Packets
${totalPackets}
+
${renderPacketCountLabel('nodePacketCountHelp')}
${totalPackets}
Packets Today
${stats.packetsToday || 0}
${stats.avgHops ? `
Avg Hops
${stats.avgHops}
` : ''} ${hasLoc ? `
Location
${Number(n.lat).toFixed(5)}, ${Number(n.lon).toFixed(5)}
` : ''} diff --git a/public/style.css b/public/style.css index 2ccbe009..00d27844 100644 --- a/public/style.css +++ b/public/style.css @@ -1081,6 +1081,17 @@ img.brand-logo { box-shadow: 0 4px 12px rgba(0,0,0,.3); pointer-events: none; } .sort-help:hover .sort-help-tip { display: block; } +.node-packet-count-help { + border: 0; padding: 0; background: none; color: inherit; font: inherit; + text-align: left; text-transform: inherit; letter-spacing: inherit; +} +.node-packet-count-help .sort-help-tip { + top: auto; bottom: 100%; left: 0; transform: none; pointer-events: auto; + font-weight: normal; text-transform: none; letter-spacing: normal; +} +.node-packet-count-help.help-dismissed .sort-help-tip { display: none; } +/* Avoid touch target expansion redirecting nearby table-cell taps into the help button. */ +.node-packet-count-help.help-touch .sort-help-tip { pointer-events: none; } .filter-bar .btn:hover { background: var(--row-hover); } .filter-bar .btn.active { background: var(--accent-strong); color: var(--text-on-accent); border-color: var(--accent-strong); } .filter-bar .col-toggle-btn { height: 34px; min-height: 34px; } @@ -3413,7 +3424,7 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); } .node-top-row .node-qr-wrap { flex: 1; align-self: flex-start; min-width: 120px; max-width: 160px; display: flex; flex-direction: column; align-items: center; justify-content: center; background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 8px; } .node-top-row .node-qr-wrap .node-qr { margin-top: 0; } .node-qr-wrap--full { max-width: 240px; margin: 0 auto; } -.node-stats-table { width: 100%; border-collapse: collapse; font-size: 13px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; margin-bottom: 12px; } +.node-stats-table { width: 100%; border-collapse: collapse; font-size: 13px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; overflow: visible; margin-bottom: 12px; } .node-stats-table td { padding: 6px 12px; border-bottom: 1px solid var(--border); } .node-stats-table tr:last-child td { border-bottom: none; } .node-stats-table tr:nth-child(even) { background: var(--row-stripe); } diff --git a/tests/e2e/test-e2e-playwright.js b/tests/e2e/test-e2e-playwright.js index 920d4869..44984552 100644 --- a/tests/e2e/test-e2e-playwright.js +++ b/tests/e2e/test-e2e-playwright.js @@ -66,6 +66,159 @@ async function run() { console.log(`\nRunning E2E tests against ${BASE}\n`); + for (const [view, width, full, interaction] of [ + ['desktop side pane', 1280, false], + ['desktop full view', 1280, true], + ['mobile full view', 375, true], + ].flatMap(([view, width, full]) => + ['', 'Escape', 'scroll edges', ...(width < 640 ? [] : ['hover Escape', 'hover transfer'])] + .map(interaction => [view, width, full, interaction]))) { + await test(`#2131 packet count help${interaction ? ' ' + interaction : ''} in ${view}`, async () => { + const height = interaction === 'scroll edges' ? 600 : (width < 640 ? 667 : 900); + const fixtureContext = await browser.newContext({ + viewport: { width, height }, hasTouch: width < 640, + }); + const fixturePage = await fixtureContext.newPage(); + await fixturePage.addInitScript(() => { + window.addEventListener('theme-refresh', () => { window.__packetCountThemeReady = true; }, { once: true }); + }); + const pubkey = 'b'.repeat(64); + const node = { public_key: pubkey, name: 'Packet count fixture', role: 'repeater', + last_seen: new Date().toISOString(), advert_count: 900 }; + const stats = { totalTransmissions: 233, totalPackets: 500, totalObservations: 12278 }; + // Keep enough real detail content below the metric to scroll it to either edge. + const recentAdverts = Array.from({ length: 12 }, (_, i) => ({ hash: i.toString(16).padStart(64, '0'), + timestamp: new Date(Date.now() - i * 60000).toISOString(), payload_type: 4, route_type: 1 })); + await fixturePage.route('**/api/nodes**', async route => { + const path = new URL(route.request().url()).pathname; + let body; + if (path === '/api/nodes') body = { nodes: [node], total: 1 }; + else if (path === '/api/nodes/' + pubkey) body = { node, recentAdverts }; + else if (path === '/api/nodes/' + pubkey + '/health') body = { stats }; + else return route.continue(); + await route.fulfill({ json: body }); + }); + try { + // Initial theme refresh rebuilds node details; finish it before testing focus. + await fixturePage.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded' }); + await fixturePage.waitForFunction(() => window.__packetCountThemeReady); + await fixturePage.goto(`${BASE}/#/nodes${full ? '/' + pubkey : ''}`, { waitUntil: 'domcontentloaded' }); + if (!full) await fixturePage.locator(`tr[data-key="${pubkey}"]`).click(); + const label = fixturePage.locator(full ? '#nodeFullBody td:first-child' : '#nodesRight dt') + .filter({ hasText: 'Total Packets' }); + await label.waitFor(); + const count = await label.evaluate(el => el.nextElementSibling.innerText.trim()); + assert(count === (full ? '233 (seen 12278×)' : '233'), `Packet counts changed: ${count}`); + + const help = label.getByRole('button', { name: 'Total Packets help', exact: true }); + assert(await help.count() === 1, `Total Packets help is missing in ${view}`); + const descriptionId = await help.getAttribute('aria-describedby'); + assert(descriptionId, 'Packet count help must have an accessible description'); + const tooltip = fixturePage.locator('#' + descriptionId); + assert(await tooltip.count() === 1, 'Packet count description must reference one tooltip'); + await fixturePage.mouse.move(0, 0); + assert(!await tooltip.isVisible(), 'Packet count tooltip should start closed'); + if (width < 640) { + await help.tap(); + } else { + await help.hover(); + assert(await tooltip.isVisible(), 'Packet count help must open on hover'); + await fixturePage.mouse.move(0, 0); + await help.focus(); + await fixturePage.keyboard.press('Shift+Tab'); + await fixturePage.keyboard.press('Tab'); + assert(await help.evaluate(el => el === document.activeElement), 'Packet count help must be keyboard reachable'); + } + assert(await tooltip.isVisible(), `Packet count help is not visible after ${width < 640 ? 'tap' : 'keyboard focus'}`); + const explanation = await tooltip.innerText(); + assert(/distinct transmissions/i.test(explanation), 'Help must explain the Total Packets count'); + assert(/originator/i.test(explanation) && /destination/i.test(explanation) && /resolved relay/i.test(explanation), + 'Help must explain that counted traffic can involve this node in different roles'); + assert(/seen/i.test(explanation) && /one transmission can have multiple observations/i.test(explanation), + 'Help must explain why seen observations can exceed transmissions'); + const box = await tooltip.boundingBox(); + assert(box && box.x >= 0 && box.x + box.width <= width && box.y >= 0 && box.y + box.height <= height, + `Packet count help is clipped in ${view}`); + const clippedBy = await tooltip.evaluate(el => { + const rect = el.getBoundingClientRect(); + for (let parent = el.parentElement; parent; parent = parent.parentElement) { + const style = getComputedStyle(parent); + const bounds = parent.getBoundingClientRect(); + if ((/hidden|auto|scroll|clip/.test(style.overflowX) && (rect.left < bounds.left - 1 || rect.right > bounds.right + 1)) || + (/hidden|auto|scroll|clip/.test(style.overflowY) && (rect.top < bounds.top - 1 || rect.bottom > bounds.bottom + 1))) { + return parent.id || parent.className || parent.tagName; + } + } + return null; + }); + assert(!clippedBy, `Packet count help is clipped by ${clippedBy} in ${view}`); + if (width < 640) await label.locator('..').locator('td').nth(1).tap(); + else await fixturePage.keyboard.press('Tab'); + assert(!await tooltip.isVisible(), 'Packet count help must close when focus or touch moves away'); + + if (interaction === 'Escape' || interaction === 'hover Escape') { + const selectedUrl = fixturePage.url(); + if (interaction === 'Escape') await help.focus(); + else await help.hover(); + const focusedBeforeEscape = await fixturePage.evaluateHandle(() => document.activeElement); + assert(await tooltip.isVisible(), 'Help must be open before Escape'); + await fixturePage.keyboard.press('Escape'); + assert(await help.count() === 1, `Escape dismissed the selected node in ${view}`); + assert(fixturePage.url() === selectedUrl, `Escape changed the selected node URL in ${view}`); + assert(await focusedBeforeEscape.evaluate(el => el === document.activeElement), 'Escape must preserve the current focus'); + assert(!await tooltip.isVisible(), 'Escape must dismiss the packet count help'); + await fixturePage.keyboard.press('Escape'); + await fixturePage.waitForFunction(() => location.hash === '#/nodes'); + await help.waitFor({ state: 'detached' }); + assert(await help.count() === 0, 'A second Escape must retain normal node dismissal'); + if (!full) assert(await fixturePage.locator('#nodesRight.empty').count() === 1, 'A second Escape must clear the side pane'); + } + + if (interaction === 'scroll edges') { + const container = fixturePage.locator(full ? '#nodeFullBody' : '#nodesRight'); + for (const edge of ['top', 'bottom']) { + await help.evaluate((el, edge) => { + const scroller = el.closest('#nodeFullBody, #nodesRight'); + const bounds = scroller.getBoundingClientRect(); + const trigger = el.getBoundingClientRect(); + const target = edge === 'top' ? bounds.top + 40 : bounds.bottom - trigger.height - 40; + scroller.scrollTop += trigger.top - target; + }, edge); + const trigger = await help.boundingBox(); + const bounds = await container.boundingBox(); + assert(trigger && bounds && trigger.y >= bounds.y && trigger.y + trigger.height <= bounds.y + bounds.height, + `Help trigger must remain visible near the ${edge} in ${view}`); + assert(edge === 'top' ? trigger.y - bounds.y <= 50 : bounds.y + bounds.height - trigger.y - trigger.height <= 50, + `Help trigger did not reach the ${edge} of the scroll container in ${view}`); + if (width < 640) await help.tap(); + else await help.hover(); + assert(await tooltip.isVisible(), `Help must open after scrolling near the ${edge} in ${view}`); + const tip = await tooltip.boundingBox(); + assert(tip && tip.x >= Math.max(0, bounds.x) && tip.x + tip.width <= Math.min(width, bounds.x + bounds.width) && + tip.y >= Math.max(0, bounds.y) && tip.y + tip.height <= Math.min(height, bounds.y + bounds.height), + `Packet count help is clipped at the scroll container ${edge} in ${view}: ${JSON.stringify({ tip, bounds })}`); + if (width < 640) await label.locator('..').locator('td').nth(1).tap(); + else await fixturePage.mouse.move(0, 0); + assert(!await tooltip.isVisible(), 'Help must close before testing the next scroll edge'); + } + } + + if (interaction === 'hover transfer') { + assert(!await help.evaluate(el => el === document.activeElement), 'Hover transfer must not rely on trigger focus'); + await help.hover(); + assert(await tooltip.isVisible(), 'Help must open before pointer transfer'); + const tip = await tooltip.boundingBox(); + await fixturePage.mouse.move(tip.x + tip.width / 2, tip.y + tip.height / 2, { steps: 20 }); + assert(await tooltip.isVisible(), `Help disappeared while the pointer moved into its explanation in ${view}`); + await fixturePage.mouse.move(0, 0); + assert(!await tooltip.isVisible(), 'Help must close when the pointer leaves both trigger and explanation'); + } + } finally { + await fixtureContext.close(); + } + }); + } + // API contract fixtures exercise both real node renderers at desktop/mobile sizes. for (const width of [1280, 375]) { await test(`#2073 recent adverts grouped in both node views at ${width}px`, async () => { diff --git a/tests/e2e/test-issue-1281-location-row-e2e.js b/tests/e2e/test-issue-1281-location-row-e2e.js index 420648aa..656d4a8f 100644 --- a/tests/e2e/test-issue-1281-location-row-e2e.js +++ b/tests/e2e/test-issue-1281-location-row-e2e.js @@ -230,7 +230,7 @@ async function findPacketDetailByType(page, predicate, maxRows = 40) { assert(text.includes(snr), observer.observer_name + ' must retain SNR ' + snr + ', got: ' + text); } for (const [label, value] of [['Total Packets', '1005'], ['Packets Today', '7'], ['Avg Hops', '2']]) { - const metric = stats.getByText(label, { exact: true }); + const metric = stats.locator(view.full ? 'td:first-child' : 'dt').filter({ hasText: label }); const actual = await metric.evaluate(el => el.nextElementSibling.textContent.trim()); assert(actual === value, label + ' must remain ' + value + ', got: ' + actual); }