From 29095658e97c3180e83dd5a25897683993c8f2cf Mon Sep 17 00:00:00 2001 From: n30nex Date: Thu, 8 Oct 2026 16:37:15 -0400 Subject: [PATCH] fix(nodes): explain packet and observation counts (#2132) Red commit: `d8a6d42` ([CI assertion](https://github.com/Kpa-clawbot/CoreScope/actions/runs/37695889461)). Review-regression red: `f73643b` ([CI assertion](https://github.com/Kpa-clawbot/CoreScope/actions/runs/37703995494)). Fixes #2131. Partial fix for #1154: U2 only; other audit items remain open. Both node views explain distinct transmissions versus recorded observations. The shared help is reachable by keyboard and touch, stays readable at either scroll edge, and remains open when the pointer enters its text. Escape dismisses help while preserving node context and focus; a second Escape keeps the existing navigation behavior. Count expressions and API requests are preserved. One controller per nodes page handles one active help element and one queued animation frame. Scroll/resize work is coalesced; listeners are removed on page teardown. Work is constant relative to packet/node counts, with no dataset scans or new settings/dependencies. E2E assertion added: `tests/e2e/test-e2e-playwright.js:114`. Thirteen cases cover exact counts, accessible description, keyboard/touch, hover transfer, Escape, and viewport/scroll bounds. Existing metric assertions are unchanged. Browser verified: http://localhost:63508 using Chromium, controlled fixtures and actual API data. Desktop/mobile screenshots retained locally; additional checks exercised resizing and repeated page cleanup. Local validation: 189 standalone suites; core browser 148 passed, 3 fixture-dependent skips (existing manual skips unchanged); location/metric suite 6 passed; syntax, theme variables and lint passed (0 errors, 92 existing warnings). ## Preflight overrides - OpenClaw preflight/profile unavailable; repository checks and local Chromium used. - Windows ingestor symlink fixture lacks permission. Backend files are unchanged; full Linux Go CI is required. --- public/nodes.js | 76 ++++++++- public/style.css | 13 +- tests/e2e/test-e2e-playwright.js | 153 ++++++++++++++++++ tests/e2e/test-issue-1281-location-row-e2e.js | 2 +- 4 files changed, 240 insertions(+), 4 deletions(-) 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); }