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